diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e6002f64e0..a1514e89d5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -167,6 +167,7 @@ dependencies { implementation(projects.domain.blockaid) implementation(projects.domain.hotWallet) implementation(projects.domain.news) + implementation(projects.domain.earn) implementation(projects.common) implementation(projects.common.routing) @@ -220,6 +221,7 @@ dependencies { implementation(projects.data.yieldSupply) implementation(projects.data.hotWallet) implementation(projects.data.news) + implementation(projects.data.earn) /** Features */ implementation(projects.features.referral.impl) diff --git a/app/src/main/java/com/tangem/tap/di/domain/EarnDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/EarnDomainModule.kt new file mode 100644 index 0000000000..55a22513b5 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/EarnDomainModule.kt @@ -0,0 +1,48 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.earn.repository.EarnRepository +import com.tangem.domain.earn.usecase.* +import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object EarnDomainModule { + + @Provides + fun provideFetchEarnNetworksUseCase(repository: EarnRepository): FetchEarnNetworksUseCase { + return FetchEarnNetworksUseCase(repository) + } + + @Provides + fun provideManageEarnNetworksUseCase( + earnRepository: EarnRepository, + userWalletsListRepository: UserWalletsListRepository, + multiNetworkStatusSupplier: MultiNetworkStatusSupplier, + ): GetEarnNetworksUseCase { + return GetEarnNetworksUseCase( + earnRepository = earnRepository, + userWalletsListRepository = userWalletsListRepository, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + ) + } + + @Provides + fun provideFetchTopEarnTokensUseCase(repository: EarnRepository): FetchTopEarnTokensUseCase { + return FetchTopEarnTokensUseCase(repository) + } + + @Provides + fun provideManageTopEarnTokensUseCase(repository: EarnRepository): GetTopEarnTokensUseCase { + return GetTopEarnTokensUseCase(repository) + } + + @Provides + fun provideGetEarnTokensBatchFlowUseCase(repository: EarnRepository): GetEarnTokensBatchFlowUseCase { + return GetEarnTokensBatchFlowUseCase(repository) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index a3b38cb1f0..9d61d0b413 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -198,4 +198,18 @@ interface TangemTechApi { */ @POST("v1/transaction-events") suspend fun transactionEvents(@Body name: TransactionEventBody): ApiResponse + + // region Earn + @GET("v1/earn/yield/markets") + suspend fun getEarnTokens( + @Query("isForEarn") isForEarn: Boolean?, + @Query("page") page: String? = null, + @Query("limit") limit: Int? = null, + @Query("type") type: String? = null, + @Query("network") network: String? = null, + ): ApiResponse + + @GET("v1/earn/networks") + suspend fun getEarnNetworks(@Query("type") type: String): ApiResponse + // endregion } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/EarnNetworkListResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/EarnNetworkListResponse.kt new file mode 100644 index 0000000000..1d54fdaeb4 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/EarnNetworkListResponse.kt @@ -0,0 +1,14 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class EarnNetworkListResponse( + @Json(name = "items") val items: List, +) + +@JsonClass(generateAdapter = true) +data class EarnNetworkResponse( + @Json(name = "networkId") val networkId: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/EarnResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/EarnResponse.kt new file mode 100644 index 0000000000..0e48d4e0f4 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/EarnResponse.kt @@ -0,0 +1,33 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class EarnListResponse( + @Json(name = "meta") val meta: MetaEarnListResponse, + @Json(name = "items") val items: List, +) + +@JsonClass(generateAdapter = true) +data class EarnResponse( + @Json(name = "apy") val apy: String, + @Json(name = "networkId") val networkId: String, + @Json(name = "rewardType") val rewardType: String, + @Json(name = "type") val type: String, + @Json(name = "token") val token: EarnTokenResponse, +) + +@JsonClass(generateAdapter = true) +data class EarnTokenResponse( + @Json(name = "id") val id: String, + @Json(name = "symbol") val symbol: String, + @Json(name = "name") val name: String, + @Json(name = "address") val address: String, +) + +@JsonClass(generateAdapter = true) +data class MetaEarnListResponse( + @Json(name = "page") val page: Int, + @Json(name = "limit") val limit: Int, +) \ No newline at end of file diff --git a/data/earn/.gitignore b/data/earn/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/data/earn/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/earn/build.gradle.kts b/data/earn/build.gradle.kts new file mode 100644 index 0000000000..985868438d --- /dev/null +++ b/data/earn/build.gradle.kts @@ -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 + +} \ No newline at end of file diff --git a/data/earn/src/main/java/com/tangem/data/earn/DefaultEarnErrorResolver.kt b/data/earn/src/main/java/com/tangem/data/earn/DefaultEarnErrorResolver.kt new file mode 100644 index 0000000000..1b1d416a64 --- /dev/null +++ b/data/earn/src/main/java/com/tangem/data/earn/DefaultEarnErrorResolver.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/data/earn/src/main/java/com/tangem/data/earn/converter/EarnTokenConverter.kt b/data/earn/src/main/java/com/tangem/data/earn/converter/EarnTokenConverter.kt new file mode 100644 index 0000000000..eb33d52cb2 --- /dev/null +++ b/data/earn/src/main/java/com/tangem/data/earn/converter/EarnTokenConverter.kt @@ -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 { + + 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, + ) + } +} \ No newline at end of file diff --git a/data/earn/src/main/java/com/tangem/data/earn/datastore/EarnNetworksStore.kt b/data/earn/src/main/java/com/tangem/data/earn/datastore/EarnNetworksStore.kt new file mode 100644 index 0000000000..ccf38c8902 --- /dev/null +++ b/data/earn/src/main/java/com/tangem/data/earn/datastore/EarnNetworksStore.kt @@ -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 by RuntimeStateStore( + defaultValue = Either.Right(emptyList()), + ) \ No newline at end of file diff --git a/data/earn/src/main/java/com/tangem/data/earn/datastore/EarnTopTokensStore.kt b/data/earn/src/main/java/com/tangem/data/earn/datastore/EarnTopTokensStore.kt new file mode 100644 index 0000000000..dded4c2c9b --- /dev/null +++ b/data/earn/src/main/java/com/tangem/data/earn/datastore/EarnTopTokensStore.kt @@ -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 by RuntimeStateStore( + defaultValue = Either.Right(emptyList()), + ) \ No newline at end of file diff --git a/data/earn/src/main/java/com/tangem/data/earn/di/EarnDataModule.kt b/data/earn/src/main/java/com/tangem/data/earn/di/EarnDataModule.kt new file mode 100644 index 0000000000..9cecd0e002 --- /dev/null +++ b/data/earn/src/main/java/com/tangem/data/earn/di/EarnDataModule.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/data/earn/src/main/java/com/tangem/data/earn/repository/DefaultEarnRepository.kt b/data/earn/src/main/java/com/tangem/data/earn/repository/DefaultEarnRepository.kt new file mode 100644 index 0000000000..98e48ffd57 --- /dev/null +++ b/data/earn/src/main/java/com/tangem/data/earn/repository/DefaultEarnRepository.kt @@ -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 { + 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 { + 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, + ) + } +} \ No newline at end of file diff --git a/data/earn/src/main/java/com/tangem/data/earn/repository/batch/EarnTokensBatchFetcher.kt b/data/earn/src/main/java/com/tangem/data/earn/repository/batch/EarnTokensBatchFetcher.kt new file mode 100644 index 0000000000..0495ea6c6c --- /dev/null +++ b/data/earn/src/main/java/com/tangem/data/earn/repository/batch/EarnTokensBatchFetcher.kt @@ -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> { + + private val state: MutableStateFlow = MutableStateFlow(null) + + override suspend fun fetchFirst( + requestParams: EarnTokensListConfig, + ): BatchFetchResult> { + 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>, + ): BatchFetchResult> { + 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>, + val state: EarnTokensPaginationState, + ) + + private data class EarnTokensPaginationState( + val nextPage: Int, + val params: EarnTokensListConfig, + ) +} \ No newline at end of file diff --git a/domain/earn/.gitignore b/domain/earn/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/earn/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/earn/build.gradle.kts b/domain/earn/build.gradle.kts new file mode 100644 index 0000000000..f72d726fea --- /dev/null +++ b/domain/earn/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} + +dependencies { + api(projects.domain.core) + api(projects.domain.models) + api(projects.core.pagination) + implementation(projects.domain.common) + implementation(projects.domain.networks) + implementation(deps.kotlin.serialization) +} \ No newline at end of file diff --git a/domain/earn/src/main/java/com/tangem/domain/earn/EarnErrorResolver.kt b/domain/earn/src/main/java/com/tangem/domain/earn/EarnErrorResolver.kt new file mode 100644 index 0000000000..f2120a63b7 --- /dev/null +++ b/domain/earn/src/main/java/com/tangem/domain/earn/EarnErrorResolver.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.earn + +import com.tangem.domain.models.earn.EarnError + +interface EarnErrorResolver { + + fun resolve(throwable: Throwable?): EarnError +} \ No newline at end of file diff --git a/domain/earn/src/main/java/com/tangem/domain/earn/model/EarnTokensListConfig.kt b/domain/earn/src/main/java/com/tangem/domain/earn/model/EarnTokensListConfig.kt new file mode 100644 index 0000000000..ab150727ee --- /dev/null +++ b/domain/earn/src/main/java/com/tangem/domain/earn/model/EarnTokensListConfig.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.earn.model + +import kotlinx.serialization.Serializable + +/** + * Config of request earn tokens. + * + * @param type of Earn (ex: yield, staking). + * @param network networkId. + * @param isForEarn flag to load mostly used tokens. + */ +@Serializable +data class EarnTokensListConfig( + val type: String, + val network: String, + val isForEarn: Boolean, +) \ No newline at end of file diff --git a/domain/earn/src/main/java/com/tangem/domain/earn/model/EarnTypealiases.kt b/domain/earn/src/main/java/com/tangem/domain/earn/model/EarnTypealiases.kt new file mode 100644 index 0000000000..fb9d2cf309 --- /dev/null +++ b/domain/earn/src/main/java/com/tangem/domain/earn/model/EarnTypealiases.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.earn.model + +import com.tangem.domain.models.earn.EarnTokenWithCurrency +import com.tangem.pagination.BatchFlow +import com.tangem.pagination.BatchingContext + +typealias EarnTokensBatchingContext = BatchingContext + +typealias EarnTokensBatchFlow = BatchFlow, Nothing> \ No newline at end of file diff --git a/domain/earn/src/main/java/com/tangem/domain/earn/repository/EarnRepository.kt b/domain/earn/src/main/java/com/tangem/domain/earn/repository/EarnRepository.kt new file mode 100644 index 0000000000..16d4ad67cb --- /dev/null +++ b/domain/earn/src/main/java/com/tangem/domain/earn/repository/EarnRepository.kt @@ -0,0 +1,26 @@ +package com.tangem.domain.earn.repository + +import com.tangem.domain.earn.model.EarnTokensBatchFlow +import com.tangem.domain.earn.model.EarnTokensBatchingContext +import com.tangem.domain.models.earn.EarnNetworks +import com.tangem.domain.models.earn.EarnTopToken +import kotlinx.coroutines.flow.Flow + +interface EarnRepository { + + fun getEarnTokensBatchFlow(context: EarnTokensBatchingContext, batchSize: Int): EarnTokensBatchFlow + + /** + * Load all networks for Earn and store it in data store. + */ + suspend fun fetchEarnNetworks(type: String) + + fun observeEarnNetworks(): Flow + + /** + * Fetch top N earn tokens by isForEarn = true and hold it in runtime store. + */ + suspend fun fetchTopEarnTokens(limit: Int) + + fun observeTopEarnTokens(): Flow +} \ No newline at end of file diff --git a/domain/earn/src/main/java/com/tangem/domain/earn/usecase/FetchEarnNetworksUseCase.kt b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/FetchEarnNetworksUseCase.kt new file mode 100644 index 0000000000..fda4a392d4 --- /dev/null +++ b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/FetchEarnNetworksUseCase.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.earn.usecase + +import arrow.core.Either +import com.tangem.domain.earn.repository.EarnRepository + +class FetchEarnNetworksUseCase( + private val repository: EarnRepository, +) { + + suspend operator fun invoke(type: String): Either { + return Either.catch { + repository.fetchEarnNetworks(type = type) + } + } +} \ No newline at end of file diff --git a/domain/earn/src/main/java/com/tangem/domain/earn/usecase/FetchTopEarnTokensUseCase.kt b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/FetchTopEarnTokensUseCase.kt new file mode 100644 index 0000000000..7d33437dc5 --- /dev/null +++ b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/FetchTopEarnTokensUseCase.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.earn.usecase + +import arrow.core.Either +import com.tangem.domain.earn.repository.EarnRepository + +class FetchTopEarnTokensUseCase( + private val repository: EarnRepository, +) { + + suspend operator fun invoke(limit: Int = DEFAULT_LIMIT): Either { + return Either.catch { + repository.fetchTopEarnTokens( + limit = limit, + ) + } + } + + private companion object { + private const val DEFAULT_LIMIT = 5 + } +} \ No newline at end of file diff --git a/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnNetworksUseCase.kt b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnNetworksUseCase.kt new file mode 100644 index 0000000000..c5e240bf47 --- /dev/null +++ b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnNetworksUseCase.kt @@ -0,0 +1,58 @@ +package com.tangem.domain.earn.usecase + +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.earn.repository.EarnRepository +import com.tangem.domain.models.earn.EarnNetwork +import com.tangem.domain.models.earn.EarnNetworks +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.networks.multi.MultiNetworkStatusProducer +import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* + +/** + * Observes earn networks with [EarnNetwork.isAdded] enriched from user's wallets + * via [multiNetworkStatusSupplier]. Single entry point for all/mine filtering. + */ +class GetEarnNetworksUseCase( + private val earnRepository: EarnRepository, + private val userWalletsListRepository: UserWalletsListRepository, + private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, +) { + + operator fun invoke(): Flow { + return combine( + earnRepository.observeEarnNetworks(), + observeMyNetworkIds(), + ) { earn, myNetworkIds -> + earn.map { earnNetworks -> + earnNetworks.map { network -> + network.copy(isAdded = network.networkId in myNetworkIds) + } + } + }.distinctUntilChanged() + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun observeMyNetworkIds(): Flow> { + return userWalletsListRepository.userWallets + .map { it.orEmpty() } + .flatMapLatest { wallets -> + val activeWallets = wallets + .filterNot(UserWallet::isLocked) + .filter(UserWallet::isMultiCurrency) + if (activeWallets.isEmpty()) { + flowOf(emptySet()) + } else { + val flows = activeWallets.map { wallet -> + multiNetworkStatusSupplier( + MultiNetworkStatusProducer.Params(userWalletId = wallet.walletId), + ).map { statuses -> statuses.map { it.network.backendId }.toSet() } + } + combine(flows) { arrays -> arrays.flatMap { it }.toSet() } + } + } + } +} \ No newline at end of file diff --git a/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnTokensBatchFlowUseCase.kt b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnTokensBatchFlowUseCase.kt new file mode 100644 index 0000000000..6531eb854e --- /dev/null +++ b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnTokensBatchFlowUseCase.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.earn.usecase + +import com.tangem.domain.earn.model.EarnTokensBatchFlow +import com.tangem.domain.earn.model.EarnTokensBatchingContext +import com.tangem.domain.earn.repository.EarnRepository + +/** + * Returns BatchFlow of Earn-tokens. + */ +class GetEarnTokensBatchFlowUseCase( + private val repository: EarnRepository, +) { + + operator fun invoke(context: EarnTokensBatchingContext, batchSize: Int = DEFAULT_BATCH_SIZE): EarnTokensBatchFlow { + return repository.getEarnTokensBatchFlow( + context = context, + batchSize = batchSize, + ) + } + + private companion object { + private const val DEFAULT_BATCH_SIZE = 20 + } +} \ No newline at end of file diff --git a/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetTopEarnTokensUseCase.kt b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetTopEarnTokensUseCase.kt new file mode 100644 index 0000000000..b4a54344f2 --- /dev/null +++ b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetTopEarnTokensUseCase.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.earn.usecase + +import com.tangem.domain.earn.repository.EarnRepository +import com.tangem.domain.models.earn.EarnTopToken +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged + +class GetTopEarnTokensUseCase( + private val repository: EarnRepository, +) { + + operator fun invoke(): Flow { + return repository.observeTopEarnTokens().distinctUntilChanged() + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnError.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnError.kt new file mode 100644 index 0000000000..d5106276e8 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnError.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.models.earn + +sealed interface EarnError { + + data class HttpError( + val code: Int, + val message: String, + ) : EarnError + + data object NotHttpError : EarnError +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnNetwork.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnNetwork.kt new file mode 100644 index 0000000000..04d3edbc79 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnNetwork.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.models.earn + +import kotlinx.serialization.Serializable + +/** + * Model for networks in Earn. + * @param networkId - id of network + * @param isAdded - is exists in any user's wallet + */ +@Serializable +data class EarnNetwork( + val networkId: String, + val isAdded: Boolean, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnNetworks.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnNetworks.kt new file mode 100644 index 0000000000..95ffdf7fb7 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnNetworks.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.models.earn + +import arrow.core.Either + +typealias EarnNetworks = Either> \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnToken.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnToken.kt new file mode 100644 index 0000000000..a91581e8eb --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnToken.kt @@ -0,0 +1,26 @@ +package com.tangem.domain.models.earn + +import kotlinx.serialization.Serializable + +/** + * Model of earn token. + * @param apy - percent of earning + * @param networkId - id of network + * @param rewardType - apr or apy + * @param type - staking or yield + * @param tokenSymbol - symbol of token (ex. "ATOM") + * @param tokenName - name of token (ex. "Cosmos Hub") + * @param tokenId - id of token (ex. "cosmos") + * @param tokenAddress - address of contract + */ +@Serializable +data class EarnToken( + val apy: String, + val networkId: String, + val rewardType: String, + val type: String, + val tokenId: String, + val tokenSymbol: String, + val tokenName: String, + val tokenAddress: String, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnTokenWithCurrency.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnTokenWithCurrency.kt new file mode 100644 index 0000000000..5111c260d4 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnTokenWithCurrency.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.models.earn + +import com.tangem.domain.models.currency.CryptoCurrency +import kotlinx.serialization.Serializable + +/** + * Represents an earn token and its associated cryptocurrency. This model is only for main account. + * + * @property earnToken The earn token details. + * @property cryptoCurrency Use this [CryptoCurrency] only for creating the cryptoCurrencyIcon!!!! + */ +@Serializable +data class EarnTokenWithCurrency( + val earnToken: EarnToken, + val cryptoCurrency: CryptoCurrency, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnTopToken.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnTopToken.kt new file mode 100644 index 0000000000..7dd62ff84b --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/EarnTopToken.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.models.earn + +import arrow.core.Either + +typealias EarnTopToken = Either> \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 83aadc8341..d71fbf5bea 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -370,6 +370,7 @@ include(":domain:wallet-manager:models") include(":domain:yield-supply") include(":domain:yield-supply:models") include(":domain:news") +include(":domain:earn") // endregion Domain modules // region Data modules @@ -406,4 +407,5 @@ include(":data:express") include(":data:wallet-manager") include(":data:yield-supply") include(":data:news") +include(":data:earn") // endregion Data modules \ No newline at end of file