Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-03 11:52:19 +01:00
parent c7941ee1f7
commit 2b327fd3e9
32 changed files with 853 additions and 0 deletions

1
data/earn/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,44 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.earn"
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Project - Data
implementation(projects.data.common)
// endregion
// region Project - Domain
implementation(projects.domain.earn)
implementation(projects.domain.common)
implementation(projects.domain.account.status)
// endregion
// region Project - Libs
implementation(projects.libs.blockchainSdk)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Other libraries
implementation(deps.androidx.datastore)
implementation(deps.moshi.kotlin)
implementation(deps.timber)
implementation(tangemDeps.blockchain)
// endregion
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.earn
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.domain.earn.EarnErrorResolver
import com.tangem.domain.models.earn.EarnError
internal class DefaultEarnErrorResolver : EarnErrorResolver {
override fun resolve(throwable: Throwable?): EarnError {
return when (throwable) {
is ApiResponseError.HttpException -> {
EarnError.HttpError(
code = throwable.code.numericCode,
message = throwable.message.orEmpty(),
)
}
else -> EarnError.NotHttpError
}
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.data.earn.converter
import com.tangem.datasource.api.tangemTech.models.EarnResponse
import com.tangem.domain.models.earn.EarnToken
import com.tangem.utils.converter.Converter
internal object EarnTokenConverter : Converter<EarnResponse, EarnToken> {
override fun convert(value: EarnResponse): EarnToken {
return EarnToken(
apy = value.apy,
networkId = value.networkId,
rewardType = value.rewardType,
type = value.type,
tokenId = value.token.id,
tokenSymbol = value.token.symbol,
tokenName = value.token.name,
tokenAddress = value.token.address,
)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.data.earn.datastore
import arrow.core.Either
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.models.earn.EarnNetworks
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class EarnNetworksStore @Inject constructor() :
RuntimeStateStore<EarnNetworks> by RuntimeStateStore(
defaultValue = Either.Right(emptyList()),
)

View file

@ -0,0 +1,13 @@
package com.tangem.data.earn.datastore
import arrow.core.Either
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.models.earn.EarnTopToken
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class EarnTopTokensStore @Inject constructor() :
RuntimeStateStore<EarnTopToken> by RuntimeStateStore(
defaultValue = Either.Right(emptyList()),
)

View file

@ -0,0 +1,47 @@
package com.tangem.data.earn.di
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.earn.DefaultEarnErrorResolver
import com.tangem.data.earn.repository.DefaultEarnRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.data.earn.datastore.EarnNetworksStore
import com.tangem.data.earn.datastore.EarnTopTokensStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.earn.EarnErrorResolver
import com.tangem.domain.earn.repository.EarnRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object EarnDataModule {
@Provides
fun provideEarnErrorResolver(): EarnErrorResolver = DefaultEarnErrorResolver()
@Provides
@Singleton
fun provideEarnRepository(
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
userWalletsListRepository: UserWalletsListRepository,
cryptoCurrencyFactory: CryptoCurrencyFactory,
earnNetworksStore: EarnNetworksStore,
earnTopTokensStore: EarnTopTokensStore,
earnErrorResolver: EarnErrorResolver,
): EarnRepository {
return DefaultEarnRepository(
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
userWalletsListRepository = userWalletsListRepository,
cryptoCurrencyFactory = cryptoCurrencyFactory,
earnNetworksStore = earnNetworksStore,
earnTopTokensStore = earnTopTokensStore,
earnErrorResolver = earnErrorResolver,
)
}
}

View file

@ -0,0 +1,157 @@
package com.tangem.data.earn.repository
import arrow.core.Either
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.earn.converter.EarnTokenConverter
import com.tangem.data.earn.datastore.EarnNetworksStore
import com.tangem.data.earn.datastore.EarnTopTokensStore
import com.tangem.data.earn.repository.batch.EarnTokensBatchFetcher
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.EarnResponse
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.earn.EarnErrorResolver
import com.tangem.domain.earn.model.EarnTokensBatchFlow
import com.tangem.domain.earn.model.EarnTokensBatchingContext
import com.tangem.domain.earn.repository.EarnRepository
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.EarnNetwork
import com.tangem.domain.models.earn.EarnNetworks
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isLocked
import com.tangem.pagination.BatchListSource
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import kotlinx.coroutines.flow.Flow
@Suppress("LongParameterList")
internal class DefaultEarnRepository(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val userWalletsListRepository: UserWalletsListRepository,
private val cryptoCurrencyFactory: CryptoCurrencyFactory,
private val earnNetworksStore: EarnNetworksStore,
private val earnTopTokensStore: EarnTopTokensStore,
private val earnErrorResolver: EarnErrorResolver,
) : EarnRepository {
override fun getEarnTokensBatchFlow(context: EarnTokensBatchingContext, batchSize: Int): EarnTokensBatchFlow {
val batchFetcher = EarnTokensBatchFetcher(
tangemTechApi = tangemTechApi,
batchSize = batchSize,
userWalletsListRepository = userWalletsListRepository,
cryptoCurrencyFactory = cryptoCurrencyFactory,
)
return BatchListSource(
fetchDispatcher = dispatchers.io,
context = context,
generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: INITIAL_BATCH_KEY },
batchFetcher = batchFetcher,
).toBatchFlow()
}
override suspend fun fetchEarnNetworks(type: String) {
runCatching(dispatchers.io) {
val response = tangemTechApi.getEarnNetworks(type = type).getOrThrow()
val items = response.items.map { dto ->
EarnNetwork(
networkId = dto.networkId,
isAdded = false,
)
}
earnNetworksStore.store(Either.Right(items))
}.onFailure { error ->
val earnError = earnErrorResolver.resolve(error)
earnNetworksStore.store(Either.Left(earnError))
}
}
override fun observeEarnNetworks(): Flow<EarnNetworks> {
return earnNetworksStore.get()
}
override suspend fun fetchTopEarnTokens(limit: Int) {
runCatching(dispatchers.io) {
val response = tangemTechApi.getEarnTokens(
isForEarn = true,
limit = limit,
).getOrThrow()
val userWallet = userWalletsListRepository
.selectedUserWallet
.value
.takeIf { wallet -> wallet?.isLocked?.not() == true }
?: return@runCatching
val items = response.items.mapNotNull { dto ->
val earnToken = EarnTokenConverter.convert(dto)
createCryptoCurrencyForEarnToken(
cryptoCurrencyFactory = cryptoCurrencyFactory,
userWallet = userWallet,
earnToken = dto,
)?.let { cryptoCurrency ->
EarnTokenWithCurrency(
earnToken = earnToken,
cryptoCurrency = cryptoCurrency,
)
}
}
earnTopTokensStore.store(Either.Right(items))
}.onFailure { error ->
val earnError = earnErrorResolver.resolve(error)
earnTopTokensStore.store(Either.Left(earnError))
}
}
override fun observeTopEarnTokens(): Flow<EarnTopToken> {
return earnTopTokensStore.get()
}
companion object {
internal const val FIRST_PAGE = 1
private const val INITIAL_BATCH_KEY = 0
}
}
/*
use this CryptoCurrency only for creating cryptoCurrencyIcon!! because accountIndex = DerivationIndex.Main and it
doesn't provide real all data.
*/
internal fun createCryptoCurrencyForEarnToken(
userWallet: UserWallet,
earnToken: EarnResponse,
cryptoCurrencyFactory: CryptoCurrencyFactory,
): CryptoCurrency? {
val blockchain = Blockchain.fromNetworkId(earnToken.networkId) ?: Blockchain.Unknown
return if (earnToken.token.address.isEmpty()) {
cryptoCurrencyFactory.createCoin(
blockchain = blockchain,
extraDerivationPath = null,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
} else {
val network = cryptoCurrencyFactory.networkFactory.create(
blockchain = blockchain,
extraDerivationPath = null,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
) ?: return null
cryptoCurrencyFactory.createToken(
network = network,
rawId = CryptoCurrency.RawID(earnToken.token.id),
name = earnToken.token.name,
symbol = earnToken.token.symbol,
decimals = blockchain.decimals(),
contractAddress = earnToken.token.address,
)
}
}

View file

@ -0,0 +1,139 @@
package com.tangem.data.earn.repository.batch
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.earn.converter.EarnTokenConverter
import com.tangem.data.earn.repository.DefaultEarnRepository.Companion.FIRST_PAGE
import com.tangem.data.earn.repository.createCryptoCurrencyForEarnToken
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.EarnResponse
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.earn.model.EarnTokensListConfig
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.exception.EndOfPaginationException
import com.tangem.pagination.fetcher.BatchFetcher
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.flow.MutableStateFlow
internal class EarnTokensBatchFetcher(
private val tangemTechApi: TangemTechApi,
private val batchSize: Int,
private val userWalletsListRepository: UserWalletsListRepository,
private val cryptoCurrencyFactory: CryptoCurrencyFactory,
) : BatchFetcher<EarnTokensListConfig, List<EarnTokenWithCurrency>> {
private val state: MutableStateFlow<EarnTokensPaginationState?> = MutableStateFlow(null)
override suspend fun fetchFirst(
requestParams: EarnTokensListConfig,
): BatchFetchResult<List<EarnTokenWithCurrency>> {
return runSuspendCatching {
loadPage(
page = FIRST_PAGE,
params = requestParams,
limit = batchSize,
)
}.fold(
onSuccess = { result ->
state.value = result.state
result.batchResult
},
onFailure = { throwable -> BatchFetchResult.Error(throwable) },
)
}
override suspend fun fetchNext(
overrideRequestParams: EarnTokensListConfig?,
lastResult: BatchFetchResult<List<EarnTokenWithCurrency>>,
): BatchFetchResult<List<EarnTokenWithCurrency>> {
val currentState = state.value ?: return BatchFetchResult.Error(
IllegalStateException("fetchFirst must be called"),
)
if (lastResult is BatchFetchResult.Success && lastResult.last && overrideRequestParams == null) {
return BatchFetchResult.Error(EndOfPaginationException())
}
val params = overrideRequestParams ?: currentState.params
val shouldReset = overrideRequestParams != null && overrideRequestParams != currentState.params
val pageToLoad = if (shouldReset) FIRST_PAGE else currentState.nextPage
return runSuspendCatching {
loadPage(
page = pageToLoad,
params = params,
limit = batchSize,
)
}.fold(
onSuccess = { result ->
state.value = result.state
result.batchResult
},
onFailure = { throwable -> BatchFetchResult.Error(throwable) },
)
}
private suspend fun loadPage(page: Int, params: EarnTokensListConfig, limit: Int): PageLoadResult {
fun createEarnTokenWithCurrency(userWallet: UserWallet, dto: EarnResponse): EarnTokenWithCurrency? {
val earnToken = EarnTokenConverter.convert(dto)
val cryptoCurrency = createCryptoCurrencyForEarnToken(
cryptoCurrencyFactory = cryptoCurrencyFactory,
userWallet = userWallet,
earnToken = dto,
)
return cryptoCurrency?.let {
EarnTokenWithCurrency(
earnToken = earnToken,
cryptoCurrency = cryptoCurrency,
)
}
}
val response = tangemTechApi.getEarnTokens(
isForEarn = params.isForEarn,
page = page.toString(),
limit = limit,
type = params.type,
network = params.network,
).getOrThrow()
val userWallet = userWalletsListRepository.selectedUserWallet.value
val items = if (userWallet == null) {
emptyList()
} else {
response.items.mapNotNull { dto ->
createEarnTokenWithCurrency(userWallet, dto)
}
}
val isLast = items.size < limit
val batchResult = BatchFetchResult.Success(
data = items,
empty = items.isEmpty(),
last = isLast,
)
return PageLoadResult(
batchResult = batchResult,
state = EarnTokensPaginationState(
nextPage = response.meta.page + 1,
params = params,
),
)
}
private data class PageLoadResult(
val batchResult: BatchFetchResult.Success<List<EarnTokenWithCurrency>>,
val state: EarnTokensPaginationState,
)
private data class EarnTokensPaginationState(
val nextPage: Int,
val params: EarnTokensListConfig,
)
}