Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-30 11:46:31 +03:00
commit 14a7ac4f5e
431 changed files with 23077 additions and 3701 deletions

View file

@ -19,6 +19,7 @@ internal class ExpressProviderConverter : Converter<ExchangeProvider, ExpressPro
privacyPolicy = value.privacyPolicy,
isRecommended = value.isRecommended,
slippage = value.slippage,
isExchangeOnlyWithinSingleAddress = value.isExchangeOnlyWithinSingleAddress,
)
}

View file

@ -4,6 +4,7 @@ import com.tangem.data.news.repository.DefaultNewsRepository
import com.tangem.datasource.api.news.NewsApi
import com.tangem.datasource.local.news.details.NewsDetailsStore
import com.tangem.datasource.local.news.trending.TrendingNewsStore
import com.tangem.datasource.local.news.viewed.NewsViewedStore
import com.tangem.domain.news.repository.NewsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -23,12 +24,14 @@ internal object NewsDataModule {
dispatchers: CoroutineDispatcherProvider,
newsDetailsStore: NewsDetailsStore,
trendingNewsStore: TrendingNewsStore,
newsViewedStore: NewsViewedStore,
): NewsRepository {
return DefaultNewsRepository(
newsApi = newsApi,
dispatchers = dispatchers,
newsDetailsStore = newsDetailsStore,
trendingNewsStore = trendingNewsStore,
newsViewedStore = newsViewedStore,
)
}
}

View file

