Updated on 2026-08-14

This commit is contained in:
Tangem 2024-08-07 09:59:23 +02:00
commit 3a321cfbe9
45 changed files with 897 additions and 272 deletions

@ -1 +1 @@
Subproject commit b5726a45bf51b7763c5967afae4d3d687676240b
Subproject commit 095b8f4ea0fa02e7ccea93cf0f437346345297ef

View file

@ -176,9 +176,9 @@ class WalletConnectSdkHelper {
is Result.Success -> gasLimitResult.data.toBigDecimal().multiply(BigDecimal("1.2"))
is Result.Failure -> {
(gasLimitResult.error as? Throwable)?.let { Timber.e(it, "getGasLimit failed") }
BigDecimal(DEFAULT_MAX_GASLIMIT) // Set high gasLimit if not provided
DEFAULT_MAX_GASLIMIT.toBigDecimal() // Set high gasLimit if not provided
}
else -> BigDecimal(DEFAULT_MAX_GASLIMIT) // Set high gasLimit if not provided
else -> DEFAULT_MAX_GASLIMIT.toBigDecimal() // Set high gasLimit if not provided
}
}

View file

@ -35,7 +35,8 @@ internal class DefaultLegacyWalletConnectRepository(
private val _activeSessions: MutableSharedFlow<List<WalletConnectSession>> = MutableSharedFlow()
override val activeSessions: Flow<List<WalletConnectSession>> = _activeSessions
private var currentSessions: List<WalletConnectSession> = emptyList()
override var currentSessions: List<WalletConnectSession> = emptyList()
private set
/**
* @param projectId Project ID at https://cloud.walletconnect.com/

View file

@ -9,6 +9,8 @@ interface LegacyWalletConnectRepository {
val activeSessions: Flow<List<WalletConnectSession>>
val currentSessions: List<WalletConnectSession>
fun init(projectId: String)
fun setUserNamespaces(userNamespaces: Map<NetworkNamespace, List<Account>>)

View file

@ -132,6 +132,7 @@ class WalletConnectInteractor(
runCatching {
if (accounts.isEmpty()) return
isWalletConnectReadyForDeepLinks = true
if (deeplinkStack.empty()) return
val lastDeeplink = deeplinkStack.pop()
store.dispatchOnMain(WalletConnectAction.OpenSession(lastDeeplink))
}.onFailure {
@ -361,6 +362,19 @@ class WalletConnectInteractor(
* @param deeplink deeplink to handle
*/
fun addDeeplink(deeplink: String) {
val deeplinkRegex = Regex(WC_PARAM_REGEX)
val matched = deeplinkRegex.findAll(deeplink)
val sessionTopic = matched.firstOrNull { it.value.contains(WC_TOPIC_QUERY_NAME) }?.groupValues?.lastOrNull()
val isAlreadyActiveSessionTopic = walletConnectRepository.currentSessions.any { session ->
session.topic == sessionTopic
}
if (isAlreadyActiveSessionTopic && sessionTopic != null) {
Timber.i("WC already has an active session topic: $deeplink")
return
}
if (isWalletConnectReadyForDeepLinks) {
store.dispatchOnMain(WalletConnectAction.OpenSession(deeplink))
} else {
@ -406,7 +420,9 @@ class WalletConnectInteractor(
return sessionRequestConverter.prepareRequest(sessionRequest, userWalletId)
}
companion object {
private const val WC_SCHEME = "wc"
private companion object {
const val WC_SCHEME = "wc"
const val WC_TOPIC_QUERY_NAME = "sessionTopic"
const val WC_PARAM_REGEX = "([a-zA-Z\\d-]+)=([a-zA-Z\\d]+)"
}
}

View file

@ -1,5 +1,7 @@
package com.tangem.tap.features.home.redux
import com.google.firebase.analytics.ktx.analytics
import com.google.firebase.ktx.Firebase
import com.tangem.common.doOnFailure
import com.tangem.common.doOnResult
import com.tangem.common.doOnSuccess
@ -62,8 +64,13 @@ private fun handleHomeAction(action: Action) {
}
is HomeAction.GoToShop -> {
Analytics.send(Shop.ScreenOpened())
store.dispatchOpenUrl(NEW_BUY_WALLET_URL)
Firebase.analytics.appInstanceId
.addOnSuccessListener {
store.dispatchOpenUrl("$NEW_BUY_WALLET_URL&app_instance_id=$it")
}
.addOnFailureListener {
store.dispatchOpenUrl(NEW_BUY_WALLET_URL)
}
// disabled for now in task [REDACTED_JIRA]
// when (action.userCountryCode) {
// RUSSIA_COUNTRY_CODE, BELARUS_COUNTRY_CODE -> store.dispatchOpenUrl(BUY_WALLET_URL)

View file

@ -39,7 +39,7 @@ class TransactionManagerImpl(
@Throws(IllegalStateException::class)
override suspend fun getFee(
networkId: String,
amountToSend: BigDecimal,
amountToSend: Amount,
currencyToSend: Currency,
destinationAddress: String,
increaseBy: Int?,
@ -52,7 +52,7 @@ class TransactionManagerImpl(
if (walletManager is EthereumOptimisticRollupWalletManager) {
return getFeeForOptimismBlockchain(
walletManager = walletManager,
amount = createAmount(amountToSend, currencyToSend, blockchain),
amount = amountToSend,
destinationAddress = destinationAddress,
data = data,
)
@ -61,7 +61,6 @@ class TransactionManagerImpl(
walletManager = walletManager,
blockchain = blockchain,
amountToSend = amountToSend,
currency = currencyToSend,
destinationAddress = destinationAddress,
data = data,
increaseBy = increaseBy,
@ -70,8 +69,6 @@ class TransactionManagerImpl(
return getFeeForBlockchain(
walletManager = walletManager,
amountToSend = amountToSend,
currency = currencyToSend,
blockchain = blockchain,
destinationAddress = destinationAddress,
)
}
@ -87,13 +84,11 @@ class TransactionManagerImpl(
private suspend fun getFeeForBlockchain(
walletManager: WalletManager,
amountToSend: BigDecimal,
currency: Currency,
blockchain: Blockchain,
amountToSend: Amount,
destinationAddress: String,
): ProxyFees {
val fee = (walletManager as? TransactionSender)?.getFee(
amount = createAmount(amountToSend, currency, blockchain),
amount = amountToSend,
destination = destinationAddress,
) ?: error("Cannot cast to TransactionSender")
return when (fee) {
@ -146,17 +141,14 @@ class TransactionManagerImpl(
private suspend fun getFeeForEthereumBlockchain(
walletManager: EthereumWalletManager,
blockchain: Blockchain,
amountToSend: BigDecimal,
currency: Currency,
amountToSend: Amount,
destinationAddress: String,
data: String?,
increaseBy: Int?,
): ProxyFees {
val gasLimit = getGasLimit(
evmWalletManager = walletManager,
blockchain = blockchain,
amount = amountToSend,
currency = currency,
destinationAddress = destinationAddress,
data = data,
).increaseBigIntegerByPercents(increaseBy)
@ -210,23 +202,20 @@ class TransactionManagerImpl(
}
}
@Suppress("LongParameterList")
private suspend fun getGasLimit(
evmWalletManager: EthereumWalletManager,
blockchain: Blockchain,
amount: BigDecimal,
currency: Currency,
amount: Amount,
destinationAddress: String,
data: String?,
): BigInteger {
val result = if (data.isNullOrEmpty()) {
evmWalletManager.getGasLimit(
amount = createAmount(amount, currency, blockchain),
amount = amount,
destination = destinationAddress,
)
} else {
evmWalletManager.getGasLimit(
amount = createAmount(amount, currency, blockchain),
amount = amount,
destination = destinationAddress,
data = data,
)
@ -241,6 +230,18 @@ class TransactionManagerImpl(
}
}
override suspend fun getFeeForGas(networkId: String, gas: BigInteger, derivationPath: String?): ProxyFees {
val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" }
val walletManager = getActualWalletManager(blockchain, derivationPath)
val gasPriceResult = (walletManager as? EthereumWalletManager)?.getGasPrice()
?: error("not supported for $blockchain")
val gasPrice = when (gasPriceResult) {
is Result.Failure -> error("fail to receive gasPrice")
is Result.Success -> gasPriceResult.data
}
return createMultipleProxyFees(gasPrice, gas, blockchain)
}
private suspend fun getActualWalletManager(blockchain: Blockchain, derivationPath: String?): WalletManager {
val selectedUserWallet = requireNotNull(
userWalletsListManager.selectedUserWalletSync,
@ -307,27 +308,6 @@ class TransactionManagerImpl(
)
}
private fun createAmount(amount: BigDecimal, currency: Currency, blockchain: Blockchain): Amount {
return when (currency) {
is Currency.NativeToken -> {
Amount(value = amount, blockchain = blockchain)
}
is Currency.NonNativeToken -> {
Amount(convertNonNativeToken(currency), amount)
}
}
}
private fun convertNonNativeToken(token: Currency.NonNativeToken): Token {
return Token(
name = token.name,
symbol = token.symbol,
contractAddress = token.contractAddress,
decimals = token.decimalCount,
id = token.id,
)
}
private fun convertToProxyAmount(amount: Amount): ProxyAmount {
return ProxyAmount(
currencySymbol = amount.currencySymbol,

View file

@ -1,7 +1,6 @@
package com.tangem.datasource.api.express.models.response
import com.squareup.moshi.Json
import java.math.BigDecimal
data class ExchangeDataResponseWithTxDetails(
val dataResponse: ExchangeDataResponse,
@ -50,7 +49,10 @@ data class TxDetails(
val txData: String?, // transaction data if DEX, null if CEX
@Json(name = "txValue")
val txValue: BigDecimal, // amount (same as fromAmount)
val txValue: String, // amount (same as fromAmount for Coin, but for bridge equal to otherNativeFee)
@Json(name = "otherNativeFee")
val otherNativeFee: String?,
@Json(name = "externalTxId")
val externalTxId: String?, // null if DEX, provider transaction id if CEX
@ -63,6 +65,9 @@ data class TxDetails(
@Json(name = "txExtraId")
val txExtraId: String?,
@Json(name = "gas")
val gas: String?,
)
enum class TxType {

View file

@ -7,17 +7,23 @@ data class ExchangeStatusResponse(
@Json(name = "providerId")
val providerId: String,
@Json(name = "externalTxId")
val externalTxId: String,
@Json(name = "status")
val status: ExchangeStatus,
@Json(name = "externalTxId")
val externalTxId: String?,
@Json(name = "externalTxUrl")
val externalTxUrl: String,
val externalTxUrl: String?,
@Json(name = "error")
val error: ExchangeStatusError?,
@Json(name = "refundNetwork")
val refundNetwork: String? = null,
@Json(name = "refundContractAddress")
val refundContractAddress: String? = null,
)
enum class ExchangeStatus {

View file

@ -18,6 +18,7 @@ interface TangemTechApi {
@Query("contractAddress") contractAddress: String? = null,
@Query("exchangeable") exchangeable: Boolean? = null,
@Query("networkIds") networkIds: String? = null,
@Query("networkId") networkId: String? = null,
@Query("active") active: Boolean? = null,
@Query("searchText") searchText: String? = null,
@Query("offset") offset: Int? = null,

View file

@ -10,9 +10,12 @@ interface SwapTransactionStatusStore {
}
enum class ExchangeAnalyticsStatus(val value: String) {
WaitingTxHash("Waiting tx hash"),
InProgress("In Progress"),
Done("Done"),
Fail("Fail"),
FailTx("Fail tx"),
Unknown("Unknown"),
KYC("KYC"),
Refunded("Refunded"),
Cancelled("Canceled"),

View file

@ -0,0 +1,125 @@
package com.tangem.core.ui.components.notifications
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.colorspace.ColorSpaces
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
/**
* Currency notification component from Design system.
*
* @param config component config
* @param modifier modifier
* @param containerColor container color
*
* @see <a href = "https://www.figma.com/design/14ISV23YB1yVW1uNVwqrKv/Android?node-id=3251-3466&t=eZb7ZPoNE8pQw45Y-4"
* >Figma component</a>
*/
@Composable
fun CurrencyNotification(
config: CurrencyNotificationConfig,
modifier: Modifier = Modifier,
containerColor: Color? = null,
) {
NotificationBaseContainer(
buttonsState = config.buttonsState,
onClick = null,
onCloseClick = null,
modifier = modifier,
containerColor = containerColor,
) {
MainContent(
tokenIconState = config.tokenIconState,
title = config.title,
subtitle = config.subtitle,
)
}
}
@Composable
private fun MainContent(
tokenIconState: CurrencyIconState,
title: TextReference,
subtitle: CurrencyNotificationConfig.AnnotatedSubtitle,
) {
Row {
CurrencyIcon(
state = tokenIconState,
modifier = Modifier.align(alignment = Alignment.CenterVertically),
)
SpacerW(width = TangemTheme.dimens.spacing6)
TextsBlock(title = title, subtitle = subtitle)
}
}
@Composable
private fun TextsBlock(title: TextReference, subtitle: CurrencyNotificationConfig.AnnotatedSubtitle) {
Column(verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing2)) {
Text(
text = title.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.button,
)
val subtitleValue = subtitle.valueProvider()
ClickableText(
text = subtitleValue,
onClick = { subtitle.onClick(subtitleValue, it) },
style = TangemTheme.typography.caption2,
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_Notification() {
TangemThemePreview {
CurrencyNotification(
config = CurrencyNotificationConfig(
title = resourceReference(
R.string.express_exchange_notification_refund_title,
wrappedList("USDT", "Polygon"),
),
subtitle = CurrencyNotificationConfig.AnnotatedSubtitle(
valueProvider = {
buildAnnotatedString {
append("Your transaction amount was refunded in USDT to your wallet due to OKX")
}
},
onClick = { _, _ -> },
),
tokenIconState = CurrencyIconState.TokenIcon(
url = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/usd-coin.png",
topBadgeIconResId = R.drawable.ic_polygon_22,
isGrayscale = false,
showCustomBadge = false,
fallbackTint = Color(1.0f, 1.0f, 1.0f, 1.0f, ColorSpaces.Srgb),
fallbackBackground = Color(0.23529412f, 0.28627452f, 0.6117647f, 1.0f, ColorSpaces.Srgb),
),
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = stringReference("Go to token"),
onClick = {},
),
),
)
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.core.ui.components.notifications
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
/**
* Currency notification component state
*
* @property title title
* @property subtitle subtitle
* @property buttonsState buttons state
* @property tokenIconState token icon state
*
[REDACTED_AUTHOR]
*/
data class CurrencyNotificationConfig(
val title: TextReference,
val subtitle: AnnotatedSubtitle,
val tokenIconState: CurrencyIconState,
val buttonsState: NotificationConfig.ButtonsState,
) {
/**
* Subtitle as [AnnotatedString]
*
* @property valueProvider composable function that provides [AnnotatedString]
* @property onClick lambda be invoked when text in specified position is clicked
*/
data class AnnotatedSubtitle(
val valueProvider: @Composable () -> AnnotatedString,
val onClick: (value: AnnotatedString, position: Int) -> Unit,
)
}

View file

@ -30,17 +30,19 @@ import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState as NotificationButtonsState
/**
* Notification component from Design system.
* Use this for Notification with title, subtitle, clickable or not.
*
* @param config component config
* @param modifier modifier
* @param iconTint icon tint
* @param config component config
* @param modifier modifier
* @param containerColor container color
* @param iconTint icon tint
* @param isEnabled flag that defines if component is clickable
*
* @see <a href = "https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?node-id=1045-807&t=6CVvYDJe0sB7wBKE-0"
* >Figma component</a>
@ -53,44 +55,33 @@ fun Notification(
iconTint: Color? = null,
isEnabled: Boolean = true,
) {
BaseContainer(
NotificationBaseContainer(
buttonsState = config.buttonsState,
onClick = config.onClick,
onCloseClick = config.onCloseClick,
modifier = modifier,
containerColor = containerColor,
isEnabled = isEnabled,
) {
Column(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12),
) {
MainContent(
iconResId = config.iconResId,
iconTint = iconTint,
title = config.title,
subtitle = config.subtitle,
isClickableComponent = isEnabled && config.onClick != null,
)
Buttons(state = config.buttonsState, isEnabled = isEnabled)
}
CloseableIconButton(
onClick = config.onCloseClick,
modifier = Modifier.align(alignment = Alignment.TopEnd),
isEnabled = isEnabled,
MainContent(
iconResId = config.iconResId,
iconTint = iconTint,
title = config.title,
subtitle = config.subtitle,
isClickableComponent = isEnabled && config.onClick != null,
)
}
}
@Composable
private fun BaseContainer(
internal fun NotificationBaseContainer(
buttonsState: NotificationConfig.ButtonsState?,
onClick: (() -> Unit)?,
onCloseClick: (() -> Unit)?,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
containerColor: Color? = null,
content: @Composable BoxScope.() -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
val tempContainerColor by rememberUpdatedState(
newValue = if (buttonsState != null || onClick != null) {
@ -109,7 +100,22 @@ private fun BaseContainer(
shape = TangemTheme.shapes.roundedCornersXMedium,
color = containerColor ?: tempContainerColor,
) {
Box(content = content)
Box {
Column(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12),
) {
content()
Buttons(state = buttonsState, isEnabled = isEnabled)
}
CloseableIconButton(
onClick = onCloseClick,
modifier = Modifier.align(alignment = Alignment.TopEnd),
isEnabled = isEnabled,
)
}
}
}

View file

@ -82,7 +82,9 @@ private fun LazyListScope.contentItems(
when (item) {
is TxHistoryState.TxHistoryItemState.GroupTitle -> item.itemKey
is TxHistoryState.TxHistoryItemState.Title -> item.onExploreClick.hashCode()
is TxHistoryState.TxHistoryItemState.Transaction -> item.state.txHash
is TxHistoryState.TxHistoryItemState.Transaction ->
item.state.txHash +
((item.state as? TransactionState.Content)?.hashCode() ?: "")
}
},
contentType = txHistoryItems.itemContentType { it::class.java },

View file

@ -33,19 +33,17 @@ internal class DefaultPromoRepository(
}
override suspend fun getOkxPromoBanner(): PromoBanner? {
// TODO disabled for 5.12, enable for 5.12.1
return null
// return runCatching(dispatchers.io) {
// promoResponseConverter.convert(
// tangemApi.getPromotionInfo(OKX)
// .getOrThrow(),
// )
// }.getOrNull()
return runCatching(dispatchers.io) {
promoResponseConverter.convert(
tangemApi.getPromotionInfo(OKX)
.getOrThrow(),
)
}.getOrNull()
}
private companion object {
private const val CHANGELLY_NAME = "changelly"
private const val TRAVALA = "travala"
// private const val OKX = "okx"
private const val OKX = "okx"
}
}

View file

@ -39,7 +39,7 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
import com.tangem.blockchain.common.FeePaidCurrency as FeePaidSdkCurrency
@Suppress("LargeClass", "LongParameterList")
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
internal class DefaultCurrenciesRepository(
private val tangemTechApi: TangemTechApi,
private val tangemExpressApi: TangemExpressApi,
@ -53,6 +53,7 @@ internal class DefaultCurrenciesRepository(
private val demoConfig = DemoConfig()
private val responseCurrenciesFactory = ResponseCryptoCurrenciesFactory()
private val cryptoCurrencyFactory = CryptoCurrencyFactory()
private val cardCurrenciesFactory = CardCryptoCurrenciesFactory(demoConfig)
private val userTokensResponseFactory = UserTokensResponseFactory()
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
@ -125,7 +126,7 @@ internal class DefaultCurrenciesRepository(
return newTokens
.filterNot { savedCurrencies.hasCoinForToken(it) } // tokens without coins
.mapNotNull {
CryptoCurrencyFactory().createCoin(
cryptoCurrencyFactory.createCoin(
blockchain = getBlockchain(networkId = it.network.id),
extraDerivationPath = it.network.derivationPath.value,
derivationStyleProvider = getUserWallet(userWalletId).scanResponse.derivationStyleProvider,
@ -415,12 +416,44 @@ internal class DefaultCurrenciesRepository(
}
override fun createTokenCurrency(cryptoCurrency: CryptoCurrency.Token, network: Network): CryptoCurrency.Token {
return CryptoCurrencyFactory().createToken(
return cryptoCurrencyFactory.createToken(
cryptoCurrency = cryptoCurrency,
network = network,
)
}
override suspend fun createTokenCurrency(
userWalletId: UserWalletId,
contractAddress: String,
networkId: String,
): CryptoCurrency.Token {
val userWallet = getUserWallet(userWalletId)
val token = withContext(dispatchers.io) {
val foundToken = tangemTechApi.getCoins(
contractAddress = contractAddress,
networkId = networkId,
)
.getOrThrow()
.coins
.firstOrNull()
?: error("Token not found")
val network = foundToken.networks.firstOrNull { it.networkId == networkId } ?: error("Network not found")
CryptoCurrencyFactory.Token(
symbol = foundToken.symbol,
name = foundToken.name,
contractAddress = contractAddress,
decimals = network.decimalCount?.toInt() ?: error("Decimals not found"),
id = foundToken.id,
)
}
return cryptoCurrencyFactory.createToken(
token = token,
networkId = networkId,
extraDerivationPath = null,
derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider,
) ?: error("Unable to create token")
}
private fun getMultiCurrencyWalletCurrencies(userWallet: UserWallet): Flow<List<CryptoCurrency>> {
return userTokensStore.get(userWallet.walletId).map { storedTokens ->
responseCurrenciesFactory.createCurrencies(

View file

@ -99,7 +99,7 @@ class CryptoCurrencyFactory {
decimals = cryptoCurrency.decimals,
id = cryptoCurrency.id.rawCurrencyId,
)
val blockchain = Blockchain.fromNetworkId(cryptoCurrency.network.id.value) ?: Blockchain.Unknown
val blockchain = Blockchain.fromNetworkId(cryptoCurrency.network.backendId) ?: Blockchain.Unknown
val id = getTokenId(network, sdkToken)
return CryptoCurrency.Token(
id = id,

View file

@ -24,7 +24,6 @@ import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.math.BigDecimal
import java.math.BigInteger
import com.tangem.blockchain.blockchains.tron.TransactionType as SdkTransactionType
@ -41,7 +40,6 @@ internal class DefaultTransactionRepository(
destination: String,
userWalletId: UserWalletId,
network: Network,
isSwap: Boolean,
txExtras: TransactionExtras?,
hash: String?,
): TransactionData.Uncompiled? = withContext(coroutineDispatcherProvider.io) {
@ -58,7 +56,6 @@ internal class DefaultTransactionRepository(
memo = memo,
destination = destination,
network = network,
isSwap = isSwap,
txExtras = txExtras,
hash = hash,
)
@ -91,7 +88,6 @@ internal class DefaultTransactionRepository(
memo = memo,
destination = destination,
network = network,
isSwap = isSwap,
txExtras = txExtras,
hash = hash,
)
@ -158,23 +154,15 @@ internal class DefaultTransactionRepository(
memo: String?,
destination: String,
network: Network,
isSwap: Boolean,
txExtras: TransactionExtras?,
hash: String?,
): TransactionData.Uncompiled {
// TODO: refactor workaround to use general mechanism in bsdk for build tx for DEX
val txAmount = if (isSwap) {
createAmountForSwap(amount)
} else {
amount
}
if (txExtras != null && memo != null) {
// throw error for now to avoid programmers errors when use extras
error("Both txExtras and memo provided, use only one of them")
}
val extras = txExtras ?: getMemoExtras(network.id.value, memo)
return createTransaction(txAmount, fee, destination).copy(
return createTransaction(amount, fee, destination).copy(
hash = hash,
extras = extras,
)
@ -203,19 +191,4 @@ internal class DefaultTransactionRepository(
else -> null
}
}
private fun createAmountForSwap(amount: Amount): Amount {
return when (amount.type) {
is AmountType.Coin -> amount
else -> {
// 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 = amount.currencySymbol,
value = BigDecimal.ZERO,
decimals = amount.decimals,
)
}
}
}
}

View file

@ -79,13 +79,35 @@ class AddCryptoCurrenciesUseCase(
.toNonEmptyListOrNull()
?: return@either
catch({ currenciesRepository.addCurrencies(userWalletId, currenciesToAdd) }) {
raise(it)
}
addCurrencies(userWalletId, currenciesToAdd)
refreshUpdatedNetworks(userWalletId, currenciesToAdd, existingCurrencies)
}
suspend operator fun invoke(
userWalletId: UserWalletId,
contractAddress: String,
networkId: String,
): Either<Throwable, CryptoCurrency> = either {
val existingCurrencies =
catch({ currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) }) {
raise(it)
}
val foundToken = existingCurrencies
.filterIsInstance<CryptoCurrency.Token>()
.firstOrNull {
it.network.backendId == networkId &&
!it.isCustom &&
it.contractAddress.equals(contractAddress, true)
}
if (foundToken != null) {
return@either foundToken
}
val tokenToAdd = createTokenCurrency(userWalletId, contractAddress, networkId)
addCurrencies(userWalletId, listOf(tokenToAdd))
refreshUpdatedNetworks(userWalletId, listOf(tokenToAdd), existingCurrencies)
tokenToAdd
}
/**
* Refreshes the network statuses for tokens that have corresponding coins in the
* [existingCurrencies] list.
@ -117,6 +139,33 @@ class AddCryptoCurrenciesUseCase(
}
}
private suspend fun Raise<Throwable>.createTokenCurrency(
userWalletId: UserWalletId,
contractAddress: String,
networkId: String,
): CryptoCurrency.Token {
return catch(
block = {
currenciesRepository.createTokenCurrency(
userWalletId = userWalletId,
contractAddress = contractAddress,
networkId = networkId,
)
},
catch = {
raise(it)
},
)
}
private suspend fun Raise<Throwable>.addCurrencies(userWalletId: UserWalletId, tokens: List<CryptoCurrency>) {
catch(
{ currenciesRepository.addCurrencies(userWalletId, tokens) },
) {
raise(it)
}
}
/**
* Determines if the [existingCurrencies] list contains a coin that corresponds
* to the given [token].

View file

@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.Flow
/**
* Repository for everything related to the tokens of user wallet
* */
@Suppress("TooManyFunctions")
interface CurrenciesRepository {
/**
@ -213,4 +214,13 @@ interface CurrenciesRepository {
* Creates token [cryptoCurrency] based on current token and [network] it`s will be added
*/
fun createTokenCurrency(cryptoCurrency: CryptoCurrency.Token, network: Network): CryptoCurrency.Token
/**
* Creates token [cryptoCurrency] based on [contractAddress] and [networkId] it`s will be added
*/
suspend fun createTokenCurrency(
userWalletId: UserWalletId,
contractAddress: String,
networkId: String,
): CryptoCurrency.Token
}

View file

@ -136,4 +136,12 @@ internal class MockCurrenciesRepository(
override fun createTokenCurrency(cryptoCurrency: CryptoCurrency.Token, network: Network): CryptoCurrency.Token {
return cryptoCurrency
}
override suspend fun createTokenCurrency(
userWalletId: UserWalletId,
contractAddress: String,
networkId: String,
): CryptoCurrency.Token {
error("not implemented")
}
}

View file

@ -18,7 +18,6 @@ interface TransactionRepository {
destination: String,
userWalletId: UserWalletId,
network: Network,
isSwap: Boolean,
txExtras: TransactionExtras?,
hash: String?,
): TransactionData.Uncompiled?

View file

@ -24,7 +24,6 @@ class CreateTransactionUseCase(
userWalletId: UserWalletId,
network: Network,
txExtras: TransactionExtras? = null,
isSwap: Boolean = false,
hash: String? = null,
) = Either.catch {
requireNotNull(
@ -35,7 +34,6 @@ class CreateTransactionUseCase(
destination = destination,
userWalletId = userWalletId,
network = network,
isSwap = isSwap,
txExtras = txExtras,
hash = hash,
),

View file

@ -15,6 +15,8 @@ internal class ExchangeStatusConverter : Converter<ExchangeStatusResponse, Excha
txId = value.externalTxId,
txExternalUrl = value.externalTxUrl,
txExternalId = value.externalTxId,
refundNetwork = value.refundNetwork,
refundContractAddress = value.refundContractAddress,
)
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.feature.swap.domain.models.createFromAmountWithOffset
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class ExpressDataConverter : Converter<ExchangeDataResponseWithTxDetails, SwapDataModel> {
@ -24,19 +25,30 @@ internal class ExpressDataConverter : Converter<ExchangeDataResponseWithTxDetail
dataResponse: ExchangeDataResponse,
): ExpressTransactionModel {
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" }
}
}
ExpressTransactionModel.DEX(
fromAmount = createFromAmountWithOffset(dataResponse.fromAmount, dataResponse.fromDecimals),
toAmount = createFromAmountWithOffset(dataResponse.toAmount, dataResponse.toDecimals),
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 {
ExpressTransactionModel.CEX(
fromAmount = createFromAmountWithOffset(dataResponse.fromAmount, dataResponse.fromDecimals),
toAmount = createFromAmountWithOffset(dataResponse.toAmount, dataResponse.toDecimals),
txValue = transactionDto.txValue,
txId = dataResponse.txId,
txTo = transactionDto.txTo,
externalTxId = requireNotNull(transactionDto.externalTxId),

View file

@ -6,6 +6,8 @@ data class ExchangeStatusModel(
val txId: String? = null,
val txExternalUrl: String? = null,
val txExternalId: String? = null,
val refundNetwork: String? = null,
val refundContractAddress: String? = null,
)
enum class ExchangeStatus {
@ -20,5 +22,6 @@ enum class ExchangeStatus {
Finished,
Refunded,
Cancelled,
TxFailed,
Unknown,
}

View file

@ -1,28 +1,38 @@
package com.tangem.feature.swap.domain.models.domain
import com.tangem.feature.swap.domain.models.SwapAmount
import java.math.BigDecimal
import java.math.BigInteger
sealed class ExpressTransactionModel {
abstract val fromAmount: SwapAmount
abstract val toAmount: SwapAmount
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: SwapAmount,
override val toAmount: SwapAmount,
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,
) : ExpressTransactionModel()
data class CEX(
override val fromAmount: SwapAmount,
override val toAmount: SwapAmount,
override val txValue: String,
override val txId: String,
override val txTo: String,
override val txExtraId: String?,

View file

@ -8,6 +8,10 @@ import java.math.BigDecimal
sealed interface SwapState {
/**
* @param txFee fee state uses for calculation and build transaction
* @param txFeeIncludeOtherNativeFee fee state uses for display and included otherNativeFee (specific for bridge)
*/
data class QuotesLoadedState(
val fromTokenInfo: TokenSwapInfo,
val toTokenInfo: TokenSwapInfo,
@ -23,6 +27,7 @@ sealed interface SwapState {
val swapDataModel: SwapDataModel? = null,
val txFee: TxFeeState,
val warnings: List<Warning> = emptyList(),
val swapProvider: SwapProvider,
) : SwapState
data class EmptyAmountState(val zeroAmountEquivalent: String) : SwapState
@ -102,6 +107,9 @@ data class TxFee(
val gasLimit: Int,
val feeFiatFormatted: String,
val feeCryptoFormatted: String,
val feeIncludeOtherNativeFee: BigDecimal,
val feeFiatFormattedWithNative: String,
val feeCryptoFormattedWithNative: String,
val decimals: Int,
val cryptoSymbol: String,
val feeType: FeeType,

View file

@ -7,6 +7,7 @@ import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.minimalAmount
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.parseBigDecimal
@ -229,7 +230,6 @@ internal class SwapInteractorImpl @Inject constructor(
permissionOptions.fromToken.network,
permissionOptions.txFee.gasLimit,
),
isSwap = false,
).getOrElse {
Timber.e(it, "Failed to create approveTransaction")
return SwapTransactionState.UnknownError
@ -325,8 +325,8 @@ internal class SwapInteractorImpl @Inject constructor(
val fromTokenAddress = getTokenAddress(fromToken.currency)
val isAllowedToSpend = quotes.fold(
ifRight = {
it.allowanceContract?.let {
ifRight = { quotes ->
quotes.allowanceContract?.let {
isAllowedToSpend(networkId, fromToken.currency, amount, it)
} ?: true
},
@ -351,7 +351,7 @@ internal class SwapInteractorImpl @Inject constructor(
)
} else {
provider to getQuotesState(
exchangeProviderType = provider.type,
provider = provider,
quoteDataModel = quotes,
amount = amount,
fromToken = fromToken,
@ -375,7 +375,6 @@ internal class SwapInteractorImpl @Inject constructor(
isBalanceWithoutFeeEnough: Boolean,
): Pair<SwapProvider, SwapState> {
return provider to loadCexQuoteData(
exchangeProviderType = ExchangeProviderType.CEX,
networkId = networkId,
amount = amount,
fromTokenStatus = fromToken,
@ -602,8 +601,8 @@ internal class SwapInteractorImpl @Inject constructor(
swapData = requireNotNull(swapData),
currencyToSendStatus = currencyToSend,
currencyToGetStatus = currencyToGet,
amountToSwap = amountToSwap,
fee = fee,
amountToSwap = amountToSwap,
userWalletId = requireNotNull(getSelectedWallet()).walletId,
)
}
@ -629,8 +628,8 @@ internal class SwapInteractorImpl @Inject constructor(
)
val fee = when (val txFee = state.txFee) {
TxFeeState.Empty -> BigDecimal.ZERO
is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue
is TxFeeState.SingleFeeState -> txFee.fee.feeValue
is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeIncludeOtherNativeFee
is TxFeeState.SingleFeeState -> txFee.fee.feeIncludeOtherNativeFee
}
val feeState = getFeeState(
fee = fee,
@ -663,8 +662,9 @@ internal class SwapInteractorImpl @Inject constructor(
val derivationPath = currencyToSendStatus.currency.network.derivationPath.value
val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX
val dataToSign = dexTransaction.txData
val amountToSend = createNativeAmountForDex(swapData.transaction.txValue, currencyToSendStatus.currency.network)
val txData = createTransactionUseCase(
amount = amount.value.convertToAmount(currencyToSendStatus.currency),
amount = amountToSend,
fee = getFeeForTransaction(
fee = fee,
blockchain = Blockchain.fromId(currencyToSendStatus.currency.network.id.value),
@ -675,7 +675,6 @@ internal class SwapInteractorImpl @Inject constructor(
network = currencyToSendStatus.currency.network,
txExtras = createDexTxExtras(dataToSign, currencyToSendStatus.currency.network, fee.gasLimit),
hash = dataToSign,
isSwap = true,
).getOrElse {
Timber.e(it, "Failed to create swap dex tx data")
return SwapTransactionState.UnknownError
@ -877,7 +876,7 @@ internal class SwapInteractorImpl @Inject constructor(
)
blockchain == Blockchain.Aptos -> {
val gasUnitPrice = fee.feeValue.divide(
BigDecimal(fee.gasLimit),
fee.gasLimit.toBigDecimal(),
Blockchain.Aptos.decimals(),
RoundingMode.HALF_UP,
)
@ -991,7 +990,6 @@ internal class SwapInteractorImpl @Inject constructor(
*/
@Suppress("LongParameterList")
private suspend fun loadCexQuoteData(
exchangeProviderType: ExchangeProviderType,
networkId: String,
amount: SwapAmount,
fromTokenStatus: CryptoCurrencyStatus,
@ -1042,7 +1040,7 @@ internal class SwapInteractorImpl @Inject constructor(
)
getQuotesState(
exchangeProviderType = exchangeProviderType,
provider = provider,
quoteDataModel = quotes,
amount = amount,
fromToken = fromTokenStatus,
@ -1059,7 +1057,7 @@ internal class SwapInteractorImpl @Inject constructor(
@Suppress("LongMethod")
private suspend fun getQuotesState(
exchangeProviderType: ExchangeProviderType,
provider: SwapProvider,
quoteDataModel: Either<DataError, QuoteModel>,
amount: SwapAmount,
fromToken: CryptoCurrencyStatus,
@ -1081,6 +1079,7 @@ internal class SwapInteractorImpl @Inject constructor(
toTokenAmount = quoteModel.toTokenAmount,
swapData = null,
txFeeState = txFee,
provider = provider,
).copy(
warnings = manageWarnings(
fromTokenStatus = fromToken,
@ -1090,7 +1089,7 @@ internal class SwapInteractorImpl @Inject constructor(
),
)
when (exchangeProviderType) {
when (provider.type) {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
val state = updatePermissionState(
networkId = networkId,
@ -1154,8 +1153,8 @@ internal class SwapInteractorImpl @Inject constructor(
): IncludeFeeInAmount {
val feeValue = when (txFee) {
TxFeeState.Empty -> BigDecimal.ZERO
is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue
is TxFeeState.SingleFeeState -> txFee.fee.feeValue
is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeIncludeOtherNativeFee
is TxFeeState.SingleFeeState -> txFee.fee.feeIncludeOtherNativeFee
}
val feePaidCurrency = getFeePaidCurrency(
userWalletId = requireNotNull(getSelectedWallet()).walletId,
@ -1261,35 +1260,43 @@ internal class SwapInteractorImpl @Inject constructor(
providerId = provider.providerId,
rateType = RateType.FLOAT,
toAddress = toToken.value.networkAddress?.defaultAddress?.value.orEmpty(),
refundAddress = fromToken.value.networkAddress?.defaultAddress?.value,
).fold(
ifRight = { swapData ->
val transaction = swapData.transaction as ExpressTransactionModel.DEX
val nativeCoinDecimals = Blockchain.fromNetworkId(networkId)?.decimals()
?: error("Blockchain not found")
val otherNativeFee = transaction.otherNativeFeeWei
?.movePointLeft(nativeCoinDecimals)
?: BigDecimal.ZERO
val userWallet = getSelectedWallet()
val cardId = userWallet?.scanResponse?.card?.cardId
val feeData = if (cardId != null && isDemoCardUseCase(cardId)) {
getDemoFees(fromToken.currency)
} else {
transactionManager.getFee(
val txFeeState = when (
val feeData = getFeeDataForDexSwap(
networkId = networkId,
amountToSend = amount.value,
currencyToSend = swapCurrencyConverter.convert(fromToken.currency),
destinationAddress = swapData.transaction.txTo,
increaseBy = INCREASE_GAS_LIMIT_BY,
data = (swapData.transaction as ExpressTransactionModel.DEX).txData,
derivationPath = fromToken.currency.network.derivationPath.value,
transaction = transaction,
fromToken = fromToken.currency,
cardId = userWallet?.scanResponse?.card?.cardId,
)
}
val txFeeState = when (feeData) {
is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(fromToken.currency)
is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(fromToken.currency)
) {
is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(fromToken.currency, otherNativeFee)
is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(fromToken.currency, otherNativeFee)
}
val feeByPriority = selectFeeByType(feeType = selectedFee, txFeeState = txFeeState)
val isBalanceIncludeFeeEnough = isBalanceEnough(fromToken, amount, feeByPriority)
val feeToCheckFunds = feeByPriority + (otherNativeFee ?: BigDecimal.ZERO)
val isBalanceIncludeFeeEnough = isBalanceEnough(fromToken, amount, feeToCheckFunds)
val feeState = getFeeState(
fee = feeByPriority,
fee = feeToCheckFunds,
spendAmount = amount,
networkId = networkId,
fromTokenStatus = fromToken,
)
val preparedSwapConfigState = PreparedSwapConfigState(
isAllowedToSpend = true,
isBalanceEnough = isBalanceIncludeFeeEnough,
feeState = feeState,
hasOutgoingTransaction = hasOutgoingTransaction(fromToken),
includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex
)
val swapState = updateBalances(
networkId = networkId,
fromTokenStatus = fromToken,
@ -1298,6 +1305,7 @@ internal class SwapInteractorImpl @Inject constructor(
toTokenAmount = swapData.toTokenAmount,
swapData = swapData,
txFeeState = txFeeState,
provider = provider,
)
swapState.copy(
permissionState = PermissionDataState.Empty,
@ -1305,17 +1313,9 @@ internal class SwapInteractorImpl @Inject constructor(
fromTokenStatus = fromToken,
amount = amount,
feeState = txFeeState,
minAdaValue = (feeData as? ProxyFees.SingleFee)?.let {
(it.singleFee as? ProxyFee.CardanoToken)?.minAdaValue
},
),
preparedSwapConfigState = PreparedSwapConfigState(
isAllowedToSpend = true,
isBalanceEnough = isBalanceIncludeFeeEnough,
feeState = feeState,
hasOutgoingTransaction = hasOutgoingTransaction(fromToken),
includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex
minAdaValue = null, // no ADA in DEX
),
preparedSwapConfigState = preparedSwapConfigState,
)
},
ifLeft = { error ->
@ -1335,8 +1335,46 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
private suspend fun getFeeDataForDexSwap(
networkId: String,
transaction: ExpressTransactionModel.DEX,
fromToken: CryptoCurrency,
cardId: String?,
): ProxyFees {
if (cardId != null && isDemoCardUseCase(cardId)) {
return getDemoFees(fromToken)
}
return try {
val nativeBalance = userWalletManager.getNativeTokenBalance(
networkId = networkId,
derivationPath = fromToken.network.derivationPath.value,
) ?: ProxyAmount.empty()
val amountToSend = createNativeAmountForDex(transaction.txValue, fromToken.network)
// transaction.txValue is always native coin
if (nativeBalance.value < amountToSend.value) {
error("It's impossible to calculate fee for nativeBalance.value < amountToSend.value")
}
transactionManager.getFee(
networkId = networkId,
amountToSend = amountToSend,
currencyToSend = swapCurrencyConverter.convert(fromToken),
destinationAddress = transaction.txTo,
increaseBy = INCREASE_GAS_LIMIT_BY,
data = transaction.txData,
derivationPath = fromToken.network.derivationPath.value,
)
} catch (e: IllegalStateException) {
transactionManager.getFeeForGas(
networkId = networkId,
gas = transaction.gas.multiply(INCREASE_GAS_LIMIT_BY.toBigInteger()).divide(100.toBigInteger()),
derivationPath = fromToken.network.derivationPath.value,
)
}
}
@Suppress("LongParameterList")
private suspend fun updateBalances(
provider: SwapProvider,
networkId: String,
fromTokenStatus: CryptoCurrencyStatus,
toTokenStatus: CryptoCurrencyStatus,
@ -1348,7 +1386,6 @@ internal class SwapInteractorImpl @Inject constructor(
val fromToken = fromTokenStatus.currency
val toToken = toTokenStatus.currency
val nativeToken = repository.getNativeTokenForNetwork(networkId)
val rates = getQuotes(fromToken.id, toToken.id, nativeToken.id)
return SwapState.QuotesLoadedState(
fromTokenInfo = TokenSwapInfo(
@ -1371,6 +1408,7 @@ internal class SwapInteractorImpl @Inject constructor(
),
swapDataModel = swapData,
txFee = txFeeState,
swapProvider = provider,
)
}
@ -1380,7 +1418,7 @@ internal class SwapInteractorImpl @Inject constructor(
): TxFeeState {
return txFeeResult?.fold(
ifLeft = { TxFeeState.Empty },
ifRight = { txFee -> txFee.toTxFeeState(fromToken.currency) },
ifRight = { txFee -> txFee.toTxFeeState(fromToken.currency, null) },
) ?: TxFeeState.Empty
}
@ -1440,7 +1478,7 @@ internal class SwapInteractorImpl @Inject constructor(
try {
transactionManager.getFee(
networkId = networkId,
amountToSend = BigDecimal.ZERO,
amountToSend = createNativeAmountForDex("0", fromToken.network),
currencyToSend = swapCurrencyConverter.convert(repository.getNativeTokenForNetwork(networkId)),
destinationAddress = fromToken.getContractAddress(),
increaseBy = INCREASE_GAS_LIMIT_BY,
@ -1488,11 +1526,16 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
private suspend fun ProxyFees.MultipleFees.proxyFeesToFeeState(fromToken: CryptoCurrency): TxFeeState {
private suspend fun ProxyFees.MultipleFees.proxyFeesToFeeState(
fromToken: CryptoCurrency,
otherNativeFee: BigDecimal? = null,
): TxFeeState {
val otherNativeFeeValue = otherNativeFee ?: BigDecimal.ZERO
val normalFeeValue = this.minFee.fee.value // in swap for normal use min fee
val normalFeeGas = this.minFee.gasLimit.toInt()
val priorityFeeValue = this.normalFee.fee.value // in swap for priority use normal fee
val priorityFeeGas = this.normalFee.gasLimit.toInt()
// region fees to use
val feesFiat = getFormattedFiatFees(fromToken, normalFeeValue, priorityFeeValue)
val normalFiatFee = requireNotNull(feesFiat.getOrNull(0)) { "feesFiat item 0 couldn't be null" }
val priorityFiatFee = requireNotNull(feesFiat.getOrNull(1)) { "feesFiat item 1 couldn't be null" }
@ -1504,12 +1547,35 @@ internal class SwapInteractorImpl @Inject constructor(
amount = priorityFeeValue,
decimals = normalFee.fee.decimals,
)
// endregion
// region fees include otherNativeFee
val feesFiatWithNative = getFormattedFiatFees(
fromToken = fromToken,
normalFeeValue + otherNativeFeeValue,
priorityFeeValue + otherNativeFeeValue,
)
val normalFiatFeeWithNative =
requireNotNull(feesFiatWithNative.getOrNull(0)) { "feesFiat item 0 couldn't be null" }
val priorityFiatFeeWithNative =
requireNotNull(feesFiatWithNative.getOrNull(1)) { "feesFiat item 1 couldn't be null" }
val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI(
amount = normalFeeValue + otherNativeFeeValue,
decimals = minFee.fee.decimals,
)
val priorityCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI(
amount = priorityFeeValue + otherNativeFeeValue,
decimals = normalFee.fee.decimals,
)
// endregion
return TxFeeState.MultipleFeeState(
normalFee = TxFee(
feeValue = normalFeeValue,
gasLimit = normalFeeGas,
feeFiatFormatted = normalFiatFee,
feeCryptoFormatted = normalCryptoFee,
feeIncludeOtherNativeFee = normalFeeValue + otherNativeFeeValue,
feeFiatFormattedWithNative = normalFiatFeeWithNative,
feeCryptoFormattedWithNative = normalCryptoFeeWithNative,
decimals = minFee.fee.decimals,
cryptoSymbol = minFee.fee.currencySymbol,
feeType = FeeType.NORMAL,
@ -1519,6 +1585,9 @@ internal class SwapInteractorImpl @Inject constructor(
gasLimit = priorityFeeGas,
feeFiatFormatted = priorityFiatFee,
feeCryptoFormatted = priorityCryptoFee,
feeIncludeOtherNativeFee = priorityFeeValue + otherNativeFeeValue,
feeFiatFormattedWithNative = priorityFiatFeeWithNative,
feeCryptoFormattedWithNative = priorityCryptoFeeWithNative,
decimals = normalFee.fee.decimals,
cryptoSymbol = normalFee.fee.currencySymbol,
feeType = FeeType.PRIORITY,
@ -1526,7 +1595,11 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
private suspend fun ProxyFees.SingleFee.proxyFeesToFeeState(fromToken: CryptoCurrency): TxFeeState {
private suspend fun ProxyFees.SingleFee.proxyFeesToFeeState(
fromToken: CryptoCurrency,
otherNativeFee: BigDecimal? = null,
): TxFeeState {
val otherNativeFeeValue = otherNativeFee ?: BigDecimal.ZERO
val normalFeeValue = this.singleFee.fee.value
val normalFeeGas = this.singleFee.gasLimit.toInt()
val feesFiat = getFormattedFiatFees(fromToken, normalFeeValue)
@ -1535,12 +1608,28 @@ internal class SwapInteractorImpl @Inject constructor(
amount = normalFeeValue,
decimals = singleFee.fee.decimals,
)
// region fees include otherNativeFee
val feesFiatWithNative = getFormattedFiatFees(
fromToken = fromToken,
normalFeeValue + otherNativeFeeValue,
normalFeeValue + otherNativeFeeValue,
)
val normalFiatFeeWithNative =
requireNotNull(feesFiatWithNative.getOrNull(0)) { "feesFiat item 0 couldn't be null" }
val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI(
amount = normalFeeValue + otherNativeFeeValue,
decimals = singleFee.fee.decimals,
)
// endregion
return TxFeeState.SingleFeeState(
fee = TxFee(
feeValue = normalFeeValue,
gasLimit = normalFeeGas,
feeFiatFormatted = normalFiatFee,
feeCryptoFormatted = normalCryptoFee,
feeIncludeOtherNativeFee = normalFeeValue + otherNativeFeeValue,
feeFiatFormattedWithNative = normalFiatFeeWithNative,
feeCryptoFormattedWithNative = normalCryptoFeeWithNative,
decimals = singleFee.fee.decimals,
cryptoSymbol = singleFee.fee.currencySymbol,
feeType = FeeType.NORMAL,
@ -1548,7 +1637,12 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
private suspend fun TransactionFee.toTxFeeState(fromToken: CryptoCurrency): TxFeeState {
@Suppress("LongMethod")
private suspend fun TransactionFee.toTxFeeState(
fromToken: CryptoCurrency,
otherNativeFee: BigDecimal?,
): TxFeeState {
val otherNativeFeeValue = otherNativeFee ?: BigDecimal.ZERO
return when (this) {
is TransactionFee.Choosable -> {
val normalFee = this.normal.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SEND)
@ -1566,12 +1660,31 @@ internal class SwapInteractorImpl @Inject constructor(
amount = feePriority,
decimals = priorityFee.amount.decimals,
)
// region otherNativeFee
val normalFeeWithOtherNative = feeNormal + otherNativeFeeValue
val priorityFeeWithOtherNative = feePriority + otherNativeFeeValue
val normalFiatValueWithNative = getFormattedFiatFees(fromToken, normalFeeWithOtherNative)[0]
val priorityFiatValueWithNative = getFormattedFiatFees(fromToken, priorityFeeWithOtherNative)[0]
val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI(
amount = normalFeeWithOtherNative,
decimals = normalFee.amount.decimals,
)
val priorityCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI(
amount = priorityFeeWithOtherNative,
decimals = priorityFee.amount.decimals,
)
// endregion
TxFeeState.MultipleFeeState(
normalFee = TxFee(
feeValue = feeNormal,
gasLimit = normalFee.getGasLimit(),
feeFiatFormatted = normalFiatValue,
feeCryptoFormatted = normalCryptoFee,
feeIncludeOtherNativeFee = normalFeeWithOtherNative,
feeFiatFormattedWithNative = normalFiatValueWithNative,
feeCryptoFormattedWithNative = normalCryptoFeeWithNative,
decimals = normalFee.amount.decimals,
cryptoSymbol = normalFee.amount.currencySymbol,
feeType = FeeType.NORMAL,
@ -1581,6 +1694,9 @@ internal class SwapInteractorImpl @Inject constructor(
gasLimit = priorityFee.getGasLimit(),
feeFiatFormatted = priorityFiatValue,
feeCryptoFormatted = priorityCryptoFee,
feeIncludeOtherNativeFee = priorityFeeWithOtherNative,
feeFiatFormattedWithNative = priorityFiatValueWithNative,
feeCryptoFormattedWithNative = priorityCryptoFeeWithNative,
decimals = priorityFee.amount.decimals,
cryptoSymbol = priorityFee.amount.currencySymbol,
feeType = FeeType.PRIORITY,
@ -1594,12 +1710,24 @@ internal class SwapInteractorImpl @Inject constructor(
amount = feeNormal,
decimals = this.normal.amount.decimals,
)
// region otherNativeFee
val normalFeeWithOtherNative = feeNormal + otherNativeFeeValue
val normalFiatValueWithNative = getFormattedFiatFees(fromToken, normalFeeWithOtherNative)[0]
val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI(
amount = normalFeeWithOtherNative,
decimals = this.normal.amount.decimals,
)
// endregion
TxFeeState.SingleFeeState(
fee = TxFee(
feeValue = this.normal.amount.value ?: BigDecimal.ZERO,
gasLimit = this.normal.getGasLimit(),
feeFiatFormatted = normalFiatValue,
feeCryptoFormatted = normalCryptoFee,
feeIncludeOtherNativeFee = normalFeeWithOtherNative,
feeFiatFormattedWithNative = normalFiatValueWithNative,
feeCryptoFormattedWithNative = normalCryptoFeeWithNative,
decimals = normal.amount.decimals,
cryptoSymbol = normal.amount.currencySymbol,
feeType = FeeType.NORMAL,
@ -1609,6 +1737,18 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
private fun createNativeAmountForDex(txValueAmount: String, network: Network): Amount {
val nativeDecimals = Blockchain.fromNetworkId(network.backendId)?.decimals()
?: error("Blockchain not found")
val decimalValue = txValueAmount.toBigDecimalOrNull()?.movePointLeft(nativeDecimals)
?: error("txValue parse error")
return Amount(
currencySymbol = network.currencySymbol,
value = decimalValue,
decimals = nativeDecimals,
)
}
/**
* Workaround to increase gas limit cause we calculate fee for random address
*/
@ -1669,7 +1809,7 @@ internal class SwapInteractorImpl @Inject constructor(
if (fromToken.currency is CryptoCurrency.Token) {
tokenBalance >= amount.value
} else {
tokenBalance > amount.value.plus(fee ?: BigDecimal.ZERO)
tokenBalance >= amount.value.plus(fee ?: BigDecimal.ZERO)
}
}
}
@ -1856,7 +1996,6 @@ internal class SwapInteractorImpl @Inject constructor(
normalFee = ProxyFee.Common(
gasLimit = 1.toBigInteger(),
fee = demoFee.copy(value = normalDemoFee),
),
priorityFee = ProxyFee.Common(
gasLimit = 1.toBigInteger(),

View file

@ -1,7 +1,6 @@
package com.tangem.feature.swap.models
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.core.ui.R
@ -75,18 +74,21 @@ data class SwapButton(
sealed interface TransactionCardType {
val headerResId: Int
val header: TextReference
val isError: Boolean
data class Inputtable(
val onAmountChanged: ((String) -> Unit),
val onFocusChanged: ((Boolean) -> Unit),
@StringRes override val headerResId: Int = R.string.swapping_from_title,
override val isError: Boolean,
override val header: TextReference = TextReference.Res(R.string.swapping_from_title),
) : TransactionCardType
data class ReadOnly(
val showWarning: Boolean = false,
val onWarningClick: (() -> Unit)? = null,
@StringRes override val headerResId: Int = R.string.swapping_to_title,
override val isError: Boolean = false,
override val header: TextReference = TextReference.Res(R.string.swapping_to_title),
) : TransactionCardType
}
@ -103,7 +105,7 @@ data class LegalState(
sealed interface SwapWarning {
data class PermissionNeeded(val notificationConfig: NotificationConfig) : SwapWarning
object InsufficientFunds : SwapWarning
data object InsufficientFunds : SwapWarning
data class NoAvailableTokensToSwap(val notificationConfig: NotificationConfig) : SwapWarning
data class GenericWarning(
val title: TextReference? = null,

View file

@ -54,7 +54,11 @@ internal class StateBuilder(
return SwapStateHolder(
blockchainId = networkInfo.blockchainId,
sendCardData = SwapCardState.SwapCardData(
type = TransactionCardType.Inputtable(actions.onAmountChanged, actions.onAmountSelected),
type = TransactionCardType.Inputtable(
onAmountChanged = actions.onAmountChanged,
onFocusChanged = actions.onAmountSelected,
isError = false,
),
amountEquivalent = null,
amountTextFieldValue = null,
token = null,
@ -155,9 +159,13 @@ internal class StateBuilder(
val canSelectReceiveToken = mainTokenId != toToken.id.value
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val sendInput = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable).copy(
isError = false,
header = TextReference.Res(R.string.swapping_from_title),
)
return uiStateHolder.copy(
sendCardData = SwapCardState.SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable),
type = sendInput,
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
amountEquivalent = null,
token = uiStateHolder.sendCardData.token,
@ -225,9 +233,19 @@ internal class StateBuilder(
val feeState = createFeeState(quoteModel.txFee, selectedFeeType)
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus
val isInsufficientFunds = isInsufficientFundsCondition(quoteModel)
val insufficientFundsHeader = if (isInsufficientFunds) {
TextReference.Res(R.string.swapping_insufficient_funds)
} else {
TextReference.Res(R.string.swapping_from_title)
}
val sendInput = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable).copy(
isError = isInsufficientFunds,
header = insufficientFundsHeader,
)
return uiStateHolder.copy(
sendCardData = SwapCardState.SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable),
type = sendInput,
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
amountEquivalent = getFormattedFiatAmount(quoteModel.fromTokenInfo.amountFiat),
token = fromCurrencyStatus,
@ -486,7 +504,7 @@ internal class StateBuilder(
) {
warnings.add(
SwapWarning.PermissionNeeded(
createPermissionNotificationConfig(providerName, fromToken.symbol),
createPermissionNotificationConfig(fromToken.symbol, providerName),
),
)
}
@ -504,8 +522,8 @@ internal class StateBuilder(
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(
fee.feeCryptoFormatted,
fee.feeFiatFormatted,
fee.feeCryptoFormattedWithNative,
fee.feeFiatFormattedWithNative,
),
),
)
@ -558,13 +576,16 @@ internal class StateBuilder(
warnings: MutableList<SwapWarning>,
) {
// check isBalanceEnough, but for dex includeFeeInAmount always Excluded
if (!quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
) {
if (isInsufficientFundsCondition(quoteModel)) {
warnings.add(SwapWarning.InsufficientFunds)
}
}
private fun isInsufficientFundsCondition(quoteModel: SwapState.QuotesLoadedState): Boolean {
return !quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
}
private fun getSwapButtonEnabled(quoteModel: SwapState.QuotesLoadedState): Boolean {
val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value
if (status is CryptoCurrencyStatus.NoAccount) {
@ -975,9 +996,9 @@ internal class StateBuilder(
return FeeItemState.Content(
feeType = feeType,
title = resourceReference(R.string.common_network_fee_title),
amountCrypto = fee.feeCryptoFormatted,
amountCrypto = fee.feeCryptoFormattedWithNative, // display fee with native as workaround for okx
symbolCrypto = fee.cryptoSymbol,
amountFiatFormatted = fee.feeFiatFormatted,
amountFiatFormatted = fee.feeFiatFormattedWithNative, // display fee with native as workaround for okx
isClickable = isClickable,
onClick = actions.onClickFee,
)
@ -1017,8 +1038,7 @@ internal class StateBuilder(
val fromFiatAmount = getFormattedFiatAmount(fromCryptoCurrency.value.fiatRate?.multiply(fromAmount))
val toFiatAmount = getFormattedFiatAmount(toCryptoCurrency.value.fiatRate?.multiply(toAmount))
val shouldShowStatus = providerState.type == ExchangeProviderType.CEX.providerName ||
providerState.type == ExchangeProviderType.DEX_BRIDGE.providerName
val shouldShowStatus = providerState.type == ExchangeProviderType.CEX.providerName
return uiState.copy(
successState = SwapSuccessStateHolder(
timestamp = swapTransactionState.timestamp,
@ -1028,7 +1048,7 @@ internal class StateBuilder(
showStatusButton = shouldShowStatus,
providerIcon = providerState.iconUrl,
rate = providerState.subtitle,
fee = stringReference("${fee.feeCryptoFormatted} (${fee.feeFiatFormatted})"),
fee = stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})"),
fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()),
toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()),
fromTokenFiatAmount = stringReference(fromFiatAmount),
@ -1375,18 +1395,18 @@ internal class StateBuilder(
FeeItemState.Content(
feeType = this.normalFee.feeType,
title = resourceReference(R.string.common_network_fee_title),
amountCrypto = this.normalFee.feeCryptoFormatted,
amountCrypto = this.normalFee.feeCryptoFormattedWithNative,
symbolCrypto = this.normalFee.cryptoSymbol,
amountFiatFormatted = this.normalFee.feeFiatFormatted,
amountFiatFormatted = this.normalFee.feeFiatFormattedWithNative,
isClickable = true,
onClick = {},
),
FeeItemState.Content(
feeType = this.priorityFee.feeType,
title = resourceReference(R.string.common_network_fee_title),
amountCrypto = this.priorityFee.feeCryptoFormatted,
amountCrypto = this.priorityFee.feeCryptoFormattedWithNative,
symbolCrypto = this.priorityFee.cryptoSymbol,
amountFiatFormatted = this.priorityFee.feeFiatFormatted,
amountFiatFormatted = this.priorityFee.feeFiatFormattedWithNative,
isClickable = true,
onClick = {},
),
@ -1419,7 +1439,7 @@ internal class StateBuilder(
}
// region warnings
private fun createPermissionNotificationConfig(providerName: String, fromTokenSymbol: String): NotificationConfig {
private fun createPermissionNotificationConfig(fromTokenSymbol: String, providerName: String): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.express_provider_permission_needed),
subtitle = resourceReference(
@ -1621,7 +1641,7 @@ internal class StateBuilder(
id = this.providerId,
name = this.name,
iconUrl = this.imageLarge,
type = this.type.toString(),
type = this.type.providerName,
selectionType = selectionType,
alertText = alertText,
onProviderClick = onProviderClick,

View file

@ -435,7 +435,7 @@ private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> U
// region preview
private val sendCard = SwapCardState.SwapCardData(
type = TransactionCardType.Inputtable({}, {}),
type = TransactionCardType.Inputtable({}, {}, false),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = "1 000 000",
tokenIconUrl = "",

View file

@ -37,6 +37,7 @@ import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.ImageBackgroundContrastChecker
@ -185,10 +186,14 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val title = type.headerResId
val titleColor = if (type.isError) {
TangemTheme.colors.text.warning
} else {
TangemTheme.colors.text.tertiary
}
Text(
text = stringResource(id = title),
color = TangemTheme.colors.text.tertiary,
text = type.header.resolveReference(),
color = titleColor,
maxLines = 1,
style = MaterialTheme.typography.subtitle2,
modifier = Modifier
@ -546,7 +551,7 @@ private fun Preview_TransactionCardWithoutPriceImpact_InDarkTheme() {
@Composable
private fun TransactionCardPreview() {
TransactionCard(
type = TransactionCardType.Inputtable({}, {}),
type = TransactionCardType.Inputtable({}, {}, false),
amountEquivalent = "1 000 000",
tokenIconUrl = "",
tokenCurrency = "DAI",

View file

@ -29,6 +29,7 @@ internal data class SwapTransactionsState(
val fromFiatAmount: String,
val fromCurrencyIcon: CurrencyIconState,
val showProviderLink: Boolean,
val isRefundTerminalStatus: Boolean = true,
val onClick: () -> Unit,
val onGoToProviderClick: (String) -> Unit,
)

View file

@ -1,38 +1,92 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.components
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.notifications.CurrencyNotificationConfig
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.features.tokendetails.impl.R
@Immutable
internal sealed class ExchangeStatusNotifications(val config: NotificationConfig) {
internal sealed interface ExchangeStatusNotifications {
data class NeedVerification(
val onGoToProviderClick: () -> Unit,
) : ExchangeStatusNotifications(
sealed class CommonNotification(val config: NotificationConfig) : ExchangeStatusNotifications
data class NeedVerification(val onGoToProviderClick: () -> Unit) : CommonNotification(
config = NotificationConfig(
title = TextReference.Res(R.string.express_exchange_notification_verification_title),
subtitle = TextReference.Res(R.string.express_exchange_notification_verification_text),
title = resourceReference(R.string.express_exchange_notification_verification_title),
subtitle = resourceReference(R.string.express_exchange_notification_verification_text),
iconResId = R.drawable.ic_alert_triangle_20,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = TextReference.Res(R.string.common_go_to_provider),
text = resourceReference(R.string.common_go_to_provider),
onClick = onGoToProviderClick,
),
),
)
data class Failed(
val onGoToProviderClick: () -> Unit,
) : ExchangeStatusNotifications(
data class Failed(val onGoToProviderClick: () -> Unit) : CommonNotification(
config = NotificationConfig(
title = TextReference.Res(R.string.express_exchange_notification_failed_title),
subtitle = TextReference.Res(R.string.express_exchange_notification_failed_text),
title = resourceReference(R.string.express_exchange_notification_failed_title),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
iconResId = R.drawable.ic_alert_circle_24,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = TextReference.Res(R.string.common_go_to_provider),
text = resourceReference(R.string.common_go_to_provider),
onClick = onGoToProviderClick,
),
),
)
data class TokenRefunded(
val cryptoCurrency: CryptoCurrency,
val onReadMoreClick: () -> Unit,
val onGoToTokenClick: () -> Unit,
) : ExchangeStatusNotifications {
val config = CurrencyNotificationConfig(
title = resourceReference(
id = R.string.express_exchange_notification_refund_title,
formatArgs = wrappedList(cryptoCurrency.symbol, cryptoCurrency.network.name),
),
subtitle = CurrencyNotificationConfig.AnnotatedSubtitle(
valueProvider = {
val linkText = stringResource(R.string.common_read_more)
val fullString = stringResource(
R.string.express_exchange_notification_refund_text,
cryptoCurrency.symbol,
linkText,
)
val linkTextPosition = fullString.length - linkText.length
buildAnnotatedString {
withStyle(SpanStyle(TangemTheme.colors.text.tertiary)) {
append(fullString.substring(0, linkTextPosition))
}
withStyle(SpanStyle(TangemTheme.colors.text.accent)) {
append(fullString.substring(linkTextPosition, fullString.length))
}
}
},
onClick = { value, position ->
val readMoreStyle = requireNotNull(value.spanStyles.getOrNull(1))
if (position in readMoreStyle.start..readMoreStyle.end) {
onReadMoreClick()
}
},
),
tokenIconState = CryptoCurrencyToIconStateConverter().convert(cryptoCurrency),
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.common_go_to_token),
onClick = onGoToTokenClick,
),
)
}
}

View file

@ -25,6 +25,7 @@ import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
import java.util.Locale
internal class TokenDetailsSwapTransactionsStateConverter(
private val clickIntents: TokenDetailsClickIntents,
@ -62,7 +63,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
}?.fiatRate?.multiply(fromAmount)
val timestamp = transaction.timestamp
val notifications =
getNotification(transaction.status?.status, transaction.status?.txExternalUrl)
getNotification(transaction.status?.status, transaction.status?.txExternalUrl, null)
val showProviderLink = getShowProviderLink(notifications, transaction.status)
result.add(
SwapTransactionsState(
@ -77,10 +78,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
statuses = getStatuses(transaction.status?.status),
hasFailed = transaction.status?.status == ExchangeStatus.Failed,
activeStatus = transaction.status?.status,
notification = getNotification(
transaction.status?.status,
transaction.status?.txExternalUrl,
),
notification = notifications,
toCryptoCurrency = toCryptoCurrency,
toCryptoAmount = BigDecimalFormatter.formatCryptoAmount(
cryptoAmount = toAmount,
@ -110,10 +108,15 @@ internal class TokenDetailsSwapTransactionsStateConverter(
return result.toPersistentList()
}
fun updateTxStatus(tx: SwapTransactionsState, statusModel: ExchangeStatusModel?): SwapTransactionsState {
fun updateTxStatus(
tx: SwapTransactionsState,
statusModel: ExchangeStatusModel?,
refundToken: CryptoCurrency?,
isRefundTerminalStatus: Boolean,
): SwapTransactionsState {
if (statusModel == null || tx.activeStatus == statusModel.status) return tx
val hasFailed = tx.hasFailed || statusModel.status == ExchangeStatus.Failed
val notifications = getNotification(statusModel.status, statusModel.txExternalUrl)
val notifications = getNotification(statusModel.status, statusModel.txExternalUrl, refundToken)
val showProviderLink = getShowProviderLink(notifications, statusModel)
return tx.copy(
activeStatus = statusModel.status,
@ -122,6 +125,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
statuses = getStatuses(statusModel.status, hasFailed),
txUrl = statusModel.txExternalUrl,
showProviderLink = showProviderLink,
isRefundTerminalStatus = isRefundTerminalStatus,
)
}
@ -133,10 +137,14 @@ internal class TokenDetailsSwapTransactionsStateConverter(
)
}
private fun getNotification(status: ExchangeStatus?, txUrl: String?): ExchangeStatusNotifications? {
if (txUrl == null) return null
private fun getNotification(
status: ExchangeStatus?,
txUrl: String?,
refundToken: CryptoCurrency?,
): ExchangeStatusNotifications? {
return when (status) {
ExchangeStatus.Failed -> {
if (txUrl == null) return null
ExchangeStatusNotifications.Failed {
analyticsEventsHandlerProvider().send(
TokenExchangeAnalyticsEvent.GoToProviderFail(cryptoCurrency.symbol),
@ -145,6 +153,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
}
}
ExchangeStatus.Verifying -> {
if (txUrl == null) return null
ExchangeStatusNotifications.NeedVerification {
analyticsEventsHandlerProvider().send(
TokenExchangeAnalyticsEvent.GoToProviderKYC(cryptoCurrency.symbol),
@ -152,6 +161,17 @@ internal class TokenDetailsSwapTransactionsStateConverter(
clickIntents.onGoToProviderClick(txUrl)
}
}
ExchangeStatus.Refunded -> {
if (refundToken == null) {
null
} else {
ExchangeStatusNotifications.TokenRefunded(
cryptoCurrency = refundToken,
onReadMoreClick = { clickIntents.onOpenUrlClick(url = getAboutCrossChainBridgesLink()) },
onGoToTokenClick = { clickIntents.onGoToRefundedTokenClick(refundToken) },
)
}
}
else -> null
}
}
@ -287,7 +307,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
status = ExchangeStatus.Refunded,
text = TextReference.Res(R.string.express_exchange_status_refunded),
isActive = false,
isDone = isRefunded,
isDone = false,
)
else -> ExchangeStatusState(
status = ExchangeStatus.Sending,
@ -300,4 +320,12 @@ internal class TokenDetailsSwapTransactionsStateConverter(
isDone = isSendingDone,
)
}
private fun getAboutCrossChainBridgesLink(): String {
return if (Locale.getDefault().country == "RU") {
"https://tangem.com/ru/blog/post/an-overview-of-cross-chain-bridges/"
} else {
"https://tangem.com/en/blog/post/an-overview-of-cross-chain-bridges/"
}
}
}

View file

@ -115,6 +115,11 @@ private fun ExchangeStatusStep(
color = TangemTheme.colors.icon.warning,
isDone = it.isDone,
)
it.status == ExchangeStatus.Refunded -> ExchangeStep(
iconRes = R.drawable.ic_close_24,
color = TangemTheme.colors.icon.warning,
isDone = it.isDone,
)
it.status == ExchangeStatus.Verifying -> ExchangeStep(
iconRes = R.drawable.ic_exclamation_24,
color = TangemTheme.colors.icon.attention,
@ -141,6 +146,7 @@ private fun ExchangeStatusStep(
private fun ExchangeStatusStepText(stepStatus: ExchangeStatusState) {
val textColor = when {
stepStatus.status == ExchangeStatus.Cancelled -> TangemTheme.colors.icon.warning
stepStatus.status == ExchangeStatus.Refunded -> TangemTheme.colors.icon.warning
stepStatus.status == ExchangeStatus.Failed && !stepStatus.isDone -> TangemTheme.colors.icon.warning
stepStatus.status == ExchangeStatus.Verifying && !stepStatus.isDone -> TangemTheme.colors.icon.attention
stepStatus.isDone -> TangemTheme.colors.text.primary1

View file

@ -17,11 +17,12 @@ import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.CurrencyNotification
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotifications
@Composable
internal fun ExchangeStatusBottomSheet(config: TangemBottomSheetConfig) {
@ -29,16 +30,13 @@ internal fun ExchangeStatusBottomSheet(config: TangemBottomSheetConfig) {
config = config,
containerColor = TangemTheme.colors.background.tertiary,
) { content: ExchangeStatusBottomSheetConfig ->
ExchangeStatusBottomSheetContent(content = content)
ExchangeStatusBottomSheetContent(config = content.value)
}
}
@Composable
private fun ExchangeStatusBottomSheetContent(content: ExchangeStatusBottomSheetConfig) {
val config = content.value
Column(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
) {
private fun ExchangeStatusBottomSheetContent(config: SwapTransactionsState) {
Column(modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16)) {
SpacerH10()
Text(
text = stringResource(id = R.string.express_exchange_status_title),
@ -80,25 +78,39 @@ private fun ExchangeStatusBottomSheetContent(content: ExchangeStatusBottomSheetC
showLink = config.showProviderLink,
onClick = { config.onGoToProviderClick(config.txUrl.orEmpty()) },
)
AnimatedContent(
targetState = config.notification,
label = "Exchange Status Notification Change",
) {
it?.let {
val tint = when (config.activeStatus) {
ExchangeStatus.Verifying -> TangemTheme.colors.icon.attention
ExchangeStatus.Failed -> TangemTheme.colors.icon.warning
else -> null
}
Notification(
config = it.config,
iconTint = tint,
if (config.notification != null) {
Notification(state = config.notification, activeStatus = config.activeStatus)
}
SpacerH24()
}
}
@Composable
private fun Notification(state: ExchangeStatusNotifications, activeStatus: ExchangeStatus?) {
AnimatedContent(
targetState = state,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing12),
label = "Exchange Status Notification Change",
) { notification ->
when (notification) {
is ExchangeStatusNotifications.CommonNotification -> {
com.tangem.core.ui.components.notifications.Notification(
config = notification.config,
iconTint = when (activeStatus) {
ExchangeStatus.Verifying -> TangemTheme.colors.icon.attention
ExchangeStatus.Failed -> TangemTheme.colors.icon.warning
else -> null
},
containerColor = TangemTheme.colors.background.action,
)
}
is ExchangeStatusNotifications.TokenRefunded -> {
CurrencyNotification(
config = notification.config,
containerColor = TangemTheme.colors.background.action,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing12),
)
}
}
SpacerH24()
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.datasource.local.swaptx.ExchangeAnalyticsStatus
import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.domain.tokens.models.analytics.TokenExchangeAnalyticsEvent
@ -12,6 +13,7 @@ import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.swap.domain.SwapTransactionRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel
import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel
@ -38,6 +40,7 @@ internal class ExchangeStatusFactory(
private val swapRepository: SwapRepository,
private val quotesRepository: QuotesRepository,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val swapTransactionStatusStore: SwapTransactionStatusStore,
private val dispatchers: CoroutineDispatcherProvider,
private val clickIntents: TokenDetailsClickIntents,
@ -81,12 +84,13 @@ internal class ExchangeStatusFactory(
}
}
suspend fun removeTransactionOnBottomSheetClosed(): TokenDetailsState {
suspend fun removeTransactionOnBottomSheetClosed(isForceTerminal: Boolean = false): TokenDetailsState {
val state = currentStateProvider()
val bottomSheetConfig = state.bottomSheetConfig?.content as? ExchangeStatusBottomSheetConfig ?: return state
val selectedTx = bottomSheetConfig.value
return if (selectedTx.activeStatus.isTerminal()) {
val shouldTerminate = selectedTx.activeStatus.isTerminal(selectedTx.isRefundTerminalStatus) || isForceTerminal
return if (shouldTerminate) {
swapTransactionRepository.removeTransaction(
userWalletId = userWalletId,
fromCryptoCurrency = selectedTx.fromCryptoCurrency,
@ -105,12 +109,20 @@ internal class ExchangeStatusFactory(
suspend fun updateSwapTxStatuses(swapTxList: PersistentList<SwapTransactionsState>) = withContext(dispatchers.io) {
swapTxList.map { tx ->
async {
if (tx.activeStatus.isTerminal()) {
val statusModel = getExchangeStatus(tx.txId)
val isRefundTerminalStatus = statusModel?.refundNetwork == null &&
statusModel?.refundContractAddress == null &&
tx.provider.type != ExchangeProviderType.DEX_BRIDGE
if (tx.activeStatus.isTerminal(isRefundTerminalStatus)) {
tx
} else {
val statusModel = getExchangeStatus(tx.txId)
swapTransactionsStateConverter
.updateTxStatus(tx, statusModel)
val addedRefundToken = addRefundCurrencyIfNeeded(statusModel, tx.provider.type)
swapTransactionsStateConverter.updateTxStatus(
tx = tx,
statusModel = statusModel,
refundToken = addedRefundToken,
isRefundTerminalStatus = isRefundTerminalStatus,
)
}
}
}
@ -143,6 +155,27 @@ internal class ExchangeStatusFactory(
}
}
/**
* For now do it only for dex-bridge provider
*/
private suspend fun addRefundCurrencyIfNeeded(
status: ExchangeStatusModel?,
type: ExchangeProviderType,
): CryptoCurrency? {
status ?: return null
if (type != ExchangeProviderType.DEX_BRIDGE) return null
val refundNetwork = status.refundNetwork
val refundContractAddress = status.refundContractAddress
if (refundNetwork != null && refundContractAddress != null) {
return addCryptoCurrenciesUseCase(
userWalletId = userWalletId,
contractAddress = refundContractAddress,
networkId = refundNetwork,
).getOrNull()
}
return null
}
private fun getExchangeStatusState(
savedTransactions: List<SavedSwapTransactionListModel>?,
quotes: Set<Quote>,
@ -157,23 +190,31 @@ internal class ExchangeStatusFactory(
)
}
private fun ExchangeStatus?.isTerminal() =
this == ExchangeStatus.Refunded || this == ExchangeStatus.Finished || this == ExchangeStatus.Cancelled
private fun ExchangeStatus?.isTerminal(isRefundTerminal: Boolean): Boolean {
val needTerminalRefund = this == ExchangeStatus.Refunded && isRefundTerminal
return needTerminalRefund ||
this == ExchangeStatus.Finished ||
this == ExchangeStatus.Cancelled ||
this == ExchangeStatus.TxFailed ||
this == ExchangeStatus.Unknown
}
private fun toAnalyticStatus(status: ExchangeStatus?): ExchangeAnalyticsStatus? {
return when (status) {
ExchangeStatus.New,
ExchangeStatus.Waiting,
ExchangeStatus.WaitingTxHash,
ExchangeStatus.Sending,
ExchangeStatus.Confirming,
ExchangeStatus.Exchanging,
-> ExchangeAnalyticsStatus.InProgress
ExchangeStatus.WaitingTxHash -> ExchangeAnalyticsStatus.WaitingTxHash
ExchangeStatus.Verifying -> ExchangeAnalyticsStatus.KYC
ExchangeStatus.Failed -> ExchangeAnalyticsStatus.Fail
ExchangeStatus.TxFailed -> ExchangeAnalyticsStatus.FailTx
ExchangeStatus.Finished -> ExchangeAnalyticsStatus.Done
ExchangeStatus.Refunded -> ExchangeAnalyticsStatus.Refunded
ExchangeStatus.Cancelled -> ExchangeAnalyticsStatus.Cancelled
ExchangeStatus.Unknown -> ExchangeAnalyticsStatus.Unknown
else -> null
}
}

View file

@ -62,4 +62,8 @@ interface TokenDetailsClickIntents {
fun onStakeBannerClick()
fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig)
fun onGoToRefundedTokenClick(cryptoCurrency: CryptoCurrency)
fun onOpenUrlClick(url: String)
}

View file

@ -102,6 +102,7 @@ internal class TokenDetailsViewModel @Inject constructor(
private val getCurrencyWarningsUseCase: GetCurrencyWarningsUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val shouldShowSwapPromoTokenUseCase: ShouldShowSwapPromoTokenUseCase,
private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase,
private val getExtendedPublicKeyForCurrencyUseCase: GetExtendedPublicKeyForCurrencyUseCase,
@ -169,6 +170,7 @@ internal class TokenDetailsViewModel @Inject constructor(
swapRepository = swapRepository,
quotesRepository = quotesRepository,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
addCryptoCurrenciesUseCase = addCryptoCurrenciesUseCase,
swapTransactionStatusStore = swapTransactionStatusStore,
dispatchers = dispatchers,
clickIntents = this,
@ -728,7 +730,8 @@ internal class TokenDetailsViewModel @Inject constructor(
}
override fun onDismissBottomSheet() {
if (internalUiState.value.bottomSheetConfig?.content is ExchangeStatusBottomSheetConfig) {
val bsContent = internalUiState.value.bottomSheetConfig?.content
if (bsContent is ExchangeStatusBottomSheetConfig) {
viewModelScope.launch(dispatchers.main) {
internalUiState.value = exchangeStatusFactory.removeTransactionOnBottomSheetClosed()
}
@ -750,6 +753,20 @@ internal class TokenDetailsViewModel @Inject constructor(
router.openUrl(url)
}
override fun onGoToRefundedTokenClick(cryptoCurrency: CryptoCurrency) {
if (internalUiState.value.bottomSheetConfig?.content is ExchangeStatusBottomSheetConfig) {
viewModelScope.launch {
internalUiState.value = exchangeStatusFactory.removeTransactionOnBottomSheetClosed(true)
}
}
internalUiState.value = stateFactory.getStateWithClosedBottomSheet()
router.openTokenDetails(userWalletId, cryptoCurrency)
}
override fun onOpenUrlClick(url: String) {
router.openUrl(url)
}
override fun onSwapPromoDismiss() {
viewModelScope.launch(dispatchers.main) {
shouldShowSwapPromoTokenUseCase.neverToShow()

View file

@ -88,7 +88,7 @@ markdown = "0.7.2"
# endregion Other libraries
# region Tangem
tangemBlockchainSdk = "release-app_5.13-714"
tangemBlockchainSdk = "release-app_5.13-718"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "release-app_5.13-376"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^

View file

@ -1,7 +1,8 @@
package com.tangem.lib.crypto
import com.tangem.blockchain.common.Amount
import com.tangem.lib.crypto.models.*
import java.math.BigDecimal
import java.math.BigInteger
interface TransactionManager {
@ -21,7 +22,7 @@ interface TransactionManager {
@Throws(IllegalStateException::class)
suspend fun getFee(
networkId: String,
amountToSend: BigDecimal,
amountToSend: Amount,
currencyToSend: Currency,
destinationAddress: String,
increaseBy: Int?,
@ -29,6 +30,9 @@ interface TransactionManager {
derivationPath: String?,
): ProxyFees
@Throws(IllegalStateException::class)
suspend fun getFeeForGas(networkId: String, gas: BigInteger, derivationPath: String?): ProxyFees
@Throws(IllegalStateException::class)
suspend fun updateWalletManager(networkId: String, derivationPath: String?)

View file

@ -5,7 +5,7 @@ import java.math.BigDecimal
import java.math.BigInteger
internal fun BigInteger.toBigDecimal(decimals: Int): BigDecimal {
return BigDecimal(this).movePointLeft(decimals)
return this.toBigDecimal().movePointLeft(decimals)
}
internal fun BigInteger.toInstant(): Instant {