Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-14 20:45:28 +05:00
parent b73c5486d3
commit e80ade9558
13 changed files with 470 additions and 10 deletions

View file

@ -3,10 +3,7 @@ package com.tangem.tap.di.domain
import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.SwapTransactionRepository
import com.tangem.domain.swap.usecase.GetSwapPairsUseCase
import com.tangem.domain.swap.usecase.GetSwapQuoteUseCase
import com.tangem.domain.swap.usecase.GetSwapSupportedPairsUseCase
import com.tangem.domain.swap.usecase.SelectInitialPairUseCase
import com.tangem.domain.swap.usecase.*
import com.tangem.feature.swap.domain.GetAvailablePairsUseCase
import dagger.Module
import dagger.Provides
@ -73,4 +70,30 @@ internal object SwapDomainModule {
swapErrorResolver = swapErrorResolver,
)
}
@Provides
@Singleton
fun provideGetSwapDataUseCase(
swapRepositoryV2: SwapRepositoryV2,
swapErrorResolver: SwapErrorResolver,
): GetSwapDataUseCase {
return GetSwapDataUseCase(
swapRepositoryV2 = swapRepositoryV2,
swapErrorResolver = swapErrorResolver,
)
}
@Provides
@Singleton
fun provideSwapTransactionSentUseCase(
swapRepositoryV2: SwapRepositoryV2,
swapTransactionRepository: SwapTransactionRepository,
swapErrorResolver: SwapErrorResolver,
): SwapTransactionSentUseCase {
return SwapTransactionSentUseCase(
swapRepositoryV2 = swapRepositoryV2,
swapTransactionRepository = swapTransactionRepository,
swapErrorResolver = swapErrorResolver,
)
}
}

View file

@ -13,6 +13,7 @@ internal class DefaultSwapErrorResolver(
is ApiResponseError.HttpException -> {
expressErrorConverter.convert(throwable.errorBody.orEmpty())
}
is ExpressError -> throwable
else -> ExpressError.UnknownError
}
}

View file

