Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-25 14:33:17 +03:00
commit 8e37d710a1
23 changed files with 123 additions and 53 deletions

View file

@ -31,6 +31,9 @@ data class ExchangeDataResponse(
)
data class TxDetails(
@Json(name = "payoutAddress")
val payoutAddress: String,
@Json(name = "requestId")
val requestId: String,

View file

@ -193,6 +193,7 @@
<string name="exchange_send_view_header">Вы отправляете</string>
<string name="exchange_tokens_available_tokens_header">Мои токены</string>
<string name="exchange_tokens_empty_tokens">У вас нет добавленных токенов. Добавьте токены для обмена</string>
<string name="express_token_list_empty_search">Токены не найдены. Пожалуйста, попробуйте другой запрос</string>
<string name="exchange_tokens_unavailable_tokens_header">Недоступен для обмена с %s</string>
<string name="express_cex_fee_explanation">Кроме того, в курс обмена включена комиссия сети за отправку обмененных средств на ваш адрес</string>
<string name="express_cex_status_button_title">Статус</string>

View file

@ -192,6 +192,7 @@
<string name="exchange_send_view_header">You send</string>
<string name="exchange_tokens_available_tokens_header">My tokens</string>
<string name="exchange_tokens_empty_tokens">You haven\'t added any tokens yet. Add tokens via Market to swap</string>
<string name="express_token_list_empty_search">No tokens found. Please try another request</string>
<string name="exchange_tokens_unavailable_tokens_header">Cannot be swapped for %s</string>
<string name="express_cex_fee_explanation">Additionally, the network fee for sending the exchanged funds back to your address is included in the rate</string>
<string name="express_cex_status_button_title">Status</string>

View file

@ -292,6 +292,9 @@ internal class DefaultSwapRepository @Inject constructor(
if (txDetails.requestId != requestId) {
return@withContext DataError.InvalidRequestIdError().left()
}
if (toAddress != txDetails.payoutAddress) {
return@withContext DataError.InvalidPayoutAddressError().left()
}
expressDataConverter.convert(
ExchangeDataResponseWithTxDetails(
dataResponse = response,

View file

@ -13,7 +13,7 @@ internal class ExchangeStatusConverter : Converter<ExchangeStatusResponse, Excha
it.name.lowercase() == value.externalStatus.name.lowercase()
},
txId = value.externalTxId,
txUrl = value.externalTxUrl,
txExternalUrl = value.externalTxUrl,
)
}
}

View file

