Updated on 2026-08-14

This commit is contained in:
Tangem 2024-08-02 15:19:14 +02:00
commit d90745ed30
45 changed files with 2366 additions and 1732 deletions

View file

@ -177,9 +177,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

@ -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
@ -63,8 +65,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

@ -161,7 +161,7 @@ class TransactionManagerImpl(
@Throws(IllegalStateException::class)
override suspend fun getFee(
networkId: String,
amountToSend: BigDecimal,
amountToSend: Amount,
currencyToSend: Currency,
destinationAddress: String,
increaseBy: Int?,
@ -174,7 +174,7 @@ class TransactionManagerImpl(
if (walletManager is EthereumOptimisticRollupWalletManager) {
return getFeeForOptimismBlockchain(
walletManager = walletManager,
amount = createAmount(amountToSend, currencyToSend, blockchain),
amount = amountToSend,
destinationAddress = destinationAddress,
data = data,
)
@ -183,7 +183,6 @@ class TransactionManagerImpl(
walletManager = walletManager,
blockchain = blockchain,
amountToSend = amountToSend,
currency = currencyToSend,
destinationAddress = destinationAddress,
data = data,
increaseBy = increaseBy,
@ -192,8 +191,6 @@ class TransactionManagerImpl(
return getFeeForBlockchain(
walletManager = walletManager,
amountToSend = amountToSend,
currency = currencyToSend,
blockchain = blockchain,
destinationAddress = destinationAddress,
)
}
@ -209,13 +206,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) {
@ -268,17 +263,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)
@ -332,23 +324,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,
)
@ -363,6 +352,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 fun handleSendResult(result: Result<TransactionSendResult>): SendTxResult {
when (result) {
is Result.Success -> {

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"),

File diff suppressed because it is too large Load diff

View file

@ -283,7 +283,7 @@
<string name="swapping_permission_buttons_approve">允許</string>
<string name="swapping_permission_header">賦予權限</string>
<string name="swapping_permission_rows_amount">數量 %s</string>
<string name="swapping_permission_subheader">要繼續,您需要允許 1inch 智能合約使用您的 %s</string>
<string name="swapping_permission_subheader">要繼續,您需要允許 %1$s 智能合約使用您的 %2$s</string>
<string name="swapping_success_view_title">進行中</string>
<string name="swapping_swap_action">交易</string>
<string name="swapping_token_list_title">選擇代幣</string>

File diff suppressed because it is too large Load diff

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.tokenicon.TokenIcon
import com.tangem.core.ui.components.currency.tokenicon.TokenIconState
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: TokenIconState,
title: TextReference,
subtitle: CurrencyNotificationConfig.AnnotatedSubtitle,
) {
Row {
TokenIcon(
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 = TokenIconState.TokenIcon(
url = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/usd-coin.png",
networkBadgeIconResId = 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.tokenicon.TokenIconState
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: TokenIconState,
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

@ -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,
@ -413,12 +414,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

@ -19,7 +19,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
internal class DefaultTransactionRepository(
private val walletManagersFacade: WalletManagersFacade,
@ -34,7 +33,6 @@ internal class DefaultTransactionRepository(
destination: String,
userWalletId: UserWalletId,
network: Network,
isSwap: Boolean,
txExtras: TransactionExtras?,
hash: String?,
): TransactionData? = withContext(coroutineDispatcherProvider.io) {
@ -51,7 +49,6 @@ internal class DefaultTransactionRepository(
memo = memo,
destination = destination,
network = network,
isSwap = isSwap,
txExtras = txExtras,
hash = hash,
)
@ -84,7 +81,6 @@ internal class DefaultTransactionRepository(
memo = memo,
destination = destination,
network = network,
isSwap = isSwap,
txExtras = txExtras,
hash = hash,
)
@ -118,23 +114,15 @@ internal class DefaultTransactionRepository(
memo: String?,
destination: String,
network: Network,
isSwap: Boolean,
txExtras: TransactionExtras?,
hash: String?,
): TransactionData {
// 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,
)
@ -163,19 +151,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 {
/**
@ -210,4 +211,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

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

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

@ -4,8 +4,11 @@ import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.*
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.common.extensions.hexToBytes
import com.tangem.core.ui.utils.BigDecimalFormatter
@ -15,10 +18,8 @@ import com.tangem.domain.appcurrency.extenstions.unwrap
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.*
import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
@ -316,8 +317,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
},
@ -342,7 +343,7 @@ internal class SwapInteractorImpl @Inject constructor(
)
} else {
provider to getQuotesState(
exchangeProviderType = provider.type,
provider = provider,
quoteDataModel = quotes,
amount = amount,
fromToken = fromToken,
@ -366,7 +367,6 @@ internal class SwapInteractorImpl @Inject constructor(
isBalanceWithoutFeeEnough: Boolean,
): Pair<SwapProvider, SwapState> {
return provider to loadCexQuoteData(
exchangeProviderType = ExchangeProviderType.CEX,
networkId = networkId,
amount = amount,
fromTokenStatus = fromToken,
@ -595,8 +595,8 @@ internal class SwapInteractorImpl @Inject constructor(
swapData = requireNotNull(swapData),
currencyToSendStatus = currencyToSend,
currencyToGetStatus = currencyToGet,
amountToSwap = amountToSwap,
fee = fee,
amountToSwap = amountToSwap,
userWalletId = requireNotNull(getSelectedWallet()).walletId,
)
}
@ -622,8 +622,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,
@ -656,8 +656,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),
@ -668,7 +669,6 @@ internal class SwapInteractorImpl @Inject constructor(
network = currencyToSendStatus.currency.network,
txExtras = createDexTxExtras(fee.gasLimit, dataToSign),
hash = dataToSign,
isSwap = true,
).getOrElse {
Timber.e(it)
return SwapTransactionState.UnknownError
@ -870,7 +870,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,
)
@ -984,7 +984,6 @@ internal class SwapInteractorImpl @Inject constructor(
*/
@Suppress("LongParameterList")
private suspend fun loadCexQuoteData(
exchangeProviderType: ExchangeProviderType,
networkId: String,
amount: SwapAmount,
fromTokenStatus: CryptoCurrencyStatus,
@ -1035,7 +1034,7 @@ internal class SwapInteractorImpl @Inject constructor(
)
getQuotesState(
exchangeProviderType = exchangeProviderType,
provider = provider,
quoteDataModel = quotes,
amount = amount,
fromToken = fromTokenStatus,
@ -1052,7 +1051,7 @@ internal class SwapInteractorImpl @Inject constructor(
@Suppress("LongMethod")
private suspend fun getQuotesState(
exchangeProviderType: ExchangeProviderType,
provider: SwapProvider,
quoteDataModel: Either<DataError, QuoteModel>,
amount: SwapAmount,
fromToken: CryptoCurrencyStatus,
@ -1074,6 +1073,7 @@ internal class SwapInteractorImpl @Inject constructor(
toTokenAmount = quoteModel.toTokenAmount,
swapData = null,
txFeeState = txFee,
provider = provider,
).copy(
warnings = manageWarnings(
fromTokenStatus = fromToken,
@ -1083,7 +1083,7 @@ internal class SwapInteractorImpl @Inject constructor(
),
)
when (exchangeProviderType) {
when (provider.type) {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
val state = updatePermissionState(
networkId = networkId,
@ -1147,8 +1147,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,
@ -1254,29 +1254,35 @@ 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 feeData = transactionManager.getFee(
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,
)
val txFeeState = when (feeData) {
is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(fromToken.currency)
is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(fromToken.currency)
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 txFeeState = when (val feeData = getFeeDataForDexSwap(networkId, transaction, 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,
@ -1285,6 +1291,7 @@ internal class SwapInteractorImpl @Inject constructor(
toTokenAmount = swapData.toTokenAmount,
swapData = swapData,
txFeeState = txFeeState,
provider = provider,
)
swapState.copy(
permissionState = PermissionDataState.Empty,
@ -1292,17 +1299,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 ->
@ -1322,8 +1321,42 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
private suspend fun getFeeDataForDexSwap(
networkId: String,
transaction: ExpressTransactionModel.DEX,
fromToken: CryptoCurrency,
): ProxyFees {
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,
@ -1335,7 +1368,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(
@ -1358,6 +1390,7 @@ internal class SwapInteractorImpl @Inject constructor(
),
swapDataModel = swapData,
txFee = txFeeState,
swapProvider = provider,
)
}
@ -1367,7 +1400,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
}
@ -1427,7 +1460,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,
@ -1475,11 +1508,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" }
@ -1491,12 +1529,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,
@ -1506,6 +1567,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,
@ -1513,7 +1577,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)
@ -1522,12 +1590,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,
@ -1535,7 +1619,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)
@ -1553,12 +1642,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,
@ -1568,6 +1676,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,
@ -1581,12 +1692,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,
@ -1596,6 +1719,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
*/
@ -1656,7 +1791,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)
}
}
}
@ -1843,7 +1978,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

@ -12,6 +12,7 @@ sealed class SwapPermissionState {
object Empty : SwapPermissionState()
data class ReadyForRequest(
val providerName: String,
val currency: String,
val amount: String,
val walletAddress: String,

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.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
@ -74,18 +73,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
}
@ -102,7 +104,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

@ -51,7 +51,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,
@ -152,9 +156,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,
@ -221,9 +229,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,
@ -257,6 +275,7 @@ internal class StateBuilder(
permissionState = convertPermissionState(
lastPermissionState = uiStateHolder.permissionState,
permissionDataState = quoteModel.permissionState,
providerName = quoteModel.swapProvider.name,
onGivePermissionClick = actions.onGivePermissionClick,
onChangeApproveType = actions.onChangeApproveType,
),
@ -320,7 +339,7 @@ internal class StateBuilder(
val warnings = mutableListOf<SwapWarning>()
maybeAddDomainWarnings(quoteModel, warnings)
maybeAddNeedReserveToCreateAccountWarning(quoteModel, warnings)
maybeAddPermissionNeededWarning(quoteModel, warnings, fromToken)
maybeAddPermissionNeededWarning(quoteModel, warnings, fromToken, quoteModel.swapProvider.name)
maybeAddNetworkFeeCoverageWarning(quoteModel, warnings, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, warnings)
maybeAddInsufficientFundsWarning(quoteModel, warnings)
@ -468,6 +487,7 @@ internal class StateBuilder(
quoteModel: SwapState.QuotesLoadedState,
warnings: MutableList<SwapWarning>,
fromToken: CryptoCurrency,
providerName: String,
) {
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough &&
@ -475,7 +495,7 @@ internal class StateBuilder(
) {
warnings.add(
SwapWarning.PermissionNeeded(
createPermissionNotificationConfig(fromToken.symbol),
createPermissionNotificationConfig(fromToken.symbol, providerName),
),
)
}
@ -493,8 +513,8 @@ internal class StateBuilder(
warnings.add(
SwapWarning.GeneralWarning(
createNetworkFeeCoverageNotificationConfig(
fee.feeCryptoFormatted,
fee.feeFiatFormatted,
fee.feeCryptoFormattedWithNative,
fee.feeFiatFormattedWithNative,
),
),
)
@ -547,13 +567,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) {
@ -964,9 +987,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,
)
@ -1006,8 +1029,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,
@ -1017,7 +1039,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),
@ -1146,6 +1168,7 @@ internal class StateBuilder(
private fun convertPermissionState(
lastPermissionState: SwapPermissionState,
permissionDataState: PermissionDataState,
providerName: String,
onGivePermissionClick: () -> Unit,
onChangeApproveType: (ApproveType) -> Unit,
): SwapPermissionState {
@ -1165,6 +1188,7 @@ internal class StateBuilder(
is TxFeeState.SingleFeeState -> fee.fee
}
SwapPermissionState.ReadyForRequest(
providerName = providerName,
currency = permissionDataState.currency,
amount = permissionDataState.amount,
approveType = approveType,
@ -1358,18 +1382,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 = {},
),
@ -1402,12 +1426,12 @@ internal class StateBuilder(
}
// region warnings
private fun createPermissionNotificationConfig(fromTokenSymbol: String): NotificationConfig {
private fun createPermissionNotificationConfig(fromTokenSymbol: String, providerName: String): NotificationConfig {
return NotificationConfig(
title = resourceReference(R.string.express_provider_permission_needed),
subtitle = resourceReference(
id = R.string.swapping_permission_subheader,
formatArgs = wrappedList(fromTokenSymbol),
formatArgs = wrappedList(providerName, fromTokenSymbol),
),
iconResId = R.drawable.ic_locked_24,
)
@ -1604,7 +1628,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

@ -68,6 +68,7 @@ private fun SwapPermissionBottomSheetContent(content: GivePermissionBottomSheetC
Text(
text = stringResource(
id = R.string.swapping_permission_subheader,
data.providerName,
data.currency,
),
color = TangemTheme.colors.text.secondary,
@ -303,6 +304,7 @@ private fun Preview_AgreementBottomSheet() {
private val previewData = GivePermissionBottomSheetConfig(
data = SwapPermissionState.ReadyForRequest(
providerName = "1inch",
currency = "DAI",
amount = "",
walletAddress = "",

View file

@ -434,7 +434,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: TokenIconState,
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.tokenicon.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

@ -55,4 +55,8 @@ interface TokenDetailsClickIntents {
fun onCopyAddress(): TextReference?
fun onAssociateClick()
fun onGoToRefundedTokenClick(cryptoCurrency: CryptoCurrency)
fun onOpenUrlClick(url: String)
}

View file

@ -96,6 +96,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,
@ -156,6 +157,7 @@ internal class TokenDetailsViewModel @Inject constructor(
swapRepository = swapRepository,
quotesRepository = quotesRepository,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
addCryptoCurrenciesUseCase = addCryptoCurrenciesUseCase,
swapTransactionStatusStore = swapTransactionStatusStore,
dispatchers = dispatchers,
clickIntents = this,
@ -691,7 +693,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()
}
@ -713,6 +716,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

@ -87,7 +87,7 @@ markdown = "0.7.2"
# endregion Other libraries
# region Tangem
tangemBlockchainSdk = "release-app_5.12-701"
tangemBlockchainSdk = "release-app_5.12-715"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "release-app_5.12-373"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^

View file

@ -1,9 +1,11 @@
package com.tangem.lib.crypto
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.TransactionExtras
import com.tangem.lib.crypto.models.*
import com.tangem.lib.crypto.models.transactions.SendTxResult
import java.math.BigDecimal
import java.math.BigInteger
interface TransactionManager {
@ -46,7 +48,7 @@ interface TransactionManager {
@Throws(IllegalStateException::class)
suspend fun getFee(
networkId: String,
amountToSend: BigDecimal,
amountToSend: Amount,
currencyToSend: Currency,
destinationAddress: String,
increaseBy: Int?,
@ -54,6 +56,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 {