Updated on 2026-08-14

This commit is contained in:
Tangem 2023-01-23 17:01:05 +03:00
parent 8f78031374
commit 2756dc5687
24 changed files with 843 additions and 314 deletions

View file

@ -323,7 +323,9 @@ class DetailsMiddleware {
enableAccessCodesSaving: Boolean,
): CompletionResult<Unit> {
val userWallet = scanResponse?.let { UserWalletBuilder(it).build() }
?: return CompletionResult.Failure(TangemSdkError.ExceptionError(IllegalStateException()))
?: return CompletionResult.Failure(
TangemSdkError.ExceptionError(IllegalStateException("scanResponse is null")),
)
return userWalletsListManager.save(userWallet)
.flatMap {

View file

@ -32,10 +32,8 @@ class TransactionManagerImpl(
private val appStateHolder: AppStateHolder,
) : TransactionManager {
override suspend fun sendTransaction(
override suspend fun sendApproveTransaction(
networkId: String,
amountToSend: BigDecimal,
currencyToSend: Currency,
feeAmount: BigDecimal,
estimatedGas: Int,
destinationAddress: String,
@ -43,8 +41,58 @@ class TransactionManagerImpl(
): SendTxResult {
val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" }
val walletManager = getActualWalletManager(blockchain)
val amount = createAmount(amountToSend, currencyToSend, blockchain)
walletManager.update()
val amount = Amount(value = BigDecimal.ZERO, blockchain = blockchain)
return sendTransactionInternal(
walletManager = walletManager,
amount = amount,
blockchain = blockchain,
feeAmount = feeAmount,
estimatedGas = estimatedGas,
destinationAddress = destinationAddress,
dataToSign = dataToSign,
)
}
override suspend fun sendTransaction(
networkId: String,
amountToSend: BigDecimal,
feeAmount: BigDecimal,
estimatedGas: Int,
destinationAddress: String,
dataToSign: String,
isSwap: Boolean,
currencyToSend: Currency,
): SendTxResult {
val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" }
val walletManager = getActualWalletManager(blockchain)
walletManager.update()
val amount = if (isSwap) {
createAmountForSwap(amountToSend, currencyToSend, blockchain)
} else {
createAmount(amountToSend, currencyToSend, blockchain)
}
return sendTransactionInternal(
walletManager = walletManager,
amount = amount,
blockchain = blockchain,
feeAmount = feeAmount,
estimatedGas = estimatedGas,
destinationAddress = destinationAddress,
dataToSign = dataToSign,
)
}
@Suppress("LongParameterList")
private suspend fun sendTransactionInternal(
walletManager: WalletManager,
amount: Amount,
blockchain: Blockchain,
feeAmount: BigDecimal,
estimatedGas: Int,
destinationAddress: String,
dataToSign: String,
): SendTxResult {
val txData = walletManager.createTransaction(
amount = amount,
fee = Amount(value = feeAmount, blockchain = blockchain),
@ -66,6 +114,11 @@ class TransactionManagerImpl(
return Blockchain.fromNetworkId(networkId)?.decimals() ?: error("blockchain not found")
}
override suspend fun updateWalletManager(networkId: String) {
val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" }
getActualWalletManager(blockchain).update()
}
override fun calculateFee(networkId: String, gasPrice: String, estimatedGas: Int): BigDecimal {
val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" }
val gasPriceValue = requireNotNull(gasPrice.toLongOrNull()) { "gasprice should be Long" }
@ -119,7 +172,7 @@ class TransactionManagerImpl(
return TangemSigner(
card = actualCard,
tangemSdk = tangemSdk,
initialMessage = Message("test transaction title", "test description"),
initialMessage = Message(),
) { signResponse ->
store.dispatch(
GlobalAction.UpdateWalletSignedHashes(
@ -179,6 +232,29 @@ class TransactionManagerImpl(
}
}
private fun createAmountForSwap(
amount: BigDecimal,
currency: Currency?,
blockchain: Blockchain,
): Amount {
return when (currency) {
is Currency.NativeToken,
null,
-> {
Amount(value = amount, blockchain = blockchain)
}
is Currency.NonNativeToken -> {
// 1. when creates swap amount for NonNativeToken, amount should be ZERO
// 2. Amount has .Coin type, as workaround to use destinationAddress in bsdk, not contractAddress
Amount(
currencySymbol = currency.symbol,
value = BigDecimal.ZERO,
decimals = currency.decimalCount,
)
}
}
}
private fun convertNonNativeToken(token: Currency.NonNativeToken): Token {
return Token(
name = token.name,

View file

@ -1,5 +1,6 @@
package com.tangem.tap.proxy
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.DerivationParams
import com.tangem.blockchain.common.DerivationStyle
@ -119,6 +120,25 @@ class UserWalletManagerImpl(
}.toMap()
}
override fun getNativeTokenBalance(networkId: String): ProxyAmount? {
val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" }
val walletManager = getActualWalletManager(blockchain)
return walletManager.wallet.amounts.firstNotNullOfOrNull {
it.takeIf { it.key is AmountType.Coin }
}?.value?.let {
ProxyAmount(
it.currencySymbol,
it.value ?: BigDecimal.ZERO,
it.decimals,
)
}
}
override fun getNetworkCurrency(networkId: String): String {
val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" }
return blockchain.currency
}
override fun getUserAppCurrency(): ProxyFiatCurrency {
val appCurrency = appStateHolder.appFiatCurrency
return ProxyFiatCurrency(

View file

@ -24,6 +24,14 @@ class OneInchApisModule {
apiFactory.putApi(ETH_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_ETH_PATH, moshi))
apiFactory.putApi(BSC_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_BSC_PATH, moshi))
apiFactory.putApi(POLYGON_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_POLYGON_PATH, moshi))
apiFactory.putApi(OPTIMISM_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_OPTIMISM_PATH, moshi))
apiFactory.putApi(ARBITRUM_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_ARBITRUM_PATH, moshi))
apiFactory.putApi(GNOSIS_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_GNOSIS_PATH, moshi))
apiFactory.putApi(
AVALANCHE_NETWORK,
createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_AVALANCHE_PATH, moshi),
)
apiFactory.putApi(FANTOM_NETWORK, createOneInchApiWithUrl(ONE_INCH_BASE_URL + ONE_INCH_FANTOM_PATH, moshi))
return apiFactory
}
@ -47,9 +55,19 @@ class OneInchApisModule {
private const val ONE_INCH_ETH_PATH = "1/"
private const val ONE_INCH_BSC_PATH = "56/"
private const val ONE_INCH_POLYGON_PATH = "137/"
private const val ONE_INCH_OPTIMISM_PATH = "10/"
private const val ONE_INCH_ARBITRUM_PATH = "42161/"
private const val ONE_INCH_GNOSIS_PATH = "100/"
private const val ONE_INCH_AVALANCHE_PATH = "43114/"
private const val ONE_INCH_FANTOM_PATH = "250/"
private const val ETH_NETWORK = "ethereum"
private const val BSC_NETWORK = "binance-smart-chain"
private const val POLYGON_NETWORK = "polygon-pos"
private const val OPTIMISM_NETWORK = "optimistic-ethereum"
private const val ARBITRUM_NETWORK = "arbitrum-one"
private const val GNOSIS_NETWORK = "xdai"
private const val AVALANCHE_NETWORK = "avalanche"
private const val FANTOM_NETWORK = "fantom"
}
}

View file

@ -23,6 +23,25 @@ fun BigDecimal.toFormattedString(
return df.format(this)
}
@Suppress("MagicNumber")
fun BigDecimal.toFormattedCurrencyString(
decimals: Int,
currency: String,
roundingMode: RoundingMode = RoundingMode.DOWN,
limitNumberOfDecimals: Boolean = true,
): String {
val decimalsForRounding = if (limitNumberOfDecimals) {
if (decimals > 8) 8 else decimals
} else {
decimals
}
val formattedAmount = this.toFormattedString(
decimals = decimalsForRounding,
roundingMode = roundingMode,
)
return "$formattedAmount $currency"
}
fun BigDecimal.toFiatString(
rateValue: BigDecimal,
fiatCurrencyName: String,

View file

@ -1,37 +1,94 @@
package com.tangem.feature.swap.domain
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.cache.ExchangeCurrencies
import com.tangem.feature.swap.domain.models.domain.ApproveModel
import com.tangem.feature.swap.domain.models.domain.Currency
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.ui.FoundTokensState
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokensDataState
import com.tangem.feature.swap.domain.models.ui.TxState
interface SwapInteractor {
/**
* Init tokens to swap, load tokens list available to swap for given network
*
* @param initialCurrency currency which to swap or receive
* @return [TokensDataState] that contains info about all available to swap tokens for networkId
* and preselected tokens which initially select to swap
*/
suspend fun initTokensToSwap(initialCurrency: Currency): TokensDataState
suspend fun onSearchToken(searchQuery: String): FoundTokensState
fun getExchangeCurrencies(): ExchangeCurrencies?
/**
* On search token, locally search tokens in previously loaded list to swap
* searching in names and symbols
*
* @param networkId networkId for tokens
* @param searchQuery string query for search
* @return [FoundTokensState] that contains list of tokens matching condition query
*/
suspend fun onSearchToken(networkId: String, searchQuery: String): FoundTokensState
/**
* Find specific token by id, null if not found
*
* @param id token id
* @return [Currency] or null
*/
fun findTokenById(id: String): Currency?
/**
* Give permission to swap
* Gives permission to swap, this starts scan card process
*
* @param networkId network in which selected token
* @param estimatedGas estimated gas for transaction
* @param transactionData tx data to give approve, it loaded from 1inch in findBestQuote if needed
* @param forTokenContractAddress token contract address for which needs permission
*/
@Throws(IllegalStateException::class)
suspend fun givePermissionToSwap()
suspend fun givePermissionToSwap(
networkId: String,
estimatedGas: Int,
transactionData: ApproveModel,
forTokenContractAddress: String,
): TxState
/**
* 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
* @return
*/
@Throws(IllegalStateException::class)
suspend fun findBestQuote(
networkId: String,
fromToken: Currency,
toToken: Currency,
amount: SwapAmount,
amountToSwap: String,
): SwapState
/**
* Starts swap transaction, perform sign transaction
*
* @param networkId network for tokens
* @param swapData tx data to swap, contains data to sign
* @param currencyToSend [Currency]
* @param currencyToGet [Currency]
* @param amountToSwap amount to swap
* @return [TxState]
*/
@Throws(IllegalStateException::class)
suspend fun onSwap(): SwapState
suspend fun onSwap(
networkId: String,
swapData: SwapDataModel,
currencyToSend: Currency,
currencyToGet: Currency,
amountToSwap: String,
): TxState
fun getTokenDecimals(token: Currency): Int
}

View file

@ -4,24 +4,28 @@ import com.tangem.feature.swap.domain.cache.SwapDataCache
import com.tangem.feature.swap.domain.converters.CryptoCurrencyConverter
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.cache.ExchangeCurrencies
import com.tangem.feature.swap.domain.models.domain.ApproveModel
import com.tangem.feature.swap.domain.models.domain.Currency
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.toStringWithRightOffset
import com.tangem.feature.swap.domain.models.ui.AmountFormatter
import com.tangem.feature.swap.domain.models.ui.FoundTokensState
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.PreselectTokens
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.TokenBalanceData
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.domain.models.ui.TokenWithBalance
import com.tangem.feature.swap.domain.models.ui.TokensDataState
import com.tangem.feature.swap.domain.models.ui.TxState
import com.tangem.lib.crypto.TransactionManager
import com.tangem.lib.crypto.UserWalletManager
import com.tangem.lib.crypto.models.ProxyAmount
import com.tangem.lib.crypto.models.ProxyFiatCurrency
import com.tangem.lib.crypto.models.transactions.SendTxResult
import com.tangem.utils.toFiatString
import com.tangem.utils.toFormattedString
import java.math.BigDecimal
import javax.inject.Inject
@ -35,10 +39,10 @@ internal class SwapInteractorImpl @Inject constructor(
) : SwapInteractor {
private val cryptoCurrencyConverter = CryptoCurrencyConverter()
private val amountFormatter = AmountFormatter()
override suspend fun initTokensToSwap(initialCurrency: Currency): TokensDataState {
val networkId = initialCurrency.networkId
cache.cacheNetworkId(networkId)
val availableTokens = cache.getAvailableTokens(networkId)
val allLoadedTokens = availableTokens.ifEmpty {
val tokens = repository.getExchangeableTokens(networkId)
@ -77,8 +81,7 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
override suspend fun onSearchToken(searchQuery: String): FoundTokensState {
val networkId = requireNotNull(cache.getNetworkId()) { "networkId is null" }
override suspend fun onSearchToken(networkId: String, searchQuery: String): FoundTokensState {
val searchQueryLowerCase = searchQuery.lowercase()
val tokensInWallet = cache.getInWalletTokens()
.filter {
@ -99,67 +102,67 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
override fun getExchangeCurrencies(): ExchangeCurrencies? {
return cache.getExchangeCurrencies()
}
override fun findTokenById(id: String): Currency? {
val tokensInWallet = cache.getInWalletTokens()
val loadedTokens = cache.getLoadedTokens()
return tokensInWallet.firstOrNull { it.id == id } ?: loadedTokens.firstOrNull { it.id == id }
}
override suspend fun givePermissionToSwap() {
cache.getNetworkId()?.let { networkId ->
val currencyToSend =
requireNotNull(cache.getExchangeCurrencies()?.fromCurrency) { "currency is not selected" }
if (currencyToSend is Currency.NonNativeToken) {
val estimatedGas =
increaseByPercents(
TWENTY_FIVE_PERCENTS,
requireNotNull(cache.getLastQuote()?.estimatedGas) {
"estimatedGas not found call findBestQuote"
},
)
val transactionData = requireNotNull(cache.getApproveTransactionData()) {
"getApproveTransactionData not found, call findQuotes"
}
val gasPrice = transactionData.gasPrice.toBigDecimalOrNull() ?: error("cannot parse gasPrice")
val fee = transactionManager.calculateFee(networkId, gasPrice.toPlainString(), estimatedGas)
val result = transactionManager.sendTransaction(
networkId = networkId,
amountToSend = BigDecimal.ZERO,
currencyToSend = cryptoCurrencyConverter.convert(currencyToSend),
feeAmount = fee,
estimatedGas = estimatedGas,
destinationAddress = transactionData.toAddress,
dataToSign = transactionData.data,
)
when (result) {
SendTxResult.Success -> {
allowPermissionsHandler.addAddressToInProgress(currencyToSend.contractAddress)
}
SendTxResult.UserCancelledError -> TODO()
is SendTxResult.BlockchainSdkError -> TODO()
is SendTxResult.TangemSdkError -> TODO()
is SendTxResult.UnknownError -> TODO()
}
override suspend fun givePermissionToSwap(
networkId: String,
estimatedGas: Int,
transactionData: ApproveModel,
forTokenContractAddress: String,
): TxState {
val increasedEstimatedGas = increaseByPercents(TWENTY_FIVE_PERCENTS, estimatedGas)
val gasPrice = transactionData.gasPrice.toBigDecimalOrNull() ?: error("cannot parse gasPrice")
val fee = transactionManager.calculateFee(networkId, gasPrice.toPlainString(), increasedEstimatedGas)
val result = transactionManager.sendApproveTransaction(
networkId = networkId,
feeAmount = fee,
estimatedGas = increasedEstimatedGas,
destinationAddress = transactionData.toAddress,
dataToSign = transactionData.data,
)
return when (result) {
SendTxResult.Success -> {
allowPermissionsHandler.addAddressToInProgress(forTokenContractAddress)
TxState.TxSent()
}
SendTxResult.UserCancelledError -> TxState.UserCancelled
is SendTxResult.BlockchainSdkError -> TxState.BlockchainError
is SendTxResult.TangemSdkError -> TxState.TangemSdkError
is SendTxResult.UnknownError -> TxState.UnknownError
}
}
override suspend fun findBestQuote(fromToken: Currency, toToken: Currency, amount: SwapAmount): SwapState {
val networkId = requireNotNull(cache.getNetworkId()) { "no networkId found, please call initTokens first" }
override suspend fun findBestQuote(
networkId: String,
fromToken: Currency,
toToken: Currency,
amountToSwap: String,
): SwapState {
val amountDecimal = amountToSwap.toBigDecimalOrNull()
if (amountDecimal == null || amountDecimal.compareTo(BigDecimal.ZERO) == 0) {
return createEmptyAmountState(networkId, fromToken, toToken)
}
val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken))
val fromTokenAddress = getTokenAddress(fromToken)
val toTokenAddress = getTokenAddress(toToken)
val isAllowedToSpend = checkAllowance(networkId, fromTokenAddress)
val isNotZeroBalance = isNotZeroBalance(fromToken, networkId)
cache.cacheExchangeCurrencies(fromToken, toToken)
cache.cacheAmountToSwap(amount)
return if (isAllowedToSpend && isNotZeroBalance) {
if (allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) {
allowPermissionsHandler.removeAddressFromProgress(toTokenAddress)
}
val fee = getAndUpdateFee(networkId, fromToken)
val isBalanceEnough = isBalanceEnough(fromToken, networkId, amount, fee)
val isFeeEnough = checkFeeIsEnough(fee, amount, networkId)
if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) {
allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress)
transactionManager.updateWalletManager(networkId)
}
val preparedSwapConfigState = PreparedSwapConfigState(
isAllowedToSpend = isAllowedToSpend,
isBalanceEnough = isBalanceEnough,
isFeeEnough = isFeeEnough,
)
return if (isAllowedToSpend && isBalanceEnough && isFeeEnough) {
loadSwapData(
networkId = networkId,
fromTokenAddress = fromTokenAddress,
@ -167,6 +170,7 @@ internal class SwapInteractorImpl @Inject constructor(
fromToken = fromToken,
toToken = toToken,
amount = amount,
preparedSwapConfigState = preparedSwapConfigState,
)
} else {
loadQuoteData(
@ -176,16 +180,19 @@ internal class SwapInteractorImpl @Inject constructor(
amount = amount,
fromToken = fromToken,
toToken = toToken,
isAllowedToSpend = isAllowedToSpend,
preparedSwapConfigState = preparedSwapConfigState,
)
}
}
override suspend fun onSwap(): SwapState {
val swapData = requireNotNull(cache.getLastSwapData()) { "swap data is not ready" }
val networkId = requireNotNull(cache.getNetworkId()) { "no networkId found, please call getTokensToSwap first" }
val currencyToSend = requireNotNull(cache.getExchangeCurrencies()?.fromCurrency) { "currency is not selected" }
val amountToSwap = requireNotNull(cache.getAmountToSwap()) { "" }
override suspend fun onSwap(
networkId: String,
swapData: SwapDataModel,
currencyToSend: Currency,
currencyToGet: Currency,
amountToSwap: String,
): TxState {
val amount = requireNotNull(amountToSwap.toBigDecimalOrNull()) { "wrong amount format, use only digits" }
val estimatedGas =
increaseByPercents(TWENTY_FIVE_PERCENTS, swapData.transaction.gas.toIntOrNull() ?: DEFAULT_GAS)
val fee = transactionManager.calculateFee(
@ -195,22 +202,27 @@ internal class SwapInteractorImpl @Inject constructor(
)
val result = transactionManager.sendTransaction(
networkId = networkId,
amountToSend = amountToSwap.value,
amountToSend = amount,
currencyToSend = cryptoCurrencyConverter.convert(currencyToSend),
feeAmount = fee,
estimatedGas = estimatedGas,
destinationAddress = swapData.transaction.toWalletAddress,
dataToSign = swapData.transaction.data,
isSwap = true,
)
when (result) {
return when (result) {
SendTxResult.Success -> {
userWalletManager.addToken(cryptoCurrencyConverter.convert(currencyToGet))
TxState.TxSent(
fromAmount = amountFormatter.formatSwapAmountToUI(swapData.fromTokenAmount, currencyToSend.symbol),
toAmount = amountFormatter.formatSwapAmountToUI(swapData.toTokenAmount, currencyToSend.symbol),
)
}
SendTxResult.UserCancelledError -> TODO()
is SendTxResult.BlockchainSdkError -> TODO()
is SendTxResult.TangemSdkError -> TODO()
is SendTxResult.UnknownError -> TODO()
SendTxResult.UserCancelledError -> TxState.UserCancelled
is SendTxResult.BlockchainSdkError -> TxState.BlockchainError
is SendTxResult.TangemSdkError -> TxState.TangemSdkError
is SendTxResult.UnknownError -> TxState.UnknownError
}
return SwapState.SwapError(DataError.UNKNOWN_ERROR)
}
override fun getTokenDecimals(token: Currency): Int {
@ -252,7 +264,7 @@ internal class SwapInteractorImpl @Inject constructor(
TokenWithBalance(
token = it,
tokenBalanceData = TokenBalanceData(
amount = balance?.let { b -> b.value.toFormattedString(b.decimals) },
amount = balance?.let { amount -> amountFormatter.formatProxyAmountToUI(amount, "") },
amountEquivalent = balance?.value?.toFiatString(
rates[it.id]?.toBigDecimal() ?: BigDecimal.ZERO,
appCurrency.symbol,
@ -262,13 +274,50 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
private suspend fun getAndUpdateFee(networkId: String, fromToken: Currency): BigDecimal? {
val lastFee = cache.getLastFeeForNetwork(networkId)
if (lastFee == null) {
if (userWalletManager.getNativeTokenBalance(networkId)?.value?.compareTo(BigDecimal.ZERO) == 0) {
return null
}
val transactionData = repository.dataToApprove(networkId, getTokenAddress(fromToken))
val fee = transactionManager.getFee(
networkId,
BigDecimal.ZERO,
cryptoCurrencyConverter.convert(fromToken),
transactionData.toAddress,
).value
cache.cacheLastFeeForNetwork(fee, networkId)
return fee
}
return lastFee
}
private suspend fun checkAllowance(networkId: String, fromTokenAddress: String): Boolean {
val allowance = repository.checkTokensSpendAllowance(
networkId = networkId,
tokenAddress = fromTokenAddress,
walletAddress = userWalletManager.getWalletAddress(networkId),
)
return allowance.error == DataError.NO_ERROR && allowance.dataModel != ZERO_BALANCE
return allowance.error == DataError.NoError && allowance.dataModel != ZERO_BALANCE
}
private fun createEmptyAmountState(
networkId: String,
fromToken: Currency,
toToken: Currency,
): SwapState {
val tokensBalance = userWalletManager.getCurrentWalletTokensBalance(networkId)
val fromTokenBalance = tokensBalance[fromToken.symbol]?.let {
SwapAmount(it.value, it.decimals)
}
val toTokenBalance = tokensBalance[toToken.symbol]?.let {
SwapAmount(it.value, it.decimals)
}
return SwapState.EmptyAmountState(
fromTokenWalletBalance = fromTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") } ?: "",
toTokenWalletBalance = toTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") } ?: "",
)
}
/**
@ -282,7 +331,7 @@ internal class SwapInteractorImpl @Inject constructor(
amount: SwapAmount,
fromToken: Currency,
toToken: Currency,
isAllowedToSpend: Boolean,
preparedSwapConfigState: PreparedSwapConfigState,
): SwapState {
repository.findBestQuote(
networkId = networkId,
@ -292,9 +341,7 @@ internal class SwapInteractorImpl @Inject constructor(
).let { quotes ->
val quoteDataModel = quotes.dataModel
if (quoteDataModel != null) {
cache.cacheQuoteData(quoteModel = quoteDataModel)
val transactionData = repository.dataToApprove(networkId, getTokenAddress(fromToken))
cache.cacheApproveTransactionData(transactionData)
val swapState = updateBalances(
networkId = networkId,
fromToken = fromToken,
@ -306,7 +353,8 @@ internal class SwapInteractorImpl @Inject constructor(
estimatedGas = quoteDataModel.estimatedGas,
gasPrice = transactionData.gasPrice,
),
isAllowedToSpend = isAllowedToSpend,
preparedSwapConfigState = preparedSwapConfigState,
swapDataModel = null,
)
return updatePermissionState(
networkId = networkId,
@ -332,6 +380,7 @@ internal class SwapInteractorImpl @Inject constructor(
fromToken: Currency,
toToken: Currency,
amount: SwapAmount,
preparedSwapConfigState: PreparedSwapConfigState,
): SwapState {
repository.prepareSwapTransaction(
networkId = networkId,
@ -343,7 +392,6 @@ internal class SwapInteractorImpl @Inject constructor(
).let {
val swapData = it.dataModel
if (swapData != null) {
cache.cacheSwapData(swapData)
val swapState = updateBalances(
networkId = networkId,
fromToken = fromToken,
@ -355,7 +403,8 @@ internal class SwapInteractorImpl @Inject constructor(
estimatedGas = swapData.transaction.gas.toIntOrNull() ?: DEFAULT_GAS,
gasPrice = swapData.transaction.gasPrice,
),
isAllowedToSpend = true,
preparedSwapConfigState = preparedSwapConfigState,
swapDataModel = swapData,
)
return swapState.copy(
permissionState = PermissionDataState.Empty,
@ -374,34 +423,45 @@ internal class SwapInteractorImpl @Inject constructor(
fromTokenAmount: SwapAmount,
toTokenAmount: SwapAmount,
fee: BigDecimal,
isAllowedToSpend: Boolean,
preparedSwapConfigState: PreparedSwapConfigState,
swapDataModel: SwapDataModel?,
): SwapState.QuotesLoadedState {
val appCurrency = userWalletManager.getUserAppCurrency()
val rates = repository.getRates(appCurrency.code, listOf(fromToken.id, toToken.id))
val tokensBalance = userWalletManager.getCurrentWalletTokensBalance(networkId)
val fromTokenBalance = tokensBalance[fromToken.symbol]?.let {
it.value.toFormattedString(it.decimals)
amountFormatter.formatProxyAmountToUI(it, "")
}
val toTokenBalance = tokensBalance[toToken.symbol]?.let {
it.value.toFormattedString(it.decimals)
amountFormatter.formatProxyAmountToUI(it, "")
}
return SwapState.QuotesLoadedState(
fromTokenAmount = fromTokenAmount,
toTokenAmount = toTokenAmount,
fromTokenAddress = getTokenAddress(fromToken),
toTokenAddress = getTokenAddress(toToken),
fee = "${fee.toPlainString()} ${userWalletManager.getCurrencyByNetworkId(networkId)}",
isAllowedToSpend = isAllowedToSpend,
fromTokenWalletBalance = fromTokenBalance ?: ZERO_BALANCE,
fromTokenFiatBalance = fromTokenAmount.value.toFiatString(
rates[fromToken.id]?.toBigDecimal() ?: BigDecimal.ZERO,
appCurrency.symbol,
fromTokenInfo = TokenSwapInfo(
tokenAmount = fromTokenAmount,
tokenId = fromToken.id,
tokenWalletBalance = fromTokenBalance ?: ZERO_BALANCE,
tokenFiatBalance = fromTokenAmount.value.toFiatString(
rates[fromToken.id]?.toBigDecimal() ?: BigDecimal.ZERO,
appCurrency.symbol,
),
),
toTokenWalletBalance = toTokenBalance ?: ZERO_BALANCE,
toTokenFiatBalance = fromTokenAmount.value.toFiatString(
rates[fromToken.id]?.toBigDecimal() ?: BigDecimal.ZERO,
appCurrency.symbol,
toTokenInfo = TokenSwapInfo(
tokenAmount = toTokenAmount,
tokenId = toToken.id,
tokenWalletBalance = toTokenBalance ?: ZERO_BALANCE,
tokenFiatBalance = toTokenAmount.value.toFiatString(
rates[toToken.id]?.toBigDecimal() ?: BigDecimal.ZERO,
appCurrency.symbol,
),
),
fee = amountFormatter.formatBigDecimalAmountToUI(
amount = fee,
decimals = transactionManager.getNativeTokenDecimals(networkId),
currency = userWalletManager.getCurrencyByNetworkId(networkId),
),
networkCurrency = userWalletManager.getNetworkCurrency(networkId),
preparedSwapConfigState = preparedSwapConfigState,
swapDataModel = swapDataModel,
)
}
@ -412,7 +472,7 @@ internal class SwapInteractorImpl @Inject constructor(
estimatedGas: Int,
transactionData: ApproveModel,
): SwapState.QuotesLoadedState {
if (allowPermissionsHandler.isAddressAllowanceInProgress(fromToken.networkId)) {
if (allowPermissionsHandler.isAddressAllowanceInProgress(getTokenAddress(fromToken))) {
return quotesLoadedState.copy(
permissionState = PermissionDataState.PermissionLoading,
)
@ -420,7 +480,7 @@ internal class SwapInteractorImpl @Inject constructor(
return quotesLoadedState.copy(
permissionState = PermissionDataState.PermissionReadyForRequest(
currency = userWalletManager.getCurrencyByNetworkId(networkId),
amount = "infinite", // FIXME
amount = "",
walletAddress = getWalletAddress(networkId),
spenderAddress = transactionData.toAddress,
fee = transactionManager.calculateFee(
@ -428,14 +488,18 @@ internal class SwapInteractorImpl @Inject constructor(
gasPrice = transactionData.gasPrice,
estimatedGas = estimatedGas,
).toPlainString(),
requestApproveData = RequestApproveStateData(
estimatedGas = estimatedGas,
approveModel = transactionData,
),
),
)
}
private fun isNotZeroBalance(fromToken: Currency, networkId: String): Boolean {
private fun isBalanceEnough(fromToken: Currency, networkId: String, amount: SwapAmount, fee: BigDecimal?): Boolean {
/** to compare [BigDecimal] use only comparator */
return (userWalletManager.getCurrentWalletTokensBalance(networkId)[fromToken.symbol]?.value
?: BigDecimal.ZERO).compareTo(BigDecimal.ZERO) != 0
?: BigDecimal.ZERO) > amount.value.plus(fee ?: BigDecimal.ZERO)
}
private fun getWalletAddress(networkId: String): String {
@ -445,7 +509,7 @@ internal class SwapInteractorImpl @Inject constructor(
private fun getTokenAddress(currency: Currency): String {
return when (currency) {
is Currency.NativeToken -> {
DEFAULT_BLOCKCHAIN_ADDRESS
DEFAULT_BLOCKCHAIN_INCH_ADDRESS
}
is Currency.NonNativeToken -> {
currency.contractAddress
@ -453,6 +517,20 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
private fun checkFeeIsEnough(fee: BigDecimal?, spendAmount: SwapAmount, networkId: String): Boolean {
if (fee == null) {
return false
}
userWalletManager.getNativeTokenBalance(networkId)?.let { balance ->
return (balance.value.minus(spendAmount.value) > fee.multiply(
BigDecimal.valueOf(
INCREASE_FEE_TO_CHECK_ENOUGH_PERCENT,
),
))
}
return false
}
@Suppress("MagicNumber")
private fun increaseByPercents(percents: Int, value: Int): Int {
return value * (percents / 100 + 1)
@ -462,7 +540,8 @@ internal class SwapInteractorImpl @Inject constructor(
private const val DEFAULT_SLIPPAGE = 2
private const val ZERO_BALANCE = "0"
private const val DEFAULT_GAS = 300000
private const val DEFAULT_BLOCKCHAIN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"
private const val DEFAULT_BLOCKCHAIN_INCH_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"
private const val TWENTY_FIVE_PERCENTS = 25
private const val INCREASE_FEE_TO_CHECK_ENOUGH_PERCENT = 1.5
}
}

View file

@ -1,30 +1,16 @@
package com.tangem.feature.swap.domain.cache
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.cache.ExchangeCurrencies
import com.tangem.feature.swap.domain.models.domain.ApproveModel
import com.tangem.feature.swap.domain.models.domain.Currency
import com.tangem.feature.swap.domain.models.domain.QuoteModel
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import java.math.BigDecimal
interface SwapDataCache {
fun cacheNetworkId(networkId: String)
fun cacheQuoteData(quoteModel: QuoteModel)
fun cacheSwapData(swapDataModel: SwapDataModel)
fun cacheAmountToSwap(amount: SwapAmount)
fun cacheApproveTransactionData(approve: ApproveModel)
fun cacheAvailableToSwapTokens(networkId: String, tokens: List<Currency>)
fun cacheInWalletTokens(tokens: List<Currency>)
fun cacheLoadedTokens(tokens: List<Currency>)
fun cacheExchangeCurrencies(fromToken: Currency, toToken: Currency)
fun cacheLastFeeForNetwork(fee: BigDecimal, networkId: String)
fun getAvailableTokens(networkId: String): List<Currency>
fun getApproveTransactionData(): ApproveModel?
fun getLastQuote(): QuoteModel?
fun getLastSwapData(): SwapDataModel?
fun getNetworkId(): String?
fun getAmountToSwap(): SwapAmount?
fun getExchangeCurrencies(): ExchangeCurrencies?
fun getInWalletTokens(): List<Currency>
fun getLoadedTokens(): List<Currency>
fun getLastFeeForNetwork(networkId: String): BigDecimal?
}

View file

@ -1,34 +1,17 @@
package com.tangem.feature.swap.domain.cache
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.cache.ExchangeCurrencies
import com.tangem.feature.swap.domain.models.cache.SwapDataHolder
import com.tangem.feature.swap.domain.models.domain.ApproveModel
import com.tangem.feature.swap.domain.models.domain.Currency
import com.tangem.feature.swap.domain.models.domain.QuoteModel
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import java.math.BigDecimal
class SwapDataCacheImpl : SwapDataCache {
private var lastDataForSwap: SwapDataHolder = SwapDataHolder()
private val availableTokensForNetwork: MutableMap<String, List<Currency>> = mutableMapOf()
private val feesForNetworks: MutableMap<String, BigDecimal> = mutableMapOf()
private val lastInWalletTokens = mutableListOf<Currency>()
private val lastLoadedTokens = mutableListOf<Currency>()
override fun cacheQuoteData(
quoteModel: QuoteModel,
) {
lastDataForSwap = lastDataForSwap.copy(quoteModel = quoteModel)
}
override fun cacheExchangeCurrencies(fromToken: Currency, toToken: Currency) {
lastDataForSwap =
lastDataForSwap.copy(
exchangeCurrencies = ExchangeCurrencies(
fromCurrency = fromToken,
toCurrency = toToken,
),
)
override fun cacheLastFeeForNetwork(fee: BigDecimal, networkId: String) {
feesForNetworks[networkId] = fee
}
override fun cacheInWalletTokens(tokens: List<Currency>) {
@ -49,51 +32,15 @@ class SwapDataCacheImpl : SwapDataCache {
return lastLoadedTokens
}
override fun cacheSwapData(swapDataModel: SwapDataModel) {
lastDataForSwap = lastDataForSwap.copy(swapModel = swapDataModel)
}
override fun getLastSwapData(): SwapDataModel? {
return lastDataForSwap.swapModel
}
override fun cacheApproveTransactionData(approve: ApproveModel) {
lastDataForSwap = lastDataForSwap.copy(approveTxModel = approve)
}
override fun getExchangeCurrencies(): ExchangeCurrencies? {
return lastDataForSwap.exchangeCurrencies
}
override fun cacheAvailableToSwapTokens(networkId: String, tokens: List<Currency>) {
availableTokensForNetwork[networkId] = tokens
}
override fun cacheNetworkId(networkId: String) {
lastDataForSwap = lastDataForSwap.copy(networkId = networkId)
}
override fun cacheAmountToSwap(amount: SwapAmount) {
lastDataForSwap = lastDataForSwap.copy(amountToSwap = amount)
}
override fun getNetworkId(): String? {
return lastDataForSwap.networkId
override fun getLastFeeForNetwork(networkId: String): BigDecimal? {
return feesForNetworks[networkId]
}
override fun getAvailableTokens(networkId: String): List<Currency> {
return availableTokensForNetwork.getOrElse(networkId) { emptyList() }
}
override fun getApproveTransactionData(): ApproveModel? {
return lastDataForSwap.approveTxModel
}
override fun getLastQuote(): QuoteModel? {
return lastDataForSwap.quoteModel
}
override fun getAmountToSwap(): SwapAmount? {
return lastDataForSwap.amountToSwap
}
}

View file

@ -1,21 +1,18 @@
package com.tangem.feature.swap.domain.models
enum class DataError {
NO_ERROR,
UNKNOWN_ERROR,
INSUFFICIENT_LIQUIDITY,
CANNOT_SYNC
sealed class DataError {
object NoError : DataError()
data class UnknownError(val message: String) : DataError()
object InsufficientLiquidity : DataError()
}
fun mapErrors(error: String?): DataError {
return if (error == null) {
DataError.UNKNOWN_ERROR
} else when {
error == INSUFFICIENT_LIQUIDITY_ERROR -> DataError.INSUFFICIENT_LIQUIDITY
error.startsWith(CANNOT_SYNC_ERROR) -> DataError.CANNOT_SYNC
else -> DataError.UNKNOWN_ERROR
DataError.NoError
} else when (error) {
INSUFFICIENT_LIQUIDITY_ERROR -> DataError.InsufficientLiquidity
else -> DataError.UnknownError(error)
}
}
private const val INSUFFICIENT_LIQUIDITY_ERROR = "insufficient liquidity"
private const val CANNOT_SYNC_ERROR = "cannot sync"
private const val INSUFFICIENT_LIQUIDITY_ERROR = "insufficient liquidity"

View file

@ -11,5 +11,5 @@ import com.tangem.feature.swap.domain.models.DataError
*/
data class AggregatedSwapDataModel<T>(
val dataModel: T?,
val error: DataError = DataError.NO_ERROR,
val error: DataError = DataError.NoError,
)

View file

@ -0,0 +1,7 @@
package com.tangem.feature.swap.domain.models.domain
data class PreparedSwapConfigState(
val isAllowedToSpend: Boolean,
val isBalanceEnough: Boolean,
val isFeeEnough: Boolean,
)

View file

@ -0,0 +1,43 @@
package com.tangem.feature.swap.domain.models.ui
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.lib.crypto.models.ProxyAmount
import com.tangem.utils.toFormattedCurrencyString
import java.math.BigDecimal
class AmountFormatter {
/**
* Use to convert crypto amount [SwapAmount] to UI representation
*
* @param swapAmount [SwapAmount]
* @param currency currency symbol
* @return formatted [String]
*/
fun formatSwapAmountToUI(swapAmount: SwapAmount, currency: String): String {
return swapAmount.value.toFormattedCurrencyString(swapAmount.decimals, currency)
}
/**
* Use to convert crypto amount [ProxyAmount] to UI representation
*
* @param proxyAmount [ProxyAmount]
* @param currency currency symbol
* @return formatted [String]
*/
fun formatProxyAmountToUI(proxyAmount: ProxyAmount, currency: String): String {
return proxyAmount.value.toFormattedCurrencyString(proxyAmount.decimals, currency)
}
/**
* Use to convert ONLY crypto amount [BigDecimal] to UI representation
*
* @param amount
* @param decimals
* @param currency
* @return formatted [String]
*/
fun formatBigDecimalAmountToUI(amount: BigDecimal, decimals: Int, currency: String): String {
return amount.toFormattedCurrencyString(decimals, currency)
}
}

View file

@ -2,26 +2,28 @@ package com.tangem.feature.swap.domain.models.ui
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ApproveModel
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
sealed interface SwapState {
data class QuotesLoadedState(
val fromTokenAmount: SwapAmount,
val toTokenAmount: SwapAmount,
val fromTokenAddress: String,
val toTokenAddress: String,
val fromTokenWalletBalance: String,
val fromTokenFiatBalance: String,
val toTokenWalletBalance: String,
val toTokenFiatBalance: String,
val fromTokenInfo: TokenSwapInfo,
val toTokenInfo: TokenSwapInfo,
val fee: String,
val isAllowedToSpend: Boolean = false,
val networkCurrency: String,
val preparedSwapConfigState: PreparedSwapConfigState,
val permissionState: PermissionDataState = PermissionDataState.Empty,
val swapDataModel: SwapDataModel? = null,
) : SwapState
data class SwapError(
val errorType: DataError,
data class EmptyAmountState(
val fromTokenWalletBalance: String,
val toTokenWalletBalance: String,
) : SwapState
data class SwapError(val error: DataError) : SwapState
}
sealed class PermissionDataState {
@ -32,6 +34,7 @@ sealed class PermissionDataState {
val walletAddress: String,
val spenderAddress: String,
val fee: String,
val requestApproveData: RequestApproveStateData,
) : PermissionDataState()
object PermissionFailed : PermissionDataState()
@ -39,4 +42,16 @@ sealed class PermissionDataState {
object PermissionLoading : PermissionDataState()
object Empty : PermissionDataState()
}
}
data class TokenSwapInfo(
val tokenAmount: SwapAmount,
val tokenId: String,
val tokenWalletBalance: String,
val tokenFiatBalance: String,
)
data class RequestApproveStateData(
val estimatedGas: Int,
val approveModel: ApproveModel,
)

View file

@ -0,0 +1,10 @@
package com.tangem.feature.swap.domain.models.ui
sealed class TxState {
data class TxSent(val fromAmount: String? = null, val toAmount: String? = null) : TxState()
object UserCancelled : TxState()
object BlockchainError : TxState()
object TangemSdkError : TxState()
object UnknownError : TxState()
}

View file

@ -30,6 +30,7 @@ data class SwapCardData(
val amount: String?,
val amountEquivalent: String?,
val tokenIconUrl: String,
val tokenId: String,
val tokenCurrency: String,
val balance: String,
@DrawableRes val networkIconRes: Int? = null,
@ -68,7 +69,7 @@ sealed interface TransactionCardType {
sealed interface SwapWarning {
data class PermissionNeeded(val tokenCurrency: String) : SwapWarning
data class InsufficientFunds(val tokenCurrency: String) : SwapWarning
object InsufficientFunds : SwapWarning
data class GenericWarning(val message: String?, val onClick: () -> Unit) : SwapWarning
// data class RateExpired(val onClick: () -> Unit) : SwapWarning
// object HighPriceImpact : SwapWarning

View file

@ -1,11 +1,13 @@
package com.tangem.feature.swap.ui
import com.tangem.feature.swap.converters.TokensDataConverter
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.domain.Currency
import com.tangem.feature.swap.domain.models.formatToUIRepresentation
import com.tangem.feature.swap.domain.models.ui.FoundTokensState
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TxState
import com.tangem.feature.swap.models.ApprovePermissionButton
import com.tangem.feature.swap.models.CancelPermissionButton
import com.tangem.feature.swap.models.FeeState
@ -13,6 +15,7 @@ import com.tangem.feature.swap.models.SwapButton
import com.tangem.feature.swap.models.SwapCardData
import com.tangem.feature.swap.models.SwapPermissionState
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.SwapSuccessStateHolder
import com.tangem.feature.swap.models.SwapWarning
import com.tangem.feature.swap.models.TransactionCardType
import com.tangem.feature.swap.models.UiActions
@ -24,14 +27,15 @@ class StateBuilder(val actions: UiActions) {
private val tokensDataConverter = TokensDataConverter(actions.onSearchEntered, actions.onTokenSelected)
fun createInitialLoadingState(networkCurrency: String): SwapStateHolder {
fun createInitialLoadingState(initialCurrency: Currency): SwapStateHolder {
return SwapStateHolder(
sendCardData = SwapCardData(
type = TransactionCardType.SendCard(actions.onAmountChanged),
amount = null,
amountEquivalent = null,
tokenIconUrl = "",
tokenCurrency = "",
tokenIconUrl = initialCurrency.logoUrl,
tokenCurrency = initialCurrency.symbol,
tokenId = initialCurrency.id,
canSelectAnotherToken = false,
balance = "",
),
@ -43,9 +47,10 @@ class StateBuilder(val actions: UiActions) {
tokenCurrency = "",
canSelectAnotherToken = false,
balance = "",
tokenId = "",
),
fee = FeeState.Loading,
networkCurrency = networkCurrency,
networkCurrency = initialCurrency.symbol,
swapButton = SwapButton(enabled = false, loading = true, onClick = {}),
onRefresh = {},
onBackClicked = actions.onBackClicked,
@ -66,6 +71,7 @@ class StateBuilder(val actions: UiActions) {
amountEquivalent = uiStateHolder.sendCardData.amountEquivalent,
tokenIconUrl = fromToken.logoUrl,
tokenCurrency = fromToken.symbol,
tokenId = fromToken.id,
canSelectAnotherToken = mainTokenId != fromToken.id,
balance = "",
),
@ -75,6 +81,7 @@ class StateBuilder(val actions: UiActions) {
amountEquivalent = null,
tokenIconUrl = toToken.logoUrl,
tokenCurrency = toToken.symbol,
tokenId = toToken.id,
canSelectAnotherToken = mainTokenId != toToken.id,
balance = "",
),
@ -89,40 +96,87 @@ class StateBuilder(val actions: UiActions) {
quoteModel: SwapState.QuotesLoadedState,
fromToken: Currency,
): SwapStateHolder {
val warnings = mutableListOf<SwapWarning>()
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend && quoteModel.preparedSwapConfigState.isFeeEnough) {
warnings.add(SwapWarning.PermissionNeeded(fromToken.symbol))
}
if (!quoteModel.preparedSwapConfigState.isBalanceEnough) {
warnings.add(SwapWarning.InsufficientFunds)
}
val feeState = if (quoteModel.preparedSwapConfigState.isFeeEnough) {
FeeState.Loaded(quoteModel.fee)
} else {
FeeState.NotEnoughFundsWarning(quoteModel.fee)
}
return uiStateHolder.copy(
sendCardData = SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard),
amount = quoteModel.fromTokenAmount.formatToUIRepresentation(),
amountEquivalent = quoteModel.fromTokenFiatBalance,
amount = quoteModel.fromTokenInfo.tokenAmount.formatToUIRepresentation(),
amountEquivalent = quoteModel.fromTokenInfo.tokenFiatBalance,
tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl,
tokenId = quoteModel.fromTokenInfo.tokenId,
tokenCurrency = uiStateHolder.sendCardData.tokenCurrency,
canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken,
balance = quoteModel.fromTokenWalletBalance,
balance = quoteModel.fromTokenInfo.tokenWalletBalance,
),
receiveCardData = SwapCardData(
type = TransactionCardType.ReceiveCard(),
amount = quoteModel.toTokenAmount.formatToUIRepresentation(),
amountEquivalent = quoteModel.toTokenFiatBalance,
amount = quoteModel.toTokenInfo.tokenAmount.formatToUIRepresentation(),
amountEquivalent = quoteModel.toTokenInfo.tokenFiatBalance,
tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl,
tokenId = quoteModel.toTokenInfo.tokenId,
tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency,
canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken,
balance = quoteModel.toTokenWalletBalance,
balance = quoteModel.toTokenInfo.tokenWalletBalance,
),
warnings = if (!quoteModel.isAllowedToSpend) {
listOf(SwapWarning.PermissionNeeded(fromToken.symbol))
} else {
emptyList()
},
networkCurrency = quoteModel.networkCurrency,
warnings = warnings,
permissionState = convertPermissionState(quoteModel.permissionState, actions.onGivePermissionClick),
fee = FeeState.Loaded(quoteModel.fee),
fee = feeState,
swapButton = SwapButton(
enabled = quoteModel.isAllowedToSpend,
enabled = quoteModel.preparedSwapConfigState.isAllowedToSpend
&& quoteModel.preparedSwapConfigState.isBalanceEnough
&& quoteModel.preparedSwapConfigState.isFeeEnough,
loading = false,
onClick = actions.onSwapClick,
),
)
}
fun createQuotesEmptyAmountState(
uiStateHolder: SwapStateHolder,
emptyAmountState: SwapState.EmptyAmountState,
): SwapStateHolder {
return uiStateHolder.copy(
sendCardData = SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard),
amount = uiStateHolder.sendCardData.amount,
amountEquivalent = uiStateHolder.sendCardData.amountEquivalent,
tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl,
tokenId = uiStateHolder.sendCardData.tokenId,
tokenCurrency = uiStateHolder.sendCardData.tokenCurrency,
canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken,
balance = emptyAmountState.fromTokenWalletBalance,
),
receiveCardData = SwapCardData(
type = TransactionCardType.ReceiveCard(),
amount = "0",
amountEquivalent = uiStateHolder.receiveCardData.amountEquivalent,
tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl,
tokenId = uiStateHolder.receiveCardData.tokenId,
tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency,
canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken,
balance = emptyAmountState.toTokenWalletBalance,
),
fee = FeeState.Empty,
swapButton = SwapButton(
enabled = false,
loading = false,
onClick = { },
),
)
}
fun createSwapInProgressState(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
swapButton = uiState.swapButton.copy(
@ -164,12 +218,78 @@ class StateBuilder(val actions: UiActions) {
}
}
fun updateSwapAmount(uiState: SwapStateHolder, amount: String, amountEquivalent: String): SwapStateHolder {
fun updateSwapAmount(uiState: SwapStateHolder, amount: String): SwapStateHolder {
return uiState.copy(
sendCardData = uiState.sendCardData.copy(
amount = amount,
amountEquivalent = amountEquivalent,
),
)
}
fun loadingPermissionState(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
permissionState = SwapPermissionState.InProgress,
warnings = uiState.warnings.filterNot { it is SwapWarning.PermissionNeeded },
)
}
fun createSuccessState(uiState: SwapStateHolder, txState: TxState.TxSent): SwapStateHolder {
return uiState.copy(
successState = SwapSuccessStateHolder(
fromTokenAmount = txState.fromAmount ?: "",
toTokenAmount = txState.toAmount ?: "",
onSecondaryButtonClick = {},
),
)
}
fun createSwapErrorTransaction(uiState: SwapStateHolder, onAlertClick: () -> Unit): SwapStateHolder {
return uiState.copy(
swapButton = uiState.swapButton.copy(
enabled = true,
loading = false,
),
alert = SwapWarning.GenericWarning(
message = null,
onClick = onAlertClick,
),
)
}
fun addWarning(uiState: SwapStateHolder, message: String?): SwapStateHolder {
return if (message != null) {
val renewWarnings = uiState.warnings.filterNot { it is SwapWarning.GenericWarning }.toMutableList()
renewWarnings.add(SwapWarning.GenericWarning(message) {})
uiState.copy(
warnings = renewWarnings,
)
} else {
uiState
}
}
fun mapError(uiState: SwapStateHolder, error: DataError): SwapStateHolder {
return when (error) {
//todo use if needed later
// DataError.InsufficientLiquidity -> TODO()
// DataError.NoError -> TODO()
is DataError.UnknownError -> addWarning(uiState, error.message)
else -> addWarning(uiState, null)
}
}
fun addAlert(uiState: SwapStateHolder, onClick: () -> Unit): SwapStateHolder {
return uiState.copy(
alert = SwapWarning.GenericWarning(
message = null,
onClick = onClick,
),
)
}
fun clearAlert(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
alert = null,
)
}
}

View file

@ -38,6 +38,7 @@ import com.tangem.core.ui.components.SmallInfoCard
import com.tangem.core.ui.components.SmallInfoCardWithWarning
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.models.FeeState
import com.tangem.feature.swap.models.SwapButton
@ -161,6 +162,7 @@ private fun MainInfo(state: SwapStateHolder) {
tokenIconUrl = state.sendCardData.tokenIconUrl,
tokenCurrency = state.sendCardData.tokenCurrency,
networkIconRes = state.sendCardData.networkIconRes,
iconPlaceholder = getActiveIconRes(state.sendCardData.tokenId),
onChangeTokenClick = if (state.sendCardData.canSelectAnotherToken) state.onSelectTokenClick else null,
)
SpacerH16()
@ -172,6 +174,7 @@ private fun MainInfo(state: SwapStateHolder) {
tokenIconUrl = state.receiveCardData.tokenIconUrl,
tokenCurrency = state.receiveCardData.tokenCurrency,
networkIconRes = state.receiveCardData.networkIconRes,
iconPlaceholder = getActiveIconRes(state.receiveCardData.tokenId),
onChangeTokenClick = if (state.receiveCardData.canSelectAnotherToken) {
state.onSelectTokenClick
} else {
@ -228,7 +231,7 @@ private fun FeeItem(feeState: FeeState, currency: String) {
SmallInfoCardWithWarning(
startText = titleString,
endText = feeState.fee,
warningText = stringResource(id = R.string.token_details_send_blocked_fee_format, currency),
warningText = stringResource(id = R.string.token_details_send_blocked_fee_format, currency, currency),
)
}
is FeeState.Empty -> {}
@ -298,6 +301,7 @@ private val sendCard = SwapCardData(
networkIconRes = R.drawable.img_polygon_22,
canSelectAnotherToken = false,
balance = "123",
tokenId = "",
)
private val receiveCard = SwapCardData(
@ -309,6 +313,7 @@ private val receiveCard = SwapCardData(
networkIconRes = R.drawable.img_polygon_22,
canSelectAnotherToken = true,
balance = "33333",
tokenId = "",
)
private val state = SwapStateHolder(

View file

@ -74,6 +74,7 @@ fun TransactionCard(
amountEquivalent: String?,
tokenIconUrl: String,
tokenCurrency: String,
@DrawableRes iconPlaceholder: Int? = null,
@DrawableRes networkIconRes: Int? = null,
onChangeTokenClick: (() -> Unit)? = null,
) {
@ -104,6 +105,7 @@ fun TransactionCard(
tokenIconUrl = tokenIconUrl,
tokenCurrency = tokenCurrency,
networkIconRes = networkIconRes,
iconPlaceholder = iconPlaceholder,
)
}
@ -328,6 +330,7 @@ private fun AutoSizeTextField(amount: String, onAmountChange: (String) -> Unit)
fun Token(
tokenIconUrl: String,
tokenCurrency: String,
@DrawableRes iconPlaceholder: Int? = null,
@DrawableRes networkIconRes: Int? = null,
) {
Column(
@ -353,6 +356,8 @@ fun Token(
model = ImageRequest.Builder(LocalContext.current)
.data(tokenIconUrl)
.crossfade(true)
.error(iconPlaceholder ?: 0)
.placeholder(iconPlaceholder ?: 0)
.build(),
loading = { TokenImageShimmer(modifier = tokenImageModifier) },
// error = { CurrencyPlaceholderIcon(modifier = tokenImageModifier, tokenCurrency) },

View file

@ -5,9 +5,11 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
class PeriodicTask(
class PeriodicTask<T>(
private val delay: Long,
private val task: suspend () -> Unit,
private val task: suspend () -> Result<T>,
private val onSuccess: (T) -> Unit,
private val onError: (Throwable) -> Unit,
) {
private var isActive: AtomicBoolean = AtomicBoolean(false)
@ -16,6 +18,18 @@ class PeriodicTask(
isActive.set(true)
while (isActive.get()) {
task.invoke()
.onSuccess {
if (!isActive.get()) {
return@onSuccess
}
onSuccess.invoke(it)
}
.onFailure {
if (!isActive.get()) {
return@onFailure
}
onError.invoke(it)
}
delay(delay)
}
}
@ -25,11 +39,11 @@ class PeriodicTask(
}
}
class SingleTaskScheduler {
class SingleTaskScheduler<T> {
private var lastTask: PeriodicTask? = null
private var lastTask: PeriodicTask<T>? = null
fun scheduleTask(scope: CoroutineScope, task: PeriodicTask) {
fun scheduleTask(scope: CoroutineScope, task: PeriodicTask<T>) {
lastTask?.cancel()
lastTask = task
scope.launch {

View file

@ -0,0 +1,15 @@
package com.tangem.feature.swap.viewmodels
import com.tangem.feature.swap.domain.models.domain.ApproveModel
import com.tangem.feature.swap.domain.models.domain.Currency
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
data class SwapProcessDataState(
val networkId: String,
val fromCurrency: Currency? = null,
val toCurrency: Currency? = null,
val amount: String? = null,
val estimatedGas: Int? = null,
val approveModel: ApproveModel? = null,
val swapModel: SwapDataModel? = null,
)

View file

@ -8,10 +8,12 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.models.createFromAmountWithoutOffset
import com.tangem.feature.swap.domain.models.domain.Currency
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.ui.FoundTokensState
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TxState
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.presentation.SwapFragment
@ -35,18 +37,19 @@ internal class SwapViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val stateBuilder = StateBuilder(
actions = createUiActions(),
)
private val amountDebouncer = Debouncer()
private val singleTaskScheduler = SingleTaskScheduler()
private val currency = Json.decodeFromString<Currency>(
savedStateHandle[SwapFragment.CURRENCY_BUNDLE_KEY]
?: error("no expected parameter Currency found"),
)
var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState(currency.symbol))
private val stateBuilder = StateBuilder(
actions = createUiActions(),
)
private val amountDebouncer = Debouncer()
private val singleTaskScheduler = SingleTaskScheduler<SwapState>()
private var dataState by mutableStateOf(SwapProcessDataState(networkId = currency.networkId))
var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState(currency))
private set
// shows currency order (direct - swap initial to selected, reversed = selected to initial)
@ -60,6 +63,11 @@ internal class SwapViewModel @Inject constructor(
initTokens(currency)
}
override fun onCleared() {
singleTaskScheduler.cancelTask()
super.onCleared()
}
fun setRouter(router: SwapRouter) {
swapRouter = router
uiState = uiState.copy(
@ -75,6 +83,10 @@ internal class SwapViewModel @Inject constructor(
swapInteractor.initTokensToSwap(currency)
}
.onSuccess { state ->
dataState = dataState.copy(
fromCurrency = state.preselectTokens.fromToken,
toCurrency = state.preselectTokens.toToken,
)
updateTokensState(dataState = state.foundTokensState)
startLoadingQuotes(
fromToken = state.preselectTokens.fromToken,
@ -97,71 +109,101 @@ internal class SwapViewModel @Inject constructor(
uiState = stateBuilder.createQuotesLoadingState(uiState, fromToken, toToken, currency.id)
singleTaskScheduler.scheduleTask(
viewModelScope,
PeriodicTask(
delay = UPDATE_DELAY,
) {
loadQuotesInternal(
fromToken = fromToken,
toToken = toToken,
amount = amount,
)
loadQuotesTask(
fromToken = fromToken,
toToken = toToken,
amount = amount,
),
)
}
private fun loadQuotesTask(
fromToken: Currency,
toToken: Currency,
amount: String,
): PeriodicTask<SwapState> {
return PeriodicTask(
UPDATE_DELAY,
task = {
runCatching(dispatchers.io) {
dataState = dataState.copy(
amount = amount,
swapModel = null,
estimatedGas = null,
approveModel = null,
)
swapInteractor.findBestQuote(
networkId = dataState.networkId,
fromToken = fromToken,
toToken = toToken,
amountToSwap = amount,
)
}
},
onSuccess = { swapState ->
when (swapState) {
is SwapState.QuotesLoadedState -> {
fillDataState(swapState.permissionState, swapState.swapDataModel)
uiState = stateBuilder.createQuotesLoadedState(
uiStateHolder = uiState,
quoteModel = swapState,
fromToken = fromToken,
)
}
is SwapState.EmptyAmountState -> {
uiState = stateBuilder.createQuotesEmptyAmountState(
uiStateHolder = uiState,
emptyAmountState = swapState,
)
}
is SwapState.SwapError -> {
uiState = stateBuilder.mapError(uiState, swapState.error)
}
}
},
onError = {
uiState = stateBuilder.addWarning(uiState, it.message)
},
)
}
private fun loadQuotesInternal(fromToken: Currency, toToken: Currency, amount: String) {
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
swapInteractor.findBestQuote(
fromToken = fromToken,
toToken = toToken,
amount = createFromAmountWithoutOffset(
amountWithoutOffset = amount,
decimals = swapInteractor.getTokenDecimals(fromToken),
),
)
}
.onSuccess { swapState ->
when (swapState) {
is SwapState.QuotesLoadedState -> {
uiState = stateBuilder.createQuotesLoadedState(
uiStateHolder = uiState,
quoteModel = swapState,
fromToken = fromToken,
)
}
is SwapState.SwapError -> {
}
}
}
.onFailure { }
private fun fillDataState(permissionState: PermissionDataState, swapDataModel: SwapDataModel?) {
dataState = if (permissionState is PermissionDataState.PermissionReadyForRequest) {
dataState.copy(
estimatedGas = permissionState.requestApproveData.estimatedGas,
approveModel = permissionState.requestApproveData.approveModel,
)
} else {
dataState.copy(
swapModel = swapDataModel,
)
}
}
private fun createUiActions(): UiActions {
return UiActions(
onSearchEntered = { onSearchEntered(it) },
onTokenSelected = { onTokenSelect(it) },
onAmountChanged = { onAmountChanged(it) },
onSwapClick = { onSwapClick() },
onGivePermissionClick = { givePermissionsToSwap() },
onChangeCardsClicked = { onChangeCardsClicked() },
onBackClicked = { onSearchEntered("") },
)
}
private fun onSwapClick() {
singleTaskScheduler.cancelTask()
uiState = stateBuilder.createSwapInProgressState(uiState)
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
swapInteractor.onSwap()
swapInteractor.onSwap(
networkId = dataState.networkId,
swapData = dataState.swapModel!!,
currencyToSend = dataState.fromCurrency!!,
currencyToGet = dataState.toCurrency!!,
amountToSwap = dataState.amount!!,
)
}
.onSuccess {
when (it) {
is SwapState.SwapError -> {
is TxState.TxSent -> {
uiState = stateBuilder.createSuccessState(uiState, it)
swapRouter.openScreen(SwapScreen.Success)
}
else -> {
uiState = stateBuilder.createSwapErrorTransaction(uiState) {
uiState = stateBuilder.clearAlert(uiState)
}
}
else -> {}
}
}
.onFailure { }
@ -171,17 +213,38 @@ internal class SwapViewModel @Inject constructor(
private fun givePermissionsToSwap() {
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
swapInteractor.givePermissionToSwap()
swapInteractor.givePermissionToSwap(
networkId = dataState.networkId,
estimatedGas = dataState.estimatedGas!!,
transactionData = dataState.approveModel!!,
forTokenContractAddress = (dataState.fromCurrency as? Currency.NonNativeToken)?.contractAddress
?: "",
)
}
.onSuccess { }
.onFailure { }
.onSuccess {
when (it) {
is TxState.TxSent -> {
uiState = stateBuilder.loadingPermissionState(uiState)
}
else -> {
uiState = stateBuilder.addAlert(uiState) {
uiState = stateBuilder.clearAlert(uiState)
}
}
}
}
.onFailure {
uiState = stateBuilder.addAlert(uiState) {
uiState = stateBuilder.clearAlert(uiState)
}
}
}
}
private fun onSearchEntered(searchQuery: String) {
viewModelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
swapInteractor.onSearchToken(searchQuery)
swapInteractor.onSearchToken(dataState.networkId, searchQuery)
}
.onSuccess {
updateTokensState(it)
@ -202,42 +265,55 @@ internal class SwapViewModel @Inject constructor(
fromToken = currency
toToken = foundToken
}
swapRouter.openScreen(SwapScreen.Main)
dataState = dataState.copy(
fromCurrency = fromToken,
toCurrency = toToken,
)
startLoadingQuotes(fromToken, toToken, lastAmount.value)
swapRouter.openScreen(SwapScreen.Main)
}
}
private fun onChangeCardsClicked() {
val currencies = swapInteractor.getExchangeCurrencies()
val newFromToken = currencies?.toCurrency
val newToToken = currencies?.fromCurrency
val newFromToken = dataState.toCurrency
val newToToken = dataState.fromCurrency
if (newFromToken != null && newToToken != null) {
dataState = dataState.copy(
fromCurrency = newFromToken,
toCurrency = newToToken,
)
isOrderReversed = !isOrderReversed
startLoadingQuotes(newFromToken, newToToken, lastAmount.value)
}
}
private fun onAmountChanged(value: String) {
uiState = stateBuilder.updateSwapAmount(uiState, value, value)
uiState = stateBuilder.updateSwapAmount(uiState, value)
lastAmount.value = value
amountDebouncer.debounce(DEBOUNCE_AMOUNT_DELAY, viewModelScope) {
val currencies = swapInteractor.getExchangeCurrencies()
val fromToken = currencies?.fromCurrency
val toToken = currencies?.toCurrency
val fromToken = dataState.fromCurrency
val toToken = dataState.toCurrency
if (fromToken != null && toToken != null) {
startLoadingQuotes(fromToken, toToken, lastAmount.value)
}
}
}
override fun onCleared() {
singleTaskScheduler.cancelTask()
super.onCleared()
private fun createUiActions(): UiActions {
return UiActions(
onSearchEntered = { onSearchEntered(it) },
onTokenSelected = { onTokenSelect(it) },
onAmountChanged = { onAmountChanged(it) },
onSwapClick = { onSwapClick() },
onGivePermissionClick = { givePermissionsToSwap() },
onChangeCardsClicked = { onChangeCardsClicked() },
onBackClicked = { onSearchEntered("") },
)
}
companion object {
private const val INITIAL_AMOUNT = "1"
private const val UPDATE_DELAY = 10000L
private const val INITIAL_AMOUNT = ""
private const val UPDATE_DELAY = 60000L
private const val DEBOUNCE_AMOUNT_DELAY = 1000L
}
}

View file

@ -7,16 +7,26 @@ import java.math.BigDecimal
interface TransactionManager {
@Throws(IllegalStateException::class)
suspend fun sendApproveTransaction(
networkId: String,
feeAmount: BigDecimal,
estimatedGas: Int,
destinationAddress: String,
dataToSign: String,
): SendTxResult
@Suppress("LongParameterList")
@Throws(IllegalStateException::class)
suspend fun sendTransaction(
networkId: String,
amountToSend: BigDecimal,
currencyToSend: Currency,
feeAmount: BigDecimal,
estimatedGas: Int,
destinationAddress: String,
dataToSign: String,
isSwap: Boolean,
currencyToSend: Currency,
): SendTxResult
@Throws(IllegalStateException::class)
@ -30,5 +40,8 @@ interface TransactionManager {
@Throws(IllegalStateException::class)
fun getNativeTokenDecimals(networkId: String): Int
@Throws(IllegalStateException::class)
suspend fun updateWalletManager(networkId: String)
fun calculateFee(networkId: String, gasPrice: String, estimatedGas: Int): BigDecimal
}

View file

@ -49,6 +49,10 @@ interface UserWalletManager {
@Throws(IllegalStateException::class)
fun getCurrentWalletTokensBalance(networkId: String): Map<String, ProxyAmount>
fun getNativeTokenBalance(networkId: String): ProxyAmount?
fun getNetworkCurrency(networkId: String): String
/**
* Returns selected app currency
*/