@ -36,6 +36,8 @@ sealed class DataError {
data class InvalidRequestIdError(override val code: Int = 991) : DataError()
data class InvalidPayoutAddressError(override val code: Int = 992) : DataError()
object UnknownError : DataError() {
override val code: Int = -1
}

View file

@ -4,7 +4,7 @@ data class ExchangeStatusModel(
val providerId: String,
val status: ExchangeStatus? = null,
val txId: String? = null,
val txUrl: String? = null,
val txExternalUrl: String? = null,
)
enum class ExchangeStatus {

View file

@ -11,8 +11,8 @@ data class TokensDataStateExpress(
companion object {
val EMPTY =
TokensDataStateExpress(
fromGroup = CurrenciesGroup(emptyList(), emptyList()),
toGroup = CurrenciesGroup(emptyList(), emptyList()),
fromGroup = CurrenciesGroup(emptyList(), emptyList(), false),
toGroup = CurrenciesGroup(emptyList(), emptyList(), false),
allProviders = emptyList(),
)
}
@ -21,4 +21,5 @@ data class TokensDataStateExpress(
data class CurrenciesGroup(
val available: List<CryptoCurrencySwapInfo>,
val unavailable: List<CryptoCurrencySwapInfo>,
val afterSearch: Boolean,
)

View file

@ -9,9 +9,8 @@ sealed class TxState {
val fromAmountValue: BigDecimal? = null,
val toAmount: String? = null,
val toAmountValue: BigDecimal? = null,
val txAddress: String,
val txHash: String,
val txExternalUrl: String? = null,
val txUrl: String? = null,
val timestamp: Long,
) : TxState()

View file

@ -11,5 +11,5 @@ interface BlockchainInteractor {
*/
fun getBlockchainInfo(networkId: String): NetworkInfo
fun getExplorerTransactionLink(networkId: String, txAddress: String): String
fun getExplorerTransactionLink(networkId: String, txHash: String): String
}

View file

@ -18,7 +18,7 @@ internal class DefaultBlockchainInteractor @Inject constructor(
}
}
override fun getExplorerTransactionLink(networkId: String, txAddress: String): String {
return transactionManager.getExplorerTransactionLink(networkId, txAddress)
override fun getExplorerTransactionLink(networkId: String, txHash: String): String {
return transactionManager.getExplorerTransactionLink(networkId, txHash)
}
}

View file

@ -54,7 +54,6 @@ internal class SwapInteractorImpl @Inject constructor(
private val dispatcher: CoroutineDispatcherProvider,
private val swapTransactionRepository: SwapTransactionRepository,
private val initialToCurrencyResolver: InitialToCurrencyResolver,
private val blockchainInteractor: BlockchainInteractor,
) : SwapInteractor {
private val estimateFeeUseCase by lazy(LazyThreadSafetyMode.NONE) {
@ -148,6 +147,7 @@ internal class SwapInteractorImpl @Inject constructor(
return CurrenciesGroup(
available = availableCryptoCurrencies,
unavailable = unavailableCryptoCurrencies.map { CryptoCurrencySwapInfo(it, emptyList()) },
afterSearch = false,
)
}
@ -214,7 +214,7 @@ internal class SwapInteractorImpl @Inject constructor(
is SendTxResult.Success -> {
allowPermissionsHandler.addAddressToInProgress(permissionOptions.forTokenContractAddress)
TxState.TxSent(
txAddress = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(),
txHash = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(),
timestamp = System.currentTimeMillis(),
)
}
@ -462,7 +462,7 @@ internal class SwapInteractorImpl @Inject constructor(
currencyToGet.symbol,
),
toAmountValue = swapData.toTokenAmount.value,
txAddress = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(),
txHash = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(),
timestamp = System.currentTimeMillis(),
)
}
@ -536,10 +536,6 @@ internal class SwapInteractorImpl @Inject constructor(
},
ifRight = {
val timestamp = System.currentTimeMillis()
val txUrl = blockchainInteractor.getExplorerTransactionLink(
networkId = currencyToSend.currency.network.backendId,
txAddress = exchangeData.transaction.txTo,
)
storeSwapTransaction(
currencyToSend = currencyToSend,
currencyToGet = currencyToGet,
@ -547,7 +543,7 @@ internal class SwapInteractorImpl @Inject constructor(
swapProvider = swapProvider,
swapDataModel = exchangeData,
timestamp = timestamp,
txUrl = txUrl,
txExternalUrl = externalUrl.orEmpty(),
)
storeLastCryptoCurrencyId(currencyToGet.currency)
TxState.TxSent(
@ -561,12 +557,11 @@ internal class SwapInteractorImpl @Inject constructor(
currencyToGet.currency.symbol,
),
toAmountValue = exchangeData.toTokenAmount.value,
txAddress = userWalletManager.getLastTransactionHash(
txHash = userWalletManager.getLastTransactionHash(
currencyToSend.currency.network.backendId,
derivationPath,
).orEmpty(),
txExternalUrl = externalUrl,
txUrl = txUrl,
timestamp = timestamp,
)
},
@ -601,7 +596,7 @@ internal class SwapInteractorImpl @Inject constructor(
swapProvider: SwapProvider,
swapDataModel: SwapDataModel,
timestamp: Long,
txUrl: String,
txExternalUrl: String,
) {
swapTransactionRepository.storeTransaction(
userWalletId = UserWalletId(userWalletManager.getWalletId()),
@ -617,7 +612,7 @@ internal class SwapInteractorImpl @Inject constructor(
providerId = swapProvider.providerId,
status = ExchangeStatus.New,
txId = swapDataModel.transaction.txId,
txUrl = txUrl,
txExternalUrl = txExternalUrl,
),
),
)

View file

@ -42,7 +42,6 @@ class SwapDomainModule {
walletManagersFacade: WalletManagersFacade,
coroutineDispatcherProvider: CoroutineDispatcherProvider,
initialToCurrencyResolver: InitialToCurrencyResolver,
blockchainInteractor: BlockchainInteractor,
): SwapInteractor {
return SwapInteractorImpl(
transactionManager = transactionManager,
@ -57,7 +56,6 @@ class SwapDomainModule {
dispatcher = coroutineDispatcherProvider,
swapTransactionRepository = swapTransactionRepository,
initialToCurrencyResolver = initialToCurrencyResolver,
blockchainInteractor = blockchainInteractor,
)
}

View file

@ -49,6 +49,7 @@ class TokensDataConverter(
.toImmutableList(),
onSearchEntered = onSearchEntered,
onTokenSelected = onTokenSelected,
afterSearch = value.afterSearch,
)
}

View file

@ -7,6 +7,7 @@ import kotlinx.collections.immutable.ImmutableList
data class SwapSelectTokenStateHolder(
val availableTokens: ImmutableList<TokenToSelectState>,
val unavailableTokens: ImmutableList<TokenToSelectState>,
val afterSearch: Boolean,
val onSearchEntered: (String) -> Unit,
val onTokenSelected: (String) -> Unit,
)

View file

@ -72,7 +72,6 @@ sealed class SwapCardState {
data class SwapButton(
val enabled: Boolean,
val loading: Boolean = false,
val onClick: () -> Unit,
)

View file

@ -260,7 +260,9 @@ private fun ProviderLoadingState(modifier: Modifier = Modifier) {
),
) {
CircularProgressIndicator(
modifier = Modifier.size(TangemTheme.dimens.size16),
modifier = Modifier
.size(TangemTheme.dimens.size16)
.align(Alignment.CenterVertically),
color = TangemTheme.colors.icon.informative,
strokeWidth = TangemTheme.dimens.size2,
)

View file

@ -79,7 +79,7 @@ internal class StateBuilder(
),
fee = FeeItemState.Empty,
networkCurrency = networkInfo.blockchainCurrency,
swapButton = SwapButton(enabled = false, loading = true, onClick = {}),
swapButton = SwapButton(enabled = false, onClick = {}),
onRefresh = {},
onBackClicked = actions.onBackClicked,
onChangeCardsClicked = actions.onChangeCardsClicked,
@ -138,7 +138,6 @@ internal class StateBuilder(
fee = FeeItemState.Empty,
swapButton = SwapButton(
enabled = false,
loading = false,
onClick = { },
),
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
@ -187,7 +186,7 @@ internal class StateBuilder(
),
warnings = emptyList(),
fee = FeeItemState.Empty,
swapButton = SwapButton(enabled = false, loading = true, onClick = {}),
swapButton = SwapButton(enabled = false, onClick = {}),
providerState = ProviderState.Loading(),
permissionState = uiStateHolder.permissionState,
changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS,
@ -260,7 +259,6 @@ internal class StateBuilder(
fee = feeState,
swapButton = SwapButton(
enabled = getSwapButtonEnabled(quoteModel.preparedSwapConfigState),
loading = false,
onClick = actions.onSwapClick,
),
changeCardsButtonState = if (isReverseSwapPossible) {
@ -454,7 +452,6 @@ internal class StateBuilder(
fee = FeeItemState.Empty,
swapButton = SwapButton(
enabled = false,
loading = false,
onClick = actions.onSwapClick,
),
changeCardsButtonState = if (isReverseSwapPossible) {
@ -566,7 +563,6 @@ internal class StateBuilder(
fee = FeeItemState.Empty,
swapButton = SwapButton(
enabled = false,
loading = false,
onClick = { },
),
changeCardsButtonState = if (isReverseSwapPossible) {
@ -582,7 +578,6 @@ internal class StateBuilder(
fun createSwapInProgressState(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
swapButton = uiState.swapButton.copy(
loading = true,
enabled = false,
),
)
@ -737,6 +732,7 @@ internal class StateBuilder(
dataState: SwapProcessDataState,
onExploreClick: () -> Unit,
onStatusClick: () -> Unit,
txUrl: String,
): SwapStateHolder {
val fee = requireNotNull(dataState.selectedFee)
val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency)
@ -751,7 +747,7 @@ internal class StateBuilder(
return uiState.copy(
successState = SwapSuccessStateHolder(
timestamp = txState.timestamp,
txUrl = txState.txUrl.orEmpty(),
txUrl = txUrl,
providerName = stringReference(providerState.name),
providerType = stringReference(providerState.type),
showStatusButton = providerState.type == ExchangeProviderType.CEX.name,

View file

@ -399,7 +399,6 @@ private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> U
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = R.string.swapping_insufficient_funds),
enabled = false,
showProgress = state.swapButton.loading,
onClick = state.swapButton.onClick,
)
}
@ -408,7 +407,6 @@ private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> U
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = R.string.swapping_give_permission),
enabled = true,
showProgress = state.swapButton.loading,
onClick = onPermissionWarningClick,
)
}
@ -418,7 +416,6 @@ private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> U
text = stringResource(id = R.string.common_swap),
iconResId = R.drawable.ic_tangem_24,
enabled = state.swapButton.enabled,
showProgress = state.swapButton.loading,
onClick = state.swapButton.onClick,
)
}
@ -487,7 +484,7 @@ private val state = SwapStateHolder(
),
),
networkCurrency = "MATIC",
swapButton = SwapButton(enabled = true, loading = false, onClick = {}),
swapButton = SwapButton(enabled = true, onClick = {}),
onRefresh = {},
onBackClicked = {},
onChangeCardsClicked = {},

View file

@ -42,10 +42,16 @@ fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit)
.background(color = TangemTheme.colors.background.secondary),
content = { padding ->
val modifier = Modifier.padding(padding)
if (state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty()) {
EmptyTokensList(modifier)
} else {
ListOfTokens(state = state, modifier = modifier)
when {
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && state.afterSearch -> {
TokensNotFound(modifier)
}
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && !state.afterSearch -> {
EmptyTokensList(modifier)
}
else -> {
ListOfTokens(state = state, modifier = modifier)
}
}
},
topBar = {
@ -91,6 +97,26 @@ private fun EmptyTokensList(modifier: Modifier = Modifier) {
}
}
@Composable
private fun TokensNotFound(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.background(TangemTheme.colors.background.secondary)
.fillMaxSize(),
) {
Text(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing32)
.padding(horizontal = TangemTheme.dimens.spacing30)
.align(Alignment.TopCenter),
text = stringResource(id = R.string.express_token_list_empty_search),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) {
val screenBackgroundColor = TangemTheme.colors.background.secondary
@ -283,6 +309,7 @@ private fun TokenScreenPreview() {
state = SwapSelectTokenStateHolder(
availableTokens = listOf(title, token, token, token).toImmutableList(),
unavailableTokens = listOf(title, token, token, token).toImmutableList(),
afterSearch = false,
onSearchEntered = {},
onTokenSelected = {},
),

View file

@ -418,7 +418,7 @@ private fun TokenIcon(
contentAlignment = Alignment.Center,
) {
Image(
modifier = Modifier.padding(all = TangemTheme.dimens.spacing0_5),
modifier = Modifier.padding(all = TangemTheme.dimens.spacing2),
painter = painterResource(id = networkIconRes),
contentDescription = null,
)

View file

@ -12,8 +12,11 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.domain.BlockchainInteractor
import com.tangem.feature.swap.domain.SwapInteractor
@ -32,6 +35,7 @@ import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.utils.coroutines.*
import com.tangem.utils.isNullOrZero
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -57,6 +61,7 @@ internal class SwapViewModel @Inject constructor(
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase,
private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase,
savedStateHandle: SavedStateHandle,
) : ViewModel(), DefaultLifecycleObserver {
@ -93,6 +98,11 @@ internal class SwapViewModel @Inject constructor(
private var isOrderReversed = false
private val lastAmount = mutableStateOf(INITIAL_AMOUNT)
private var swapRouter: SwapRouter by Delegates.notNull()
private val isExchangeTooSmallAmountError: (SwapState) -> Boolean = {
it is SwapState.SwapError && it.error is DataError.ExchangeTooSmallAmountError
}
val currentScreen: SwapNavScreen
get() = swapRouter.currentScreen
@ -380,13 +390,17 @@ internal class SwapViewModel @Inject constructor(
}
private fun selectProvider(state: Map<SwapProvider, SwapState>): SwapProvider {
val stateSuccess = state.getLastLoadedSuccessStates()
return if (stateSuccess.isNotEmpty()) {
val consideredProviders = state.filter {
it.value is SwapState.QuotesLoadedState || isExchangeTooSmallAmountError(it.value)
}
return if (consideredProviders.isNotEmpty()) {
val currentSelected = dataState.selectedProvider
if (currentSelected != null && stateSuccess.keys.contains(currentSelected)) {
if (currentSelected != null && consideredProviders.keys.contains(currentSelected)) {
currentSelected
} else {
findBestQuoteProvider(stateSuccess) ?: stateSuccess.keys.first()
findBestQuoteProvider(consideredProviders.getLastLoadedSuccessStates())
?: consideredProviders.keys.first()
}
} else {
state.keys.first()
@ -448,14 +462,19 @@ internal class SwapViewModel @Inject constructor(
}.onSuccess {
when (it) {
is TxState.TxSent -> {
val url = blockchainInteractor.getExplorerTransactionLink(
networkId = fromCurrency.currency.network.backendId,
txHash = it.txHash,
)
updateWalletBalance()
uiState = stateBuilder.createSuccessState(
uiState = uiState,
txState = it,
dataState = dataState,
txUrl = url,
onExploreClick = {
val txUrl = it.txUrl
if (!txUrl.isNullOrBlank()) {
swapRouter.openUrl(txUrl)
if (it.txHash.isNotEmpty()) {
swapRouter.openUrl(url)
}
analyticsEventHandler.send(
event = SwapEvents.ButtonExplore(initialCryptoCurrency.symbol),
@ -580,13 +599,16 @@ internal class SwapViewModel @Inject constructor(
fromGroup = tokenDataState.fromGroup.copy(
available = available,
unavailable = unavailable,
afterSearch = true,
),
)
} else {
tokenDataState.copy(
toGroup = tokenDataState.toGroup.copy(
available = available,
unavailable = unavailable,
afterSearch = true,
),
)
}
@ -957,10 +979,32 @@ internal class SwapViewModel @Inject constructor(
}
}
private fun updateWalletBalance() {
swapInteractor.getSelectedWallet()?.let { userWallet ->
dataState.fromCryptoCurrency?.currency?.network?.let { network ->
viewModelScope.launch {
withContext(NonCancellable) {
updateForBalance(userWallet, network)
}
}
}
}
}
private suspend fun updateForBalance(userWallet: UserWallet, network: Network) {
updateDelayedCurrencyStatusUseCase(
userWalletId = userWallet.walletId,
network = network,
delayMillis = UPDATE_BALANCE_DELAY_MILLIS,
refresh = true,
)
}
companion object {
private const val loggingTag = "SwapViewModel"
private const val INITIAL_AMOUNT = ""
private const val UPDATE_DELAY = 10000L
private const val DEBOUNCE_AMOUNT_DELAY = 1000L
private const val UPDATE_BALANCE_DELAY_MILLIS = 11000L
}
}

View file

@ -66,7 +66,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
SwapTransactionsState(
txId = transaction.txId,
provider = transaction.provider,
txUrl = transaction.status?.txUrl,
txUrl = transaction.status?.txExternalUrl,
timestamp = TextReference.Str("${timestamp.toDateFormat()}, ${timestamp.toTimeFormat()}"),
fiatSymbol = appCurrency.symbol,
statuses = getStatuses(transaction.status?.status),
@ -74,7 +74,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
activeStatus = transaction.status?.status,
notification = getNotification(
transaction.status?.status,
transaction.status?.txUrl,
transaction.status?.txExternalUrl,
),
toCryptoCurrencyId = toCurrency.currency.id,
toCryptoAmount = BigDecimalFormatter.formatCryptoAmount(
@ -112,9 +112,9 @@ internal class TokenDetailsSwapTransactionsStateConverter(
return tx.copy(
activeStatus = statusModel.status,
hasFailed = hasFailed,
notification = getNotification(statusModel.status, statusModel.txUrl),
notification = getNotification(statusModel.status, statusModel.txExternalUrl),
statuses = getStatuses(statusModel.status, hasFailed),
txUrl = statusModel.txUrl,
txUrl = statusModel.txExternalUrl,
)
}