@ -1,19 +1,28 @@
package com.tangem.data.swap
import com.squareup.moshi.Moshi
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.swap.converter.SwapDataConverter
import com.tangem.data.swap.converter.SwapStatusConverter
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.PairsRequestBody
import com.tangem.datasource.api.express.models.response.ExchangeDataResponseWithTxDetails
import com.tangem.datasource.api.express.models.response.TxDetails
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.exchangeservice.swap.ExpressUtils
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.models.SwapPairModel
import com.tangem.domain.swap.models.SwapQuoteModel
import com.tangem.domain.swap.models.*
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations
import com.tangem.domain.wallets.models.UserWallet
@ -24,19 +33,26 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.IOException
import java.math.BigDecimal
import java.util.UUID
import javax.inject.Inject
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class DefaultSwapRepositoryV2 @Inject constructor(
private val tangemExpressApi: TangemExpressApi,
private val expressRepository: ExpressRepository,
private val coroutineDispatcher: CoroutineDispatcherProvider,
private val appPreferencesStore: AppPreferencesStore,
private val currencyStatusOperations: BaseCurrencyStatusOperations,
private val dataSignatureVerifier: DataSignatureVerifier,
@NetworkMoshi moshi: Moshi,
) : SwapRepositoryV2 {
private val swapDataConverter = SwapDataConverter()
private val tokenInfoConverter = TokenInfoConverter()
private val exchangeStatusConverter = SwapStatusConverter()
private val txDetailsMoshiAdapter = moshi.adapter(TxDetails::class.java)
override suspend fun getPairs(
userWallet: UserWallet,
@ -153,6 +169,115 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
)
}
override suspend fun getSwapData(
userWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
toCryptoCurrencyStatus: CryptoCurrencyStatus,
fromAmount: String,
toAddress: String?,
expressProvider: ExpressProvider,
rateType: ExpressRateType,
): SwapDataModel = withContext(coroutineDispatcher.io) {
val requestId = UUID.randomUUID().toString()
val fromCryptoCurrency = fromCryptoCurrencyStatus.currency
val toCryptoCurrency = toCryptoCurrencyStatus.currency
val refundData = when (expressProvider.type) {
ExpressProviderType.CEX,
ExpressProviderType.DEX_BRIDGE,
ExpressProviderType.DEX,
-> SwapRefundData(
refundAddress = fromCryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value,
refundExtraId = null, // currently always null
)
else -> null
}
val response = tangemExpressApi.getExchangeData(
fromContractAddress = fromCryptoCurrency.getContractAddress(),
toContractAddress = toCryptoCurrency.getContractAddress(),
fromNetwork = fromCryptoCurrency.network.backendId,
toNetwork = toCryptoCurrency.network.backendId,
fromAddress = fromCryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value.orEmpty(),
toAddress = toAddress ?: toCryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value.orEmpty(),
fromDecimals = fromCryptoCurrency.decimals,
toDecimals = toCryptoCurrency.decimals,
fromAmount = fromAmount,
providerId = expressProvider.name,
rateType = rateType.name.lowercase(),
requestId = requestId,
refundAddress = refundData?.refundAddress,
refundExtraId = refundData?.refundExtraId,
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
).getOrThrow()
if (dataSignatureVerifier.verifySignature(response.signature, response.txDetailsJson)) {
val txDetails = parseTxDetails(response.txDetailsJson)
?: throw ExpressError.UnknownError
if (txDetails.requestId != requestId) {
throw ExpressError.InvalidRequestIdError()
}
if (!toAddress.equals(txDetails.payoutAddress, ignoreCase = true)) {
throw ExpressError.InvalidPayoutAddressError()
}
swapDataConverter.convert(
ExchangeDataResponseWithTxDetails(
dataResponse = response,
txDetails = txDetails,
),
)
} else {
throw ExpressError.InvalidSignatureError()
}
}
override suspend fun swapTransactionSent(
userWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
toAddress: String,
txId: String,
txHash: String,
txExtraId: String?,
) {
withContext(coroutineDispatcher.io) {
tangemExpressApi.exchangeSent(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
body = ExchangeSentRequestBody(
txId = txId,
fromNetwork = fromCryptoCurrencyStatus.currency.network.backendId,
fromAddress = fromCryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value.orEmpty(),
payinAddress = toAddress,
payinExtraId = txExtraId,
txHash = txHash,
),
).getOrThrow()
}
}
override suspend fun getExchangeStatus(userWallet: UserWallet, txId: String): SwapStatusModel =
withContext(coroutineDispatcher.io) {
exchangeStatusConverter.convert(
tangemExpressApi
.getExchangeStatus(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
txId = txId,
)
.getOrThrow(),
)
}
private suspend fun CoroutineScope.getPairsInternal(
userWallet: UserWallet,
initialCurrency: CryptoCurrency,
@ -200,7 +325,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
},
)
private suspend fun CoroutineScope.createPairModelOnly(
private suspend fun createPairModelOnly(
currencyFrom: CryptoCurrency?,
currencyTo: CryptoCurrency?,
userWalletId: UserWalletId,
@ -229,6 +354,15 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
}
}
private fun parseTxDetails(txDetailsJson: String): TxDetails? {
return try {
txDetailsMoshiAdapter.fromJson(txDetailsJson)
} catch (e: IOException) {
Timber.e(e, "error parsing txDetailsJson")
null
}
}
private fun CryptoCurrency.getContractAddress(): String {
return when (this) {
is CryptoCurrency.Token -> this.contractAddress

View file

@ -0,0 +1,67 @@
package com.tangem.data.swap.converter
import com.tangem.datasource.api.express.models.response.ExchangeDataResponse
import com.tangem.datasource.api.express.models.response.ExchangeDataResponseWithTxDetails
import com.tangem.datasource.api.express.models.response.TxDetails
import com.tangem.datasource.api.express.models.response.TxType
import com.tangem.domain.swap.models.SwapDataModel
import com.tangem.domain.swap.models.SwapDataTransactionModel
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class SwapDataConverter : Converter<ExchangeDataResponseWithTxDetails, SwapDataModel> {
override fun convert(value: ExchangeDataResponseWithTxDetails): SwapDataModel {
val data = value.dataResponse
return SwapDataModel(
toTokenAmount = requireNotNull(data.toAmount.toBigDecimalOrNull()?.movePointLeft(data.toDecimals)),
transaction = convertTransaction(value.txDetails, data),
)
}
private fun convertTransaction(
transactionDto: TxDetails,
dataResponse: ExchangeDataResponse,
): SwapDataTransactionModel {
val fromAmount = requireNotNull(
dataResponse.fromAmount.toBigDecimalOrNull()?.movePointLeft(dataResponse.fromDecimals),
)
val toAmount = requireNotNull(
dataResponse.toAmount.toBigDecimalOrNull()?.movePointLeft(dataResponse.toDecimals),
)
return if (transactionDto.txType == TxType.SWAP) {
val otherNativeFeeWei = transactionDto.otherNativeFee?.let {
if (it == "0") {
BigDecimal.ZERO
} else {
requireNotNull(it.toBigDecimalOrNull()) { "wrong amount format, use only digits" }
}
}
SwapDataTransactionModel.DEX(
fromAmount = fromAmount,
toAmount = toAmount,
txValue = transactionDto.txValue,
txId = dataResponse.txId,
txTo = transactionDto.txTo,
txFrom = requireNotNull(transactionDto.txFrom),
txData = requireNotNull(transactionDto.txData),
txExtraId = transactionDto.txExtraId,
otherNativeFeeWei = otherNativeFeeWei,
gas = transactionDto.gas?.toBigIntegerOrNull() ?: error("gas is empty"),
)
} else {
SwapDataTransactionModel.CEX(
fromAmount = fromAmount,
toAmount = toAmount,
txValue = transactionDto.txValue,
txId = dataResponse.txId,
txTo = transactionDto.txTo,
externalTxId = requireNotNull(transactionDto.externalTxId),
externalTxUrl = requireNotNull(transactionDto.externalTxUrl),
txExtraIdName = transactionDto.txExtraIdName,
txExtraId = transactionDto.txExtraId,
)
}
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.data.swap.DefaultSwapRepositoryV2
import com.tangem.data.swap.DefaultSwapTransactionRepository
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.express.ExpressRepository
@ -43,6 +44,8 @@ internal object SwapDataModule {
coroutineDispatcher: CoroutineDispatcherProvider,
appPreferencesStore: AppPreferencesStore,
currencyStatusOperations: BaseCurrencyStatusOperations,
dataSignatureVerifier: DataSignatureVerifier,
@NetworkMoshi moshi: Moshi,
): SwapRepositoryV2 {
return DefaultSwapRepositoryV2(
tangemExpressApi = tangemExpressApi,
@ -50,6 +53,8 @@ internal object SwapDataModule {
coroutineDispatcher = coroutineDispatcher,
appPreferencesStore = appPreferencesStore,
currencyStatusOperations = currencyStatusOperations,
dataSignatureVerifier = dataSignatureVerifier,
moshi = moshi,
)
}

View file

@ -2,7 +2,7 @@ package com.tangem.domain.express.models
import java.math.BigDecimal
sealed class ExpressError {
sealed class ExpressError : Throwable() {
abstract val code: Int

View file

@ -21,4 +21,16 @@ enum class ExpressProviderType(val typeName: String) {
@Json(name = "onramp")
ONRAMP(typeName = "ONRAMP"),
;
companion object {
fun ExpressProviderType.shouldStoreSwapTransaction() = when (this) {
CEX,
DEX_BRIDGE,
-> true
DEX,
ONRAMP,
-> false
}
}
}

View file

@ -23,9 +23,11 @@ dependencies {
/** Util */
implementation(projects.core.utils)
implementation(projects.core.datasource)
/** Other */
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(deps.jodatime)
}

View file

@ -0,0 +1,47 @@
package com.tangem.domain.swap.models
import java.math.BigDecimal
import java.math.BigInteger
data class SwapDataModel(
val toTokenAmount: BigDecimal,
val transaction: SwapDataTransactionModel,
)
sealed class SwapDataTransactionModel {
abstract val fromAmount: BigDecimal
abstract val toAmount: BigDecimal
abstract val txValue: String
abstract val txId: String
abstract val txTo: String
abstract val txExtraId: String?
/**
* @param txValue amount for tx, should use native coin decimals, this value will send as native amount in tx
*/
data class DEX(
override val fromAmount: BigDecimal,
override val toAmount: BigDecimal,
override val txValue: String,
override val txId: String,
override val txTo: String,
override val txExtraId: String?,
val txFrom: String,
val txData: String,
val otherNativeFeeWei: BigDecimal?,
val gas: BigInteger,
) : SwapDataTransactionModel()
data class CEX(
override val fromAmount: BigDecimal,
override val toAmount: BigDecimal,
override val txValue: String,
override val txId: String,
override val txTo: String,
override val txExtraId: String?,
val externalTxId: String,
val externalTxUrl: String,
val txExtraIdName: String?,
) : SwapDataTransactionModel()
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.swap.models
/**
* Refund data
*
* @param refundAddress address refund send to
* @param refundExtraId refund token id
*/
data class SwapRefundData(
val refundAddress: String? = null,
val refundExtraId: String? = null,
)

View file

@ -3,8 +3,10 @@ package com.tangem.domain.swap
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.swap.models.SwapDataModel
import com.tangem.domain.swap.models.SwapPairModel
import com.tangem.domain.swap.models.SwapQuoteModel
import com.tangem.domain.swap.models.SwapStatusModel
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
import java.math.BigDecimal
@ -12,6 +14,7 @@ import java.math.BigDecimal
/**
* Swap repository
*/
@Suppress("LongParameterList")
interface SwapRepositoryV2 {
/**
@ -44,7 +47,6 @@ interface SwapRepositoryV2 {
* @param provider selected express provider
* @param rateType rate type
*/
@Suppress("LongParameterList")
suspend fun getSwapQuote(
userWallet: UserWallet,
fromCryptoCurrency: CryptoCurrency,
@ -53,4 +55,52 @@ interface SwapRepositoryV2 {
provider: ExpressProvider,
rateType: ExpressRateType,
): SwapQuoteModel
/**
* Returns swap data [SwapDataModel] ready to sign and send on selected quote
*
* @param userWallet selected user wallet
* @param fromCryptoCurrencyStatus currency status being swapped from
* @param toCryptoCurrencyStatus currency status being swapped to
* @param fromAmount swap amount
* @param toAddress destination address (optional, if null send to self)
* @param expressProvider selected swap provider
* @param rateType selected provider rate type
*/
suspend fun getSwapData(
userWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
toCryptoCurrencyStatus: CryptoCurrencyStatus,
fromAmount: String,
toAddress: String?,
expressProvider: ExpressProvider,
rateType: ExpressRateType,
): SwapDataModel
/**
* Send ExpressApi info that swap transaction occurred
*
* @param userWallet selected user wallet
* @param fromCryptoCurrencyStatus currency status being swapped from
* @param toAddress swap destination address
* @param txId transaction id in ExpressApi
* @param txHash transaction hash in blockchain
* @param txExtraId extra transaction id in ExpressApi
*/
suspend fun swapTransactionSent(
userWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
toAddress: String,
txId: String,
txHash: String,
txExtraId: String?,
)
/**
* Returns status [SwapStatusModel] on active swap
*
* @param userWallet selected user wallet
* @param txId transaction id in ExpressApi
*/
suspend fun getExchangeStatus(userWallet: UserWallet, txId: String): SwapStatusModel
}

View file

@ -0,0 +1,40 @@
package com.tangem.domain.swap.usecase
import arrow.core.Either
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.models.SwapDataModel
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
@Suppress("LongParameterList")
class GetSwapDataUseCase(
private val swapRepositoryV2: SwapRepositoryV2,
private val swapErrorResolver: SwapErrorResolver,
) {
suspend operator fun invoke(
userWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
fromAmount: String,
toCryptoCurrencyStatus: CryptoCurrencyStatus,
toAddress: String?,
expressProvider: ExpressProvider,
rateType: ExpressRateType,
): Either<ExpressError, SwapDataModel> = Either.catch {
swapRepositoryV2.getSwapData(
userWallet = userWallet,
fromCryptoCurrencyStatus = fromCryptoCurrencyStatus,
fromAmount = fromAmount,
toCryptoCurrencyStatus = toCryptoCurrencyStatus,
toAddress = toAddress,
expressProvider = expressProvider,
rateType = rateType,
)
}.mapLeft { throwable ->
swapErrorResolver.resolve(throwable)
}
}

View file

@ -0,0 +1,67 @@
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.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.domain.swap.SwapTransactionRepository
import com.tangem.domain.swap.models.SwapDataTransactionModel
import com.tangem.domain.swap.models.SwapStatus
import com.tangem.domain.swap.models.SwapStatusModel
import com.tangem.domain.swap.models.SwapTransactionModel
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.wallets.models.UserWallet
@Suppress("LongParameterList")
class SwapTransactionSentUseCase(
private val swapRepositoryV2: SwapRepositoryV2,
private val swapTransactionRepository: SwapTransactionRepository,
private val swapErrorResolver: SwapErrorResolver,
) {
suspend operator fun invoke(
userWallet: UserWallet,
fromCryptoCurrencyStatus: CryptoCurrencyStatus,
toCryptoCurrencyStatus: CryptoCurrencyStatus,
swapDataTransactionModel: SwapDataTransactionModel,
provider: ExpressProvider,
txHash: String,
) = Either.catch {
swapRepositoryV2.swapTransactionSent(
userWallet = userWallet,
fromCryptoCurrencyStatus = fromCryptoCurrencyStatus,
toAddress = swapDataTransactionModel.txTo,
txId = swapDataTransactionModel.txId,
txHash = txHash,
txExtraId = swapDataTransactionModel.txExtraId,
)
if (provider.type.shouldStoreSwapTransaction()) {
val timestamp = System.currentTimeMillis()
swapTransactionRepository.storeTransaction(
userWalletId = userWallet.walletId,
fromCryptoCurrency = fromCryptoCurrencyStatus.currency,
toCryptoCurrency = toCryptoCurrencyStatus.currency,
transaction = SwapTransactionModel(
txId = swapDataTransactionModel.txId,
provider = provider,
timestamp = timestamp,
fromCryptoAmount = swapDataTransactionModel.fromAmount,
toCryptoAmount = swapDataTransactionModel.toAmount,
status = SwapStatusModel(
providerId = provider.providerId,
status = SwapStatus.New,
txId = swapDataTransactionModel.txId,
txExternalUrl = (swapDataTransactionModel as? SwapDataTransactionModel.CEX)?.externalTxUrl,
txExternalId = (swapDataTransactionModel as? SwapDataTransactionModel.CEX)?.externalTxId,
averageDuration = null,
),
),
)
}
swapTransactionRepository.storeLastSwappedCryptoCurrencyId(
userWalletId = userWallet.walletId,
cryptoCurrencyId = toCryptoCurrencyStatus.currency.id,
)
}.mapLeft(swapErrorResolver::resolve)
}