@ -7,24 +7,19 @@ import com.tangem.datasource.api.news.NewsApi
import com.tangem.datasource.api.news.models.response.NewsTrendingResponse
import com.tangem.datasource.local.news.details.NewsDetailsStore
import com.tangem.datasource.local.news.trending.TrendingNewsStore
import com.tangem.datasource.local.news.viewed.NewsViewedStore
import com.tangem.domain.models.news.*
import com.tangem.domain.news.model.NewsListBatchFlow
import com.tangem.domain.news.model.NewsListBatchingContext
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.domain.news.repository.NewsRepository
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListSource
import com.tangem.pagination.*
import com.tangem.pagination.exception.EndOfPaginationException
import com.tangem.pagination.fetcher.BatchFetcher
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import timber.log.Timber
/**
@ -36,15 +31,39 @@ internal class DefaultNewsRepository(
private val dispatchers: CoroutineDispatcherProvider,
private val newsDetailsStore: NewsDetailsStore,
private val trendingNewsStore: TrendingNewsStore,
private val newsViewedStore: NewsViewedStore,
) : NewsRepository {
override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow {
return BatchListSource(
val newsBatchFlow = BatchListSource(
fetchDispatcher = dispatchers.io,
context = context,
generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: INITIAL_BATCH_KEY },
batchFetcher = createBatchFetcher(batchSize),
).toBatchFlow()
return updateViewedStatusForNewsBatch(newsBatchFlow, context.coroutineScope)
}
override suspend fun getNews(config: NewsListConfig, limit: Int): List<ShortArticle> {
return withContext(dispatchers.io) {
val response = newsApi.getNews(
page = FIRST_PAGE,
limit = limit,
language = config.language,
snapshot = config.snapshot,
tokenIds = config.tokenIds.takeIf { it.isNotEmpty() },
categoryIds = config.categoryIds.takeIf { it.isNotEmpty() },
).getOrThrow()
val articles = response.items.map { it.toDomainShortArticle() }
val viewedFlags = newsViewedStore.getSync()
articles.map { article ->
val isViewed = viewedFlags[article.id] == true
article.copy(viewed = isViewed)
}
}
}
override suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle {
@ -73,37 +92,67 @@ internal class DefaultNewsRepository(
}
override fun observeTrendingNews(): Flow<TrendingNews> {
return trendingNewsStore.get(TRENDING_NEWS_KEY)
}
override suspend fun updateTrendingNewsViewed(articleIds: Collection<Int>, viewed: Boolean) {
if (articleIds.isEmpty()) return
val currentResult = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY) ?: return
val currentArticles = when (currentResult) {
is TrendingNews.Data -> currentResult.articles
is TrendingNews.Error -> return
}
if (currentArticles.isEmpty()) return
val ids = articleIds.toSet()
val updated = currentArticles.map { article ->
if (article.id in ids) {
article.copy(viewed = viewed)
} else {
article
return combine(
trendingNewsStore.get(TRENDING_NEWS_KEY),
newsViewedStore.getAll(),
) { trendingNews, viewedFlags ->
when (trendingNews) {
is TrendingNews.Data -> {
val articlesWithViewedFlags = trendingNews.articles.map { article ->
val isViewed = viewedFlags[article.id] == true
article.copy(viewed = isViewed)
}
TrendingNews.Data(articlesWithViewedFlags)
}
is TrendingNews.Error -> trendingNews
}
}
trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(updated))
}
override suspend fun getCategories(): List<ArticleCategory> {
return newsApi.getCategories().getOrThrow().items.map { dto ->
ArticleCategory(
id = dto.id,
name = dto.name,
)
return withContext(dispatchers.io) {
newsApi.getCategories().getOrThrow().items.map { dto ->
ArticleCategory(
id = dto.id,
name = dto.name,
)
}
}
}
override suspend fun updateNewsViewed(articleIds: Collection<Int>, viewed: Boolean) {
newsViewedStore.updateViewed(articleIds, viewed)
}
private fun updateViewedStatusForNewsBatch(
newsBatchFlow: NewsListBatchFlow,
scope: CoroutineScope,
): NewsListBatchFlow {
return object : NewsListBatchFlow {
override val state: StateFlow<BatchListState<Int, List<ShortArticle>>> =
combine(
newsBatchFlow.state,
newsViewedStore.getAll(),
) { batchListState, viewedFlags ->
val updatedBatches = batchListState.data.map { batch ->
val updatedArticles = batch.data.map { article ->
val isViewed = viewedFlags[article.id] == true
article.copy(viewed = isViewed)
}
Batch(key = batch.key, data = updatedArticles)
}
BatchListState(
data = updatedBatches,
status = batchListState.status,
)
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = BatchListState(emptyList(), newsBatchFlow.state.value.status),
)
override val updateResults: SharedFlow<Pair<Nothing, BatchUpdateResult<Int, List<ShortArticle>>>> =
newsBatchFlow.updateResults
}
}
@ -121,7 +170,7 @@ internal class DefaultNewsRepository(
if (idsToFetch.isEmpty()) return@withContext
val fetchedArticles = coroutineScope {
val fetchedArticles = supervisorScope {
idsToFetch.map { newsId ->
async {
newsApi.getNewsDetails(newsId = newsId, language = language)
@ -140,13 +189,12 @@ internal class DefaultNewsRepository(
private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?) {
return withContext(dispatchers.io) {
val apiResponse = newsApi.getTrendingNews(limit = limit, language = language)
when (val result = apiResponse) {
when (val apiResponse = newsApi.getTrendingNews(limit = limit, language = language)) {
is ApiResponse.Error -> {
Timber.e(
result.cause.cause,
apiResponse.cause.cause,
"Trending news fetch failed cause: ${
when (val error = result.cause) {
when (val error = apiResponse.cause) {
is ApiResponseError.HttpException -> error.code
is ApiResponseError.NetworkException -> "NetworkException"
is ApiResponseError.TimeoutException -> "TimeoutException"
@ -159,49 +207,34 @@ internal class DefaultNewsRepository(
key = TRENDING_NEWS_KEY,
value = TrendingNews.Error(
NewsError.Unknown(
message = result.cause.message,
message = apiResponse.cause.message,
code = null,
),
),
)
}
is ApiResponse.Success<NewsTrendingResponse> -> {
val freshArticles = result.data.items.map { it.toDomainShortArticle() }
val cachedArticles = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY)
val currentArticles = when (cachedArticles) {
is TrendingNews.Data -> cachedArticles.articles
is TrendingNews.Error -> emptyList()
null -> emptyList()
}
val merged = mergeTrendingArticles(current = currentArticles, fresh = freshArticles).take(limit)
trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(merged))
TrendingNews.Data(merged)
val freshArticles = apiResponse.data.items.map { it.toDomainShortArticle() }
val articles = freshArticles.take(limit)
trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(articles))
TrendingNews.Data(articles)
}
}
}
}
private fun mergeTrendingArticles(current: List<ShortArticle>, fresh: List<ShortArticle>): List<ShortArticle> {
if (current.isEmpty()) return fresh
val currentById = current.associateBy(ShortArticle::id)
return fresh.map { article ->
val stored = currentById[article.id] ?: return@map article
article.copy(viewed = stored.viewed)
}
}
private fun createBatchFetcher(batchSize: Int): BatchFetcher<NewsListConfig, List<ShortArticle>> {
return NewsBatchFetcher(
newsApi = newsApi,
batchSize = batchSize,
newsViewedStore = newsViewedStore,
)
}
private class NewsBatchFetcher(
private val newsApi: NewsApi,
private val batchSize: Int,
private val newsViewedStore: NewsViewedStore,
) : BatchFetcher<NewsListConfig, List<ShortArticle>> {
private var state: NewsPaginationState? = null
@ -266,12 +299,18 @@ internal class DefaultNewsRepository(
page = page,
limit = limit,
language = params.language,
snapshot = snapshotOverride,
snapshot = snapshotOverride?.takeIf { it.isNotEmpty() },
tokenIds = params.tokenIds.takeIf { it.isNotEmpty() },
categoryIds = params.categoryIds.takeIf { it.isNotEmpty() },
).getOrThrow()
val items = response.items.map { it.toDomainShortArticle() }
val articles = response.items.map { it.toDomainShortArticle() }
val viewedFlags = newsViewedStore.getSync()
val items = articles.map { article ->
val isViewed = viewedFlags[article.id] == true
article.copy(viewed = isViewed)
}
val batchResult = BatchFetchResult.Success(
data = items,

View file

@ -4,11 +4,7 @@ import com.tangem.datasource.api.news.models.response.NewsArticleDto
import com.tangem.datasource.api.news.models.response.NewsDetailsResponse
import com.tangem.datasource.api.news.models.response.NewsOriginalArticleDto
import com.tangem.datasource.api.news.models.response.NewsRelatedTokenDto
import com.tangem.domain.models.news.ArticleCategory
import com.tangem.domain.models.news.DetailedArticle
import com.tangem.domain.models.news.OriginalArticle
import com.tangem.domain.models.news.RelatedToken
import com.tangem.domain.models.news.ShortArticle
import com.tangem.domain.models.news.*
internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle {
return DetailedArticle(
@ -54,7 +50,10 @@ internal fun NewsOriginalArticleDto.toDomainOriginalArticle(): OriginalArticle {
return OriginalArticle(
id = id,
title = title,
sourceName = sourceName,
source = Source(
id = source.id,
name = source.name,
),
locale = language,
publishedAt = publishedAt,
url = url,

View file

@ -2,21 +2,32 @@ package com.tangem.data.staking
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.Raise
import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.data.staking.converters.ethpool.*
import com.tangem.data.staking.converters.ethpool.P2PEthPoolBroadcastResultConverter
import com.tangem.data.staking.converters.ethpool.P2PEthPoolErrorConverter
import com.tangem.data.staking.converters.ethpool.P2PEthPoolRewardConverter
import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingAccountConverter
import com.tangem.data.staking.converters.ethpool.P2PEthPoolUnsignedTxConverter
import com.tangem.data.staking.converters.ethpool.P2PEthPoolVaultConverter
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolDepositRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolUnstakeRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolWithdrawRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionRequest
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolTransactionResponse
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.domain.models.staking.P2PEthPoolStakingAccount
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.ethpool.*
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult
import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork
import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward
import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
@ -26,25 +37,44 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
/**
* P2P staking repository implementation
* P2PEthPool staking repository implementation
*/
internal class DefaultP2PEthPoolRepository(
private val p2pApi: P2PEthPoolApi,
private val p2pEthPoolApi: P2PEthPoolApi,
private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
private val dispatchers: CoroutineDispatcherProvider,
private val stakingFeatureToggles: StakingFeatureToggles,
) : P2PEthPoolRepository {
private val vaultConverter = P2PEthPoolVaultConverter
private val accountInfoConverter = P2PEthPoolAccountConverter
private val accountConverter = P2PEthPoolStakingAccountConverter
private val rewardConverter = P2PEthPoolRewardConverter
private val broadcastResultConverter = P2PEthPoolBroadcastResultConverter
private val errorConverter = P2PEthPoolErrorConverter
/**
* Handles P2PEthPool API response with error checking and result extraction.
* Reduces duplication across all API call methods.
*/
private inline fun <T, R> Raise<StakingError>.handleApiResponse(
response: ApiResponse<P2PEthPoolResponse<T>>,
transform: (T) -> R,
): R = when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
transform(result)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
override suspend fun fetchVaults(network: P2PEthPoolNetwork) {
val vaults = if (stakingFeatureToggles.isEthStakingEnabled) {
getVaults(network).getOrElse { error ->
Timber.e("Error fetching P2P vaults: $error")
Timber.e("Error fetching P2PEthPool vaults: $error")
emptyList()
}
} else {
@ -56,17 +86,8 @@ internal class DefaultP2PEthPoolRepository(
override suspend fun getVaults(network: P2PEthPoolNetwork): Either<StakingError, List<P2PEthPoolVault>> = either {
withContext(dispatchers.io) {
val response = p2pApi.getVaults(network.value)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
result.vaults.map { vaultConverter.convert(it) }
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
handleApiResponse(p2pEthPoolApi.getVaults(network.value)) { result ->
result.vaults.map { vaultConverter.convert(it) }
}
}
}
@ -76,81 +97,60 @@ internal class DefaultP2PEthPoolRepository(
delegatorAddress: String,
vaultAddress: String,
amount: String,
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolDepositRequest(
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
amount = amount.toDoubleOrNull() ?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")),
)
val response = p2pApi.createDepositTransaction(network.value, requestBody)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
): Either<StakingError, P2PEthPoolUnsignedTx> = createStakingTransaction(
network = network,
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
amount = amount,
apiCall = p2pEthPoolApi::createDepositTransaction,
)
override suspend fun createUnstakeTransaction(
network: P2PEthPoolNetwork,
stakerPublicKey: String,
stakeTransactionHash: String,
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolUnstakeRequest(
stakerPublicKey = stakerPublicKey,
stakeTransactionHash = stakeTransactionHash,
)
val response = p2pApi.createUnstakeTransaction(network.value, requestBody)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
// Note: API returns only hex string for unstake, not full transaction structure
P2PEthPoolUnsignedTx(
serializeTx = result.unstakeTransactionHex,
to = "", // Will be parsed from hex by wallet
data = result.unstakeTransactionHex,
value = java.math.BigDecimal.ZERO,
nonce = 0,
chainId = network.chainId,
gasLimit = java.math.BigDecimal.ZERO,
maxFeePerGas = java.math.BigDecimal.ZERO,
maxPriorityFeePerGas = java.math.BigDecimal.ZERO,
)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
delegatorAddress: String,
vaultAddress: String,
amount: String,
): Either<StakingError, P2PEthPoolUnsignedTx> = createStakingTransaction(
network = network,
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
amount = amount,
apiCall = p2pEthPoolApi::createUnstakeTransaction,
)
override suspend fun createWithdrawTransaction(
network: P2PEthPoolNetwork,
stakerAddress: String,
delegatorAddress: String,
vaultAddress: String,
amount: String,
): Either<StakingError, P2PEthPoolUnsignedTx> = createStakingTransaction(
network = network,
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
amount = amount,
apiCall = p2pEthPoolApi::createWithdrawTransaction,
)
private suspend fun createStakingTransaction(
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
amount: String,
apiCall:
suspend (
String,
P2PEthPoolTransactionRequest,
) -> ApiResponse<P2PEthPoolResponse<P2PEthPoolTransactionResponse>>,
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolWithdrawRequest(stakerAddress = stakerAddress)
val response = p2pApi.createWithdrawTransaction(network.value, requestBody)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
val requestBody = P2PEthPoolTransactionRequest(
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
amount = amount.toBigDecimalOrNull()
?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")),
)
handleApiResponse(apiCall(network.value, requestBody)) { result ->
P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction)
}
}
}
@ -161,17 +161,8 @@ internal class DefaultP2PEthPoolRepository(
): Either<StakingError, P2PEthPoolBroadcastResult> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolBroadcastRequest(signedTransaction = signedTransaction)
val response = p2pApi.broadcastTransaction(network.value, requestBody)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
broadcastResultConverter.convert(result)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
handleApiResponse(p2pEthPoolApi.broadcastTransaction(network.value, requestBody)) { result ->
broadcastResultConverter.convert(result)
}
}
}
@ -180,19 +171,12 @@ internal class DefaultP2PEthPoolRepository(
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
): Either<StakingError, P2PEthPoolAccount> = either {
): Either<StakingError, P2PEthPoolStakingAccount> = either {
withContext(dispatchers.io) {
val response = p2pApi.getAccountInfo(network.value, delegatorAddress, vaultAddress)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
accountInfoConverter.convert(result)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
handleApiResponse(
p2pEthPoolApi.getAccountInfo(network.value, delegatorAddress, vaultAddress),
) { result ->
accountConverter.convert(result)
}
}
}
@ -204,26 +188,23 @@ internal class DefaultP2PEthPoolRepository(
period: Int?,
): Either<StakingError, List<P2PEthPoolReward>> = either {
withContext(dispatchers.io) {
val response = p2pApi.getRewards(
network = network.value,
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
period = period,
)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
result.rewards.map { rewardConverter.convert(it) }
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
handleApiResponse(
p2pEthPoolApi.getRewards(
network = network.value,
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
period = period,
),
) { result ->
result.rewards.map { rewardConverter.convert(it) }
}
}
}
override fun getVaultsFlow(): Flow<List<P2PEthPoolVault>> {
return p2pEthPoolVaultsStore.get()
}
override fun getStakingAvailability(): Flow<StakingAvailability> {
return getVaultsFlow()
.distinctUntilChanged()
@ -231,7 +212,7 @@ internal class DefaultP2PEthPoolRepository(
if (vaults.isEmpty()) {
return@map StakingAvailability.TemporaryUnavailable
} else {
StakingAvailability.Available(StakingOption.P2P(vaults))
StakingAvailability.Available(StakingOption.P2PEthPool(vaults))
}
}
}
@ -241,15 +222,11 @@ internal class DefaultP2PEthPoolRepository(
return if (vaults.isEmpty()) {
StakingAvailability.TemporaryUnavailable
} else {
StakingAvailability.Available(StakingOption.P2P(vaults))
StakingAvailability.Available(StakingOption.P2PEthPool(vaults))
}
}
private suspend fun getVaultsSync(): List<P2PEthPoolVault> {
override suspend fun getVaultsSync(): List<P2PEthPoolVault> {
return p2pEthPoolVaultsStore.getSync()
}
private fun getVaultsFlow(): Flow<List<P2PEthPoolVault>> {
return p2pEthPoolVaultsStore.get()
}
}

View file

@ -364,6 +364,7 @@ internal class DefaultStakeKitRepository(
}
override fun getStakingAvailability(
integrationId: StakingIntegrationID.StakeKit,
rawCurrencyId: CryptoCurrency.RawID,
symbol: String,
): Flow<StakingAvailability> {
@ -381,7 +382,7 @@ internal class DefaultStakeKitRepository(
)
if (prefetchedYield != null) {
StakingAvailability.Available(StakingOption.StakeKit(prefetchedYield))
StakingAvailability.Available(StakingOption.StakeKit(integrationId, prefetchedYield))
} else {
StakingAvailability.TemporaryUnavailable
}
@ -389,6 +390,7 @@ internal class DefaultStakeKitRepository(
}
override suspend fun getStakingAvailabilitySync(
integrationId: StakingIntegrationID.StakeKit,
rawCurrencyId: CryptoCurrency.RawID,
symbol: String,
): StakingAvailability {
@ -404,7 +406,7 @@ internal class DefaultStakeKitRepository(
)
return if (prefetchedYield != null) {
StakingAvailability.Available(StakingOption.StakeKit(prefetchedYield))
StakingAvailability.Available(StakingOption.StakeKit(integrationId, prefetchedYield))
} else {
StakingAvailability.TemporaryUnavailable
}

View file

@ -29,12 +29,12 @@ internal class DefaultStakingErrorResolver(
is StakingError.DomainError -> {
analyticsEventHandler.send(StakingAnalyticsEvent.DomainError(error))
}
// P2P errors
// P2PEthPool errors
is StakingError.InvalidAmount,
is StakingError.DataError,
is StakingError.UnknownError,
-> {
// P2P errors - no specific analytics event yet
// P2PEthPool errors - no specific analytics event yet
}
}

View file

@ -61,8 +61,9 @@ internal class DefaultStakingRepository(
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
val availabilityFlow = when (stakingIntegration) {
is StakingIntegrationID.P2P -> p2pEthPoolRepository.getStakingAvailability()
StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailability()
is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability(
stakingIntegration,
rawCurrencyId,
cryptoCurrency.symbol,
)
@ -94,8 +95,9 @@ internal class DefaultStakingRepository(
?: return StakingAvailability.Unavailable
return when (stakingIntegration) {
is StakingIntegrationID.P2P -> p2pEthPoolRepository.getStakingAvailabilitySync()
StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailabilitySync()
is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailabilitySync(
stakingIntegration,
rawCurrencyId,
cryptoCurrency.symbol,
)

View file

@ -5,6 +5,8 @@ import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardScheduleDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.ValidatorDTO.ValidatorStatusDTO
import com.tangem.datasource.local.token.converter.YieldTokenConverter
import com.tangem.domain.staking.model.common.RewardInfo
import com.tangem.domain.staking.model.common.RewardType
import com.tangem.domain.staking.model.stakekit.AddressArgument
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.Yield.Metadata.RewardSchedule
@ -124,7 +126,7 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
)
}
private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO, rewardType: Yield.RewardType): Yield.Validator {
private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO, rewardType: RewardType): Yield.Validator {
val address = validatorDTO.address.asMandatory("address")
return Yield.Validator(
@ -142,7 +144,7 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
)
}
private fun createRewardInfo(validatorDTO: YieldDTO.ValidatorDTO, rewardType: Yield.RewardType): Yield.RewardInfo? {
private fun createRewardInfo(validatorDTO: YieldDTO.ValidatorDTO, rewardType: RewardType): RewardInfo? {
val aprOrApy = validatorDTO.apr
val commission = validatorDTO.commission
// gross = net / (1 - commission)
@ -162,17 +164,17 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
} else {
netApy
}
grossAprOrApy?.let { Yield.RewardInfo(rate = it, type = rewardType) }
grossAprOrApy?.let { RewardInfo(rate = it, type = rewardType) }
} catch (_: Exception) {
aprOrApy?.let { Yield.RewardInfo(rate = it, type = rewardType) }
aprOrApy?.let { RewardInfo(rate = it, type = rewardType) }
}
}
private fun convertRewardType(rewardTypeDTO: YieldDTO.RewardTypeDTO): Yield.RewardType {
private fun convertRewardType(rewardTypeDTO: YieldDTO.RewardTypeDTO): RewardType {
return when (rewardTypeDTO) {
YieldDTO.RewardTypeDTO.APY -> Yield.RewardType.APY
YieldDTO.RewardTypeDTO.APR -> Yield.RewardType.APR
else -> Yield.RewardType.UNKNOWN
YieldDTO.RewardTypeDTO.APY -> RewardType.APY
YieldDTO.RewardTypeDTO.APR -> RewardType.APR
else -> RewardType.UNKNOWN
}
}

View file

@ -8,7 +8,7 @@ import com.tangem.utils.converter.Converter
import java.math.BigDecimal
/**
* Converter from P2P Broadcast Transaction Response to Domain model
* Converter from P2PEthPool Broadcast Transaction Response to Domain model
*/
internal object P2PEthPoolBroadcastResultConverter : Converter<P2PEthPoolBroadcastResponse, P2PEthPoolBroadcastResult> {

View file

@ -6,7 +6,7 @@ import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.utils.converter.Converter
/**
* Converter from P2P Error Response to Domain StakingError
* Converter from P2PEthPool Error Response to Domain StakingError
*/
@Suppress("MagicNumber")
internal object P2PEthPoolErrorConverter : Converter<P2PEthPoolErrorResponse, StakingError> {

View file

@ -5,7 +5,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward
import com.tangem.utils.converter.Converter
/**
* Converter from P2P Reward Entry DTO to Domain model
* Converter from P2PEthPool Reward Entry DTO to Domain model
*/
internal object P2PEthPoolRewardConverter : Converter<P2PEthPoolRewardDTO, P2PEthPoolReward> {

View file

@ -4,17 +4,20 @@ import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountRespon
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitQueueDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitRequestDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolStakeDTO
import com.tangem.domain.staking.model.ethpool.*
import com.tangem.domain.models.staking.P2PEthPoolExitQueue
import com.tangem.domain.models.staking.P2PEthPoolExitRequest
import com.tangem.domain.models.staking.P2PEthPoolStake
import com.tangem.domain.models.staking.P2PEthPoolStakingAccount
import com.tangem.utils.converter.Converter
import org.joda.time.Instant
import kotlinx.datetime.Instant
/**
* Converter from P2P Account Info Response to Domain model
* Converts P2PEthPool Account API response to domain [P2PEthPoolStakingAccount].
*/
internal object P2PEthPoolAccountConverter : Converter<P2PEthPoolAccountResponse, P2PEthPoolAccount> {
internal object P2PEthPoolStakingAccountConverter : Converter<P2PEthPoolAccountResponse, P2PEthPoolStakingAccount> {
override fun convert(value: P2PEthPoolAccountResponse): P2PEthPoolAccount {
return P2PEthPoolAccount(
override fun convert(value: P2PEthPoolAccountResponse): P2PEthPoolStakingAccount {
return P2PEthPoolStakingAccount(
delegatorAddress = value.delegatorAddress,
vaultAddress = value.vaultAddress,
stake = convertStake(value.stake),
@ -24,26 +27,26 @@ internal object P2PEthPoolAccountConverter : Converter<P2PEthPoolAccountResponse
)
}
private fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake {
fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake {
return P2PEthPoolStake(
assets = dto.assets,
totalEarnedAssets = dto.totalEarnedAssets,
)
}
private fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PEthPoolExitQueue {
fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PEthPoolExitQueue {
return P2PEthPoolExitQueue(
total = dto.total.toBigDecimal(),
total = dto.total,
requests = dto.requests.map(::convertExitRequest),
)
}
private fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PEthPoolExitRequest {
fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PEthPoolExitRequest {
return P2PEthPoolExitRequest(
ticket = dto.ticket,
totalAssets = dto.totalAssets.toBigDecimal(),
timestamp = Instant.ofEpochSecond(dto.timestamp),
withdrawalTimestamp = Instant.ofEpochSecond(dto.withdrawalTimestamp),
totalAssets = dto.totalAssets,
timestamp = Instant.fromEpochMilliseconds(dto.timestamp),
withdrawalTimestamp = dto.withdrawalTimestamp?.let { Instant.fromEpochMilliseconds(it) },
isClaimable = dto.isClaimable,
)
}

View file

@ -0,0 +1,42 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.staking.model.StakingIntegrationID
import java.math.BigDecimal
/**
* Converts P2PEthPool API response to [StakingBalance].
*
* Uses [P2PEthPoolStakingAccountConverter] for account conversion to avoid duplication.
*/
internal object P2PEthPoolStakingBalanceConverter {
fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance {
val stakingId = StakingID(
integrationId = StakingIntegrationID.P2PEthPool.value,
address = response.delegatorAddress,
)
val account = P2PEthPoolStakingAccountConverter.convert(response)
val hasActivePosition = account.stake.assets > BigDecimal.ZERO ||
account.exitQueue.total > BigDecimal.ZERO ||
account.availableToWithdraw > BigDecimal.ZERO
return if (hasActivePosition) {
StakingBalance.Data.P2PEthPool(
stakingId = stakingId,
source = source,
account = account,
)
} else {
StakingBalance.Empty(
stakingId = stakingId,
source = source,
)
}
}
}

View file

@ -6,7 +6,7 @@ import com.tangem.utils.converter.Converter
import java.math.BigDecimal
/**
* Converter from P2P Unsigned Transaction DTO to Domain model
* Converter from P2PEthPool Unsigned Transaction DTO to Domain model
*/
internal object P2PEthPoolUnsignedTxConverter : Converter<P2PEthPoolUnsignedTxDTO, P2PEthPoolUnsignedTx> {

View file

@ -3,21 +3,26 @@ package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultDTO
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
import java.math.RoundingMode
/**
* Converter from P2P Vault DTO to Domain model
* Converter from P2PEthPool Vault DTO to Domain model
*/
internal object P2PEthPoolVaultConverter : Converter<P2PEthPoolVaultDTO, P2PEthPoolVault> {
private val HUNDRED = BigDecimal(100)
private const val DIVIDE_SCALE = 8
override fun convert(value: P2PEthPoolVaultDTO): P2PEthPoolVault {
return P2PEthPoolVault(
vaultAddress = value.vaultAddress,
displayName = value.displayName,
apy = value.apy.toBigDecimal(),
baseApy = value.baseApy.toBigDecimal(),
capacity = value.capacity.toBigDecimal(),
totalAssets = value.totalAssets.toBigDecimal(),
feePercent = value.feePercent.toBigDecimal(),
apy = value.apy.divide(HUNDRED, DIVIDE_SCALE, RoundingMode.HALF_UP),
baseApy = value.baseApy.divide(HUNDRED, DIVIDE_SCALE, RoundingMode.HALF_UP),
capacity = value.capacity,
totalAssets = value.totalAssets,
feePercent = value.feePercent,
isPrivate = value.isPrivate,
isGenesis = value.isGenesis,
isSmoothingPool = value.isSmoothingPool,

View file

@ -1,60 +0,0 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitQueueDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitRequestDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolStakeDTO
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.*
import com.tangem.domain.staking.model.StakingIntegrationID
import kotlinx.datetime.Instant
/** Converts P2P ETH Pool API response to [StakingBalance.Data.P2P] */
internal object P2PStakingBalanceConverter {
fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance.Data.P2P {
val stakingId = StakingID(
integrationId = StakingIntegrationID.P2P.EthereumPooled.value,
address = response.delegatorAddress,
)
val account = P2PStakingAccount(
delegatorAddress = response.delegatorAddress,
vaultAddress = response.vaultAddress,
stake = convertStake(response.stake),
availableToUnstake = response.availableToUnstake,
availableToWithdraw = response.availableToWithdraw,
exitQueue = convertExitQueue(response.exitQueue),
)
return StakingBalance.Data.P2P(
stakingId = stakingId,
source = source,
account = account,
)
}
private fun convertStake(dto: P2PEthPoolStakeDTO): P2PStake {
return P2PStake(
assets = dto.assets,
totalEarnedAssets = dto.totalEarnedAssets,
)
}
private fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PExitQueue {
return P2PExitQueue(
total = dto.total.toBigDecimal(),
requests = dto.requests.map(::convertExitRequest),
)
}
private fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PExitRequest {
return P2PExitRequest(
ticket = dto.ticket,
totalAssets = dto.totalAssets.toBigDecimal(),
timestamp = Instant.fromEpochSeconds(dto.timestamp),
withdrawalTimestamp = Instant.fromEpochSeconds(dto.withdrawalTimestamp),
isClaimable = dto.isClaimable,
)
}
}

View file

@ -1,92 +0,0 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.*
import com.tangem.domain.staking.model.ethpool.P2PEthPoolAccount
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import java.math.BigDecimal
/**
* tmp solution before facade implementation
*/
internal object P2PYieldBalanceConverter {
private const val ETH_DECIMALS = 18
private const val ETH_SYMBOL = "ETH"
private const val ETH_NAME = "Ethereum"
private const val ETH_COINGECKO_ID = "ethereum"
fun convert(
account: P2PEthPoolAccount,
vault: P2PEthPoolVault,
address: String,
source: StatusSource,
): YieldBalance {
val integrationId = "p2p-ethereum-pooled"
val stakingId = StakingID(
integrationId = integrationId,
address = address,
)
val balanceItems = buildBalanceItems(account, vault)
return if (balanceItems.isEmpty()) {
YieldBalance.Empty(stakingId = stakingId, source = source)
} else {
YieldBalance.Data(
stakingId = stakingId,
source = source,
balance = YieldBalanceItem(
items = balanceItems,
integrationId = integrationId,
),
)
}
}
private fun buildBalanceItems(account: P2PEthPoolAccount, vault: P2PEthPoolVault): List<BalanceItem> = buildList {
if (account.stake.assets > BigDecimal.ZERO) {
add(
createBalanceItem(
groupId = "p2p-staked",
amount = account.stake.assets,
type = BalanceType.STAKED,
validatorAddress = vault.vaultAddress,
),
)
}
}
private fun createBalanceItem(
groupId: String,
amount: BigDecimal,
type: BalanceType,
validatorAddress: String,
): BalanceItem {
return BalanceItem(
groupId = groupId,
token = createEthToken(),
type = type,
amount = amount,
rawCurrencyId = ETH_COINGECKO_ID,
validatorAddress = validatorAddress,
date = null,
pendingActions = emptyList(),
pendingActionsConstraints = emptyList(),
isPending = false,
)
}
private fun createEthToken(): YieldToken {
return YieldToken(
name = ETH_NAME,
network = NetworkType.ETHEREUM,
symbol = ETH_SYMBOL,
decimals = ETH_DECIMALS,
address = null,
coinGeckoId = ETH_COINGECKO_ID,
logoURI = null,
isPoints = false,
)
}
}

View file

@ -1,9 +1,9 @@
package com.tangem.data.staking.di
import androidx.datastore.core.DataStore
import com.tangem.data.staking.store.DefaultP2PBalancesStore
import com.tangem.data.staking.store.DefaultP2PEthPoolBalancesStore
import com.tangem.data.staking.store.DefaultStakingBalancesStore
import com.tangem.data.staking.store.P2PBalancesStore
import com.tangem.data.staking.store.P2PEthPoolBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
@ -38,11 +38,11 @@ internal object StakingBalanceSupplierModule {
@Provides
@Singleton
fun provideP2PBalancesStore(
fun provideP2PEthPoolBalancesStore(
persistenceStore: DataStore<Map<String, Set<P2PEthPoolAccountResponse>>>,
dispatchers: CoroutineDispatcherProvider,
): P2PBalancesStore {
return DefaultP2PBalancesStore(
): P2PEthPoolBalancesStore {
return DefaultP2PEthPoolBalancesStore(
runtimeStore = RuntimeSharedStore(),
persistenceStore = persistenceStore,
dispatchers = dispatchers,

View file

@ -75,13 +75,13 @@ internal object StakingDataModule {
@Provides
@Singleton
fun provideP2PEthPoolRepository(
p2pApi: P2PEthPoolApi,
p2pEthPoolApi: P2PEthPoolApi,
p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
dispatchers: CoroutineDispatcherProvider,
stakingFeatureToggles: StakingFeatureToggles,
): P2PEthPoolRepository {
return DefaultP2PEthPoolRepository(
p2pApi = p2pApi,
p2pEthPoolApi = p2pEthPoolApi,
p2pEthPoolVaultsStore = p2pEthPoolVaultsStore,
dispatchers = dispatchers,
stakingFeatureToggles = stakingFeatureToggles,

View file

@ -5,7 +5,7 @@ import arrow.core.left
import arrow.core.right
import arrow.core.toOption
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.staking.store.P2PBalancesStore
import com.tangem.data.staking.store.P2PEthPoolBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.common.response.ApiResponse
@ -24,7 +24,8 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.ethpool.P2PStakingConfig
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
@ -38,16 +39,16 @@ import javax.inject.Inject
/**
* Default implementation of [MultiStakingBalanceFetcher]
*
* Supports both StakeKit and P2P staking providers.
* Supports both StakeKit and P2PEthPool staking providers.
*
* @property userWalletsStore user wallets store
* @property stakingYieldsStore staking yields store
* @property stakingBalancesStore staking balances store (StakeKit)
* @property p2pBalancesStore P2P balances store
* @property stakeKitApi stake kit API
* @property p2pApi P2P ETH Pool API
* @property p2pVaultsStore P2P vaults store
* @property dispatchers dispatchers
* @property userWalletsStore user wallets store
* @property stakingYieldsStore staking yields store
* @property stakingBalancesStore staking balances store (StakeKit)
* @property p2PEthPoolBalancesStore P2PEthPool balances store
* @property stakeKitApi stake kit API
* @property p2pEthPoolApi P2PEthPool API
* @property p2pEthPoolVaultsStore P2PEthPool vaults store
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
@ -56,10 +57,10 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val stakingYieldsStore: StakingYieldsStore,
private val stakingBalancesStore: StakingBalancesStore,
private val p2pBalancesStore: P2PBalancesStore,
private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore,
private val stakeKitApi: StakeKitApi,
private val p2pApi: P2PEthPoolApi,
private val p2pVaultsStore: P2PEthPoolVaultsStore,
private val p2pEthPoolApi: P2PEthPoolApi,
private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiStakingBalanceFetcher {
@ -75,7 +76,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
return it.left()
}
val (stakeKitIds, p2pIds) = stakingIds.partition { stakingId ->
val (stakeKitIds, p2pEthPoolIds) = stakingIds.partition { stakingId ->
val stakingIntegrationID = StakingIntegrationID.entries.find {
it.value == stakingId.integrationId
}
@ -86,7 +87,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
"""
Staking IDs to fetch:
- StakeKit: ${stakeKitIds.joinToString()}
- P2P: ${p2pIds.joinToString()}
- P2PEthPool: ${p2pEthPoolIds.joinToString()}
""".trimIndent(),
)
@ -96,8 +97,8 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
launch { fetchStakeKitBalances(params.userWalletId, stakeKitIds.toSet()) }
}
if (p2pIds.isNotEmpty()) {
launch { fetchP2PBalances(params.userWalletId, p2pIds.toSet()) }
if (p2pEthPoolIds.isNotEmpty()) {
launch { fetchP2PBalances(params.userWalletId, p2pEthPoolIds.toSet()) }
}
}
}
@ -110,8 +111,11 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
stakingIds = stakeKitIds.toSet(),
)
}
if (p2pIds.isNotEmpty()) {
p2pBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = p2pIds.toSet())
if (p2pEthPoolIds.isNotEmpty()) {
p2PEthPoolBalancesStore.storeError(
userWalletId = params.userWalletId,
stakingIds = p2pEthPoolIds.toSet(),
)
}
}
}
@ -128,12 +132,12 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
}
private suspend fun fetchP2PBalances(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
p2pBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
p2PEthPoolBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
val vaults = runSuspendCatching { p2pVaultsStore.getSync() }.getOrNull().orEmpty()
val vaults = runSuspendCatching { p2pEthPoolVaultsStore.getSync() }.getOrNull().orEmpty()
if (vaults.isEmpty()) {
Timber.w("No P2P vaults available for $userWalletId")
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
Timber.w("No P2PEthPool vaults available for $userWalletId, storing empty balances")
p2PEthPoolBalancesStore.storeEmpty(userWalletId = userWalletId, stakingIds = stakingIds)
return
}
@ -143,59 +147,17 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
private suspend fun fetchFromP2P(
userWalletId: UserWalletId,
stakingIds: Set<StakingID>,
vaults: List<com.tangem.domain.staking.model.ethpool.P2PEthPoolVault>,
vaults: List<P2PEthPoolVault>,
) {
safeApiCall(
call = {
val addresses = stakingIds.map { it.address }.toSet()
val responses = fetchP2PAccountResponses(vaults = vaults, addresses = addresses)
val responses = mutableSetOf<P2PEthPoolAccountResponse>()
for (vault in vaults) {
for (address in addresses) {
runSuspendCatching {
val response = p2pApi.getAccountInfo(
network = P2PStakingConfig.activeNetwork.value,
delegatorAddress = address,
vaultAddress = vault.vaultAddress,
)
when (response) {
is ApiResponse.Success -> {
val data = response.data
if (data.error != null) {
Timber.w(
"P2P API returned error for vault ${vault.vaultAddress}, " +
"address $address: ${data.error ?: "error"}",
)
} else {
val result = requireNotNull(data.result) {
"Result is null in successful response"
}
responses.add(result)
}
}
is ApiResponse.Error -> {
Timber.w(
response.cause,
"Failed to fetch P2P balance for vault ${vault.vaultAddress}, " +
"address $address",
)
}
}
}.onFailure { error ->
Timber.w(
error,
"Failed to fetch P2P balance for vault ${vault.vaultAddress}, address $address",
)
}
}
}
Timber.i("Successfully fetched ${responses.size} P2P balances for $userWalletId")
Timber.i("Successfully fetched ${responses.size} P2PEthPool balances for $userWalletId")
if (responses.isNotEmpty()) {
p2pBalancesStore.storeActual(userWalletId = userWalletId, values = responses)
p2PEthPoolBalancesStore.storeActual(userWalletId = userWalletId, values = responses)
val missingStakingIds = stakingIds.filter { stakingId ->
responses.none { response ->
@ -205,23 +167,76 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
if (missingStakingIds.isNotEmpty()) {
Timber.i("Missing responses for ${missingStakingIds.size} staking IDs: $missingStakingIds")
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = missingStakingIds.toSet())
p2PEthPoolBalancesStore.storeError(
userWalletId = userWalletId,
stakingIds = missingStakingIds.toSet(),
)
}
} else {
Timber.i("No P2P responses received for $userWalletId")
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
Timber.i("No P2PEthPool responses received for $userWalletId")
p2PEthPoolBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
}
},
onError = { throwable ->
Timber.e(throwable, "Unable to fetch P2P balances $userWalletId")
Timber.e(throwable, "Unable to fetch P2PEthPool balances $userWalletId")
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
p2PEthPoolBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
throw throwable
},
)
}
private suspend fun fetchP2PAccountResponses(
vaults: List<P2PEthPoolVault>,
addresses: Set<String>,
): Set<P2PEthPoolAccountResponse> {
val responses = mutableSetOf<P2PEthPoolAccountResponse>()
for (vault in vaults) {
for (address in addresses) {
runSuspendCatching {
val response = p2pEthPoolApi.getAccountInfo(
network = P2PEthPoolStakingConfig.activeNetwork.value,
delegatorAddress = address,
vaultAddress = vault.vaultAddress,
)
when (response) {
is ApiResponse.Success -> {
val data = response.data
if (data.error != null) {
Timber.w(
"P2PEthPool API returned error for vault ${vault.vaultAddress}, " +
"address $address: ${data.error ?: "error"}",
)
} else {
val result = requireNotNull(data.result) {
"Result is null in successful response"
}
responses.add(result)
}
}
is ApiResponse.Error -> {
Timber.w(
response.cause,
"Failed to fetch P2PEthPool balance for vault ${vault.vaultAddress}, " +
"address $address",
)
}
}
}.onFailure { error ->
Timber.w(
error,
"Failed to fetch P2PEthPool balance for vault ${vault.vaultAddress}, address $address",
)
}
}
}
return responses
}
private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) {
val maybeUserWallet = userWalletsStore.getSyncOrNull(key = userWalletId).toOption()

View file

@ -2,7 +2,7 @@ package com.tangem.data.staking.multi
import arrow.core.Option
import arrow.core.some
import com.tangem.data.staking.store.P2PBalancesStore
import com.tangem.data.staking.store.P2PEthPoolBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
@ -19,11 +19,11 @@ import kotlinx.coroutines.flow.onEmpty
/**
* Default implementation of [MultiStakingBalanceProducer]
*
* Combines staking balances from both StakeKit and P2P providers.
* Combines staking balances from both StakeKit and P2PEthPool providers.
*
* @property params params
* @property stakingBalancesStore StakeKit staking balances store
* @property p2pBalancesStore P2P balances store
* @property p2PEthPoolBalancesStore P2PEthPool balances store
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.onEmpty
internal class DefaultMultiStakingBalanceProducer @AssistedInject constructor(
@Assisted val params: MultiStakingBalanceProducer.Params,
private val stakingBalancesStore: StakingBalancesStore,
private val p2pBalancesStore: P2PBalancesStore,
private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiStakingBalanceProducer {
@ -39,10 +39,10 @@ internal class DefaultMultiStakingBalanceProducer @AssistedInject constructor(
override fun produce(): Flow<Set<StakingBalance>> {
val stakeKitFlow = stakingBalancesStore.get(userWalletId = params.userWalletId)
val p2pFlow = p2pBalancesStore.get(userWalletId = params.userWalletId)
val p2pEthPoolFlow = p2PEthPoolBalancesStore.get(userWalletId = params.userWalletId)
return combine(stakeKitFlow, p2pFlow) { stakeKitBalances, p2pBalances ->
stakeKitBalances + p2pBalances
return combine(stakeKitFlow, p2pEthPoolFlow) { stakeKitBalances, p2pEthPoolBalances ->
stakeKitBalances + p2pEthPoolBalances
}
.distinctUntilChanged()
.onEmpty { emit(value = hashSetOf()) }

View file

@ -1,29 +1,35 @@
package com.tangem.data.staking.store
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* Store for P2P ETH Pool staking balances
* Base interface for staking balances stores.
*
* Defines common read/query operations shared by all staking provider stores.
*/
interface P2PBalancesStore {
interface BaseStakingBalancesStore {
/** Get flow of staking balances for a wallet */
fun get(userWalletId: UserWalletId): Flow<Set<StakingBalance>>
/** Get a single staking balance synchronously */
suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance?
/** Get all staking balances for a wallet synchronously */
suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<StakingBalance>?
/** Refresh a single staking balance from cache */
suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID)
/** Refresh multiple staking balances from cache */
suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
suspend fun storeActual(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>)
/** Store error state for staking balances */
suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
/** Clear staking balances */
suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
}

View file

@ -1,7 +1,7 @@
package com.tangem.data.staking.store
import androidx.datastore.core.DataStore
import com.tangem.data.staking.converters.ethpool.P2PStakingBalanceConverter
import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingBalanceConverter
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
@ -20,22 +20,22 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
internal typealias WalletIdWithP2PStakingBalances = Map<UserWalletId, Set<StakingBalance>>
internal typealias WalletIdWithP2PResponses = Map<String, Set<P2PEthPoolAccountResponse>>
internal typealias WalletIdWithP2PEthPoolResponses = Map<String, Set<P2PEthPoolAccountResponse>>
/**
* Default implementation of [P2PBalancesStore]
* Default implementation of [P2PEthPoolBalancesStore]
*
* Stores P2P ETH Pool staking balances.
* Stores P2PEthPool staking balances.
*
* @property runtimeStore runtime store
* @property persistenceStore persistence store
* @param dispatchers coroutine dispatchers
*/
internal class DefaultP2PBalancesStore(
internal class DefaultP2PEthPoolBalancesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithP2PStakingBalances>,
private val persistenceStore: DataStore<WalletIdWithP2PResponses>,
private val persistenceStore: DataStore<WalletIdWithP2PEthPoolResponses>,
dispatchers: CoroutineDispatcherProvider,
) : P2PBalancesStore {
) : P2PEthPoolBalancesStore {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
@ -47,7 +47,7 @@ internal class DefaultP2PBalancesStore(
value = cachedData.map { (stringWalletId, responses) ->
val key = UserWalletId(stringWalletId)
val value = responses.map { response ->
P2PStakingBalanceConverter.convert(
P2PEthPoolStakingBalanceConverter.convert(
response = response,
source = StatusSource.CACHE,
)
@ -90,6 +90,15 @@ internal class DefaultP2PBalancesStore(
}
}
override suspend fun storeEmpty(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
updateInRuntime(
userWalletId = userWalletId,
stakingIds = stakingIds,
ifNotFound = ::createEmptyStakingBalance,
update = { it.copySealed(source = StatusSource.ACTUAL) },
)
}
override suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
updateInRuntime(
userWalletId = userWalletId,
@ -108,7 +117,7 @@ internal class DefaultP2PBalancesStore(
private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>) {
val newBalances = values.map { response ->
P2PStakingBalanceConverter.convert(
P2PEthPoolStakingBalanceConverter.convert(
response = response,
source = StatusSource.ACTUAL,
)
@ -152,7 +161,7 @@ internal class DefaultP2PBalancesStore(
current.toMutableMap().apply {
this[userWalletId.stringValue] = this[userWalletId.stringValue].orEmpty()
.filterNot { response ->
StakingIntegrationID.P2P.EthereumPooled.value in integrationIds
StakingIntegrationID.P2PEthPool.value in integrationIds
}
.toSet()
}
@ -187,5 +196,8 @@ internal class DefaultP2PBalancesStore(
}
}
private fun createEmptyStakingBalance(id: StakingID): StakingBalance =
StakingBalance.Empty(stakingId = id, source = StatusSource.ACTUAL)
private fun createErrorStakingBalance(id: StakingID): StakingBalance = StakingBalance.Error(stakingId = id)
}

View file

@ -0,0 +1,19 @@
package com.tangem.data.staking.store
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
/**
* Store for P2PEthPool staking balances.
*
* Extends [BaseStakingBalancesStore] with P2PEthPool-specific storage operations.
*/
interface P2PEthPoolBalancesStore : BaseStakingBalancesStore {
/** Store actual P2PEthPool account balances */
suspend fun storeActual(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>)
/** Store empty state for accounts with no active positions */
suspend fun storeEmpty(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
}

View file

@ -1,27 +1,15 @@
package com.tangem.data.staking.store
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/** Store of StakeKit [StakingBalance] */
interface StakingBalancesStore {
fun get(userWalletId: UserWalletId): Flow<Set<StakingBalance>>
suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance?
suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<StakingBalance>?
suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID)
suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
/**
* Store for StakeKit staking balances.
*
* Extends [BaseStakingBalancesStore] with StakeKit-specific storage operations.
*/
interface StakingBalancesStore : BaseStakingBalancesStore {
/** Store actual StakeKit yield balances */
suspend fun storeActual(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>)
suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
}

View file

@ -1,6 +1,6 @@
package com.tangem.data.staking
import com.tangem.data.staking.converters.ethpool.P2PStakingBalanceConverter
import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingBalanceConverter
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.token.converter.StakingBalanceConverter
@ -11,8 +11,10 @@ internal fun YieldBalanceWrapperDTO.toDomain(source: StatusSource = StatusSource
return StakingBalanceConverter(isCached = source == StatusSource.CACHE).convert(this)!!
}
internal fun P2PEthPoolAccountResponse.toDomain(source: StatusSource = StatusSource.CACHE): StakingBalance.Data.P2P {
return P2PStakingBalanceConverter.convert(
internal fun P2PEthPoolAccountResponse.toDomain(
source: StatusSource = StatusSource.CACHE,
): StakingBalance {
return P2PEthPoolStakingBalanceConverter.convert(
response = this,
source = source,
)

View file

@ -4,7 +4,7 @@ import arrow.core.toOption
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.data.staking.MockYieldDTOFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.data.staking.store.P2PBalancesStore
import com.tangem.data.staking.store.P2PEthPoolBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.common.response.ApiResponse
@ -36,19 +36,19 @@ internal class DefaultMultiStakingBalanceFetcherTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val stakingYieldsStore: StakingYieldsStore = mockk()
private val stakingBalancesStore: StakingBalancesStore = mockk(relaxUnitFun = true)
private val p2pBalancesStore: P2PBalancesStore = mockk(relaxUnitFun = true)
private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore = mockk(relaxUnitFun = true)
private val stakeKitApi: StakeKitApi = mockk()
private val p2pApi: P2PEthPoolApi = mockk()
private val p2pVaultsStore: P2PEthPoolVaultsStore = mockk()
private val p2pEthPoolApi: P2PEthPoolApi = mockk()
private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore = mockk()
private val fetcher = DefaultMultiStakingBalanceFetcher(
userWalletsStore = userWalletsStore,
stakingYieldsStore = stakingYieldsStore,
stakingBalancesStore = stakingBalancesStore,
p2pBalancesStore = p2pBalancesStore,
p2PEthPoolBalancesStore = p2PEthPoolBalancesStore,
stakeKitApi = stakeKitApi,
p2pApi = p2pApi,
p2pVaultsStore = p2pVaultsStore,
p2pEthPoolApi = p2pEthPoolApi,
p2pEthPoolVaultsStore = p2pEthPoolVaultsStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)

View file

@ -3,7 +3,7 @@ package com.tangem.data.staking.multi
import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.data.staking.MockP2PEthPoolAccountResponseFactory
import com.tangem.data.staking.store.P2PBalancesStore
import com.tangem.data.staking.store.P2PEthPoolBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.data.staking.toDomain
import com.tangem.domain.models.StatusSource
@ -28,13 +28,13 @@ internal class DefaultMultiStakingBalanceProducerTest {
private val params = MultiStakingBalanceProducer.Params(userWalletId = UserWalletId("011"))
private val stakingBalancesStore = mockk<StakingBalancesStore>()
private val p2pBalancesStore = mockk<P2PBalancesStore>()
private val p2PEthPoolBalancesStore = mockk<P2PEthPoolBalancesStore>()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val producer = DefaultMultiStakingBalanceProducer(
params = params,
stakingBalancesStore = stakingBalancesStore,
p2pBalancesStore = p2pBalancesStore,
p2PEthPoolBalancesStore = p2PEthPoolBalancesStore,
dispatchers = dispatchers,
)
@ -48,13 +48,13 @@ internal class DefaultMultiStakingBalanceProducerTest {
val networksStatusesFlow = flowOf(balances)
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produce()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
val values = getEmittedValues(flow = actual)
@ -67,13 +67,13 @@ internal class DefaultMultiStakingBalanceProducerTest {
val networksStatusesFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2)
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produce()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
// first emit
val balances = setOf(
@ -108,13 +108,13 @@ internal class DefaultMultiStakingBalanceProducerTest {
val networksStatusesFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2)
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produce()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
// first emit
val wrappers = setOf(
@ -157,13 +157,13 @@ internal class DefaultMultiStakingBalanceProducerTest {
.buffer(capacity = 5)
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produceWithFallback()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
val values1 = getEmittedValues(flow = actual)
@ -181,13 +181,13 @@ internal class DefaultMultiStakingBalanceProducerTest {
@Test
fun `test that flow is empty`() = runTest {
every { stakingBalancesStore.get(params.userWalletId) } returns emptyFlow()
every { p2pBalancesStore.get(params.userWalletId) } returns emptyFlow()
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns emptyFlow()
val actual = producer.produce()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
val values = getEmittedValues(flow = actual)
@ -198,53 +198,53 @@ internal class DefaultMultiStakingBalanceProducerTest {
@Test
fun `test that StakeKit and P2P balances are combined`() = runTest {
val stakeKitBalances = createStakeKitBalances()
val p2pBalances = createP2PBalances()
val p2pEthPoolBalances = createP2PEthPoolBalances()
every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances)
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(p2pBalances)
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(p2pEthPoolBalances)
val actual = producer.produce()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values.first()).isEqualTo(stakeKitBalances + p2pBalances)
Truth.assertThat(values.first()).isEqualTo(stakeKitBalances + p2pEthPoolBalances)
}
@Test
fun `test that P2P balances are updated independently from StakeKit`() = runTest {
val stakeKitBalances = createStakeKitBalancesWithTonOnly()
val p2pFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2)
val p2pEthPoolFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2)
every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances)
every { p2pBalancesStore.get(params.userWalletId) } returns p2pFlow
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns p2pEthPoolFlow
val actual = producer.produce()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
// first emit - empty P2P
p2pFlow.emit(emptySet())
// first emit - empty P2PEthPool
p2pEthPoolFlow.emit(emptySet())
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1.first()).isEqualTo(stakeKitBalances)
// second emit - with P2P balance
val p2pBalances = createP2PBalances()
p2pFlow.emit(p2pBalances)
// second emit - with P2PEthPool balance
val p2pEthPoolBalances = createP2PEthPoolBalances()
p2pEthPoolFlow.emit(p2pEthPoolBalances)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2.last()).isEqualTo(stakeKitBalances + p2pBalances)
Truth.assertThat(values2.last()).isEqualTo(stakeKitBalances + p2pEthPoolBalances)
}
private companion object {
@ -255,7 +255,7 @@ internal class DefaultMultiStakingBalanceProducerTest {
address = "0x1",
)
val p2pEthereumId = StakingID(
integrationId = StakingIntegrationID.P2P.EthereumPooled.value,
integrationId = StakingIntegrationID.P2PEthPool.value,
address = "0x5aa711F440Eb6d4361148bBD89d03464628ace84",
)
@ -272,7 +272,7 @@ internal class DefaultMultiStakingBalanceProducerTest {
)
}
fun createP2PBalances(): Set<StakingBalance> {
fun createP2PEthPoolBalances(): Set<StakingBalance> {
return setOf(
MockP2PEthPoolAccountResponseFactory.createWithBalance(stakingId = p2pEthereumId).toDomain(
source = StatusSource.ACTUAL,

View file

@ -75,11 +75,11 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
swapTxType = swapTxType,
)
val providers = expressRepository.getProviders(
val expressProviders = expressRepository.getFilteredProviders(
userWallet = userWallet,
filterProviderTypes = filterProviderTypes,
)
val expressProviders = providers.associateBy(ExpressProvider::providerId)
swapTxType = swapTxType,
).associateBy(ExpressProvider::providerId)
allPairs.map { pair ->
async {
@ -125,11 +125,11 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
swapTxType = swapTxType,
)
val providers = expressRepository.getProviders(
val mappedProviders = expressRepository.getFilteredProviders(
userWallet = userWallet,
filterProviderTypes = filterProviderTypes,
)
val mappedProviders = providers.associateBy(ExpressProvider::providerId)
swapTxType = swapTxType,
).associateBy(ExpressProvider::providerId)
allPairs.map { pair ->
async {
@ -434,4 +434,20 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
true
}
}
}
private suspend fun ExpressRepository.getFilteredProviders(
userWallet: UserWallet,
filterProviderTypes: List<ExpressProviderType>,
swapTxType: SwapTxType,
): List<ExpressProvider> {
return getProviders(
userWallet = userWallet,
filterProviderTypes = filterProviderTypes,
).let { allProviders ->
when (swapTxType) {
SwapTxType.SendWithSwap -> allProviders.filterNot { it.isExchangeOnlyWithinSingleAddress }
SwapTxType.Swap -> allProviders
}
}
}