Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-16 14:29:00 +04:00
parent d9bef76e89
commit 2227762050
12 changed files with 57 additions and 278 deletions

View file

@ -20,11 +20,7 @@ import com.tangem.domain.staking.single.SingleYieldBalanceSupplier
import com.tangem.domain.tokens.* import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations
import com.tangem.domain.tokens.operations.CachedCurrenciesStatusesOperations import com.tangem.domain.tokens.operations.CachedCurrenciesStatusesOperations
import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.*
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.PolkadotAccountHealthCheckRepository
import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository
import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository
import com.tangem.domain.tokens.wallet.WalletBalanceFetcher import com.tangem.domain.tokens.wallet.WalletBalanceFetcher
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.tap.domain.tokens.DefaultTokensFeatureToggles import com.tangem.tap.domain.tokens.DefaultTokensFeatureToggles
@ -60,24 +56,6 @@ internal object TokensDomainModule {
) )
} }
@Provides
@Singleton
fun provideFetchTokenListUseCase(
currenciesRepository: CurrenciesRepository,
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
stakingIdFactory: StakingIdFactory,
): FetchTokenListUseCase {
return FetchTokenListUseCase(
currenciesRepository = currenciesRepository,
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
multiYieldBalanceFetcher = multiYieldBalanceFetcher,
stakingIdFactory = stakingIdFactory,
)
}
@Provides @Provides
@Singleton @Singleton
fun provideFetchPendingTransactionsUseCase( fun provideFetchPendingTransactionsUseCase(
@ -184,24 +162,6 @@ internal object TokensDomainModule {
) )
} }
@Provides
@Singleton
fun provideFetchCardTokenListUseCase(
currenciesRepository: CurrenciesRepository,
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
stakingIdFactory: StakingIdFactory,
): FetchCardTokenListUseCase {
return FetchCardTokenListUseCase(
currenciesRepository = currenciesRepository,
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
multiYieldBalanceFetcher = multiYieldBalanceFetcher,
stakingIdFactory = stakingIdFactory,
)
}
@Provides @Provides
@Singleton @Singleton
fun provideGetCryptoCurrencyUseCase( fun provideGetCryptoCurrencyUseCase(

View file

@ -27,6 +27,7 @@ dependencies {
api(projects.domain.common) api(projects.domain.common)
api(projects.domain.models) api(projects.domain.models)
api(projects.domain.tokens) api(projects.domain.tokens)
api(projects.domain.wallets)
// endregion // endregion
// region Project - Data // region Project - Data

View file

@ -51,18 +51,25 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : WalletAccountsFetcher, WalletAccountsSaver { ) : WalletAccountsFetcher, WalletAccountsSaver {
override suspend fun fetch(userWalletId: UserWalletId) { override suspend fun fetch(userWalletId: UserWalletId): GetWalletAccountsResponse {
val savedAccountsResponse = getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull() val savedAccountsResponse = getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull()
val accountsResponse = fetchWalletAccounts(userWalletId, savedAccountsResponse) val accountsResponse = fetchWalletAccounts(userWalletId, savedAccountsResponse)
?: return
if (accountsResponse.accounts.isEmpty()) { return when {
initializeAccounts(userWalletId, accountsResponse) accountsResponse.accounts.isEmpty() -> {
} else if (accountsResponse.unassignedTokens.isNotEmpty()) { initializeAccounts(userWalletId, accountsResponse)
assignTokens(userWalletId, accountsResponse) }
accountsResponse.unassignedTokens.isNotEmpty() -> {
assignTokens(userWalletId, accountsResponse)
}
else -> accountsResponse
} }
} }
override suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse? {
return getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull()
}
override suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) { override suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) {
val store = getAccountsResponseStore(userWalletId = userWalletId) val store = getAccountsResponseStore(userWalletId = userWalletId)
@ -115,7 +122,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
private suspend fun fetchWalletAccounts( private suspend fun fetchWalletAccounts(
userWalletId: UserWalletId, userWalletId: UserWalletId,
savedAccountsResponse: GetWalletAccountsResponse?, savedAccountsResponse: GetWalletAccountsResponse?,
): GetWalletAccountsResponse? { ): GetWalletAccountsResponse {
return safeApiCall( return safeApiCall(
call = { call = {
val apiResponse = withContext(dispatchers.io) { val apiResponse = withContext(dispatchers.io) {
@ -145,7 +152,10 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
) )
} }
private suspend fun initializeAccounts(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) { private suspend fun initializeAccounts(
userWalletId: UserWalletId,
accountsResponse: GetWalletAccountsResponse,
): GetWalletAccountsResponse {
val response = defaultWalletAccountsResponseFactory.create( val response = defaultWalletAccountsResponseFactory.create(
userWalletId = userWalletId, userWalletId = userWalletId,
userTokensResponse = UserTokensResponse( userTokensResponse = UserTokensResponse(
@ -156,14 +166,17 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
) )
userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse()) userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse())
val syncedResponse = push(userWalletId = userWalletId, accounts = response.accounts) val syncedResponse = push(userWalletId = userWalletId, accounts = response.accounts) ?: response
if (syncedResponse != null) { store(userWalletId = userWalletId, response = syncedResponse)
store(userWalletId = userWalletId, response = syncedResponse)
} return syncedResponse
} }
private suspend fun assignTokens(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) { private suspend fun assignTokens(
userWalletId: UserWalletId,
accountsResponse: GetWalletAccountsResponse,
): GetWalletAccountsResponse {
val accountsResponseWithTokens = accountsResponse.assignTokens(userWalletId) val accountsResponseWithTokens = accountsResponse.assignTokens(userWalletId)
store(userWalletId = userWalletId, response = accountsResponseWithTokens) store(userWalletId = userWalletId, response = accountsResponseWithTokens)
@ -172,6 +185,8 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
userWalletId = userWalletId, userWalletId = userWalletId,
response = accountsResponseWithTokens.toUserTokensResponse(), response = accountsResponseWithTokens.toUserTokensResponse(),
) )
return accountsResponseWithTokens
} }
private suspend fun getETag(userWalletId: UserWalletId): String? { private suspend fun getETag(userWalletId: UserWalletId): String? {

View file

@ -49,11 +49,13 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
savedAccountsResponse: GetWalletAccountsResponse?, savedAccountsResponse: GetWalletAccountsResponse?,
pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> GetWalletAccountsResponse?, pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> GetWalletAccountsResponse?,
storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit, storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit,
): GetWalletAccountsResponse? { ): GetWalletAccountsResponse {
val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED) val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED)
if (isResponseUpToDate) { if (isResponseUpToDate) {
Timber.e("ETag is up to date, no need to update accounts for wallet: $userWalletId") Timber.e("ETag is up to date, no need to update accounts for wallet: $userWalletId")
return savedAccountsResponse return requireNotNull(savedAccountsResponse) {
"Saved accounts response is null for wallet: $userWalletId"
}
} }
var response = savedAccountsResponse ?: createDefaultResponse(userWalletId) var response = savedAccountsResponse ?: createDefaultResponse(userWalletId)

View file

@ -29,6 +29,8 @@ class DefaultSingleAccountListFetcherTest {
// Arrange // Arrange
val params = SingleAccountListFetcher.Params(userWalletId = userWalletId) val params = SingleAccountListFetcher.Params(userWalletId = userWalletId)
coEvery { walletAccountsFetcher.fetch(userWalletId) } returns mockk()
// Act // Act
val actual = fetcher.invoke(params) val actual = fetcher.invoke(params)

View file

@ -199,7 +199,7 @@ class DefaultWalletAccountsFetcherTest {
@Test @Test
fun `fetch should call error handler when getWalletAccounts returns error`() = runTest { fun `fetch should call error handler when getWalletAccounts returns error`() = runTest {
// Arrange // Arrange
val savedAccountsResponse = null val savedAccountsResponse = createGetWalletAccountsResponse(userWalletId)
val apiError = ApiResponse.Error(ApiResponseError.NetworkException()) val apiError = ApiResponse.Error(ApiResponseError.NetworkException())
accountsResponseStoreFlow.value = savedAccountsResponse accountsResponseStoreFlow.value = savedAccountsResponse
@ -212,7 +212,7 @@ class DefaultWalletAccountsFetcherTest {
fetchWalletAccountsErrorHandler.handle( fetchWalletAccountsErrorHandler.handle(
error = apiError.cause, error = apiError.cause,
userWalletId = userWalletId, userWalletId = userWalletId,
savedAccountsResponse = null, savedAccountsResponse = savedAccountsResponse,
pushWalletAccounts = any(), pushWalletAccounts = any(),
storeWalletAccounts = any(), storeWalletAccounts = any(),
) )
@ -231,7 +231,7 @@ class DefaultWalletAccountsFetcherTest {
fetchWalletAccountsErrorHandler.handle( fetchWalletAccountsErrorHandler.handle(
error = apiError.cause, error = apiError.cause,
userWalletId = userWalletId, userWalletId = userWalletId,
savedAccountsResponse = null, savedAccountsResponse = savedAccountsResponse,
pushWalletAccounts = any(), pushWalletAccounts = any(),
storeWalletAccounts = any(), storeWalletAccounts = any(),
) )

View file

@ -53,6 +53,8 @@ class FetchWalletAccountsErrorHandlerTest {
@Test @Test
fun `does not update accounts when response is up to date`() = runTest { fun `does not update accounts when response is up to date`() = runTest {
// Arrange // Arrange
val response = createGetWalletAccountsResponse(userWalletId)
val error = ApiResponseError.HttpException( val error = ApiResponseError.HttpException(
code = Code.NOT_MODIFIED, code = Code.NOT_MODIFIED,
message = "Not Modified", message = "Not Modified",
@ -63,7 +65,7 @@ class FetchWalletAccountsErrorHandlerTest {
handler.handle( handler.handle(
error = error, error = error,
userWalletId = userWalletId, userWalletId = userWalletId,
savedAccountsResponse = null, savedAccountsResponse = response,
pushWalletAccounts = pushWalletAccounts, pushWalletAccounts = pushWalletAccounts,
storeWalletAccounts = storeWalletAccounts, storeWalletAccounts = storeWalletAccounts,
) )

View file

@ -1,5 +1,6 @@
package com.tangem.data.common.account package com.tangem.data.common.account
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
/** /**
@ -11,5 +12,8 @@ interface WalletAccountsFetcher {
/** Fetch wallet accounts by [userWalletId] */ /** Fetch wallet accounts by [userWalletId] */
@Throws @Throws
suspend fun fetch(userWalletId: UserWalletId) suspend fun fetch(userWalletId: UserWalletId): GetWalletAccountsResponse
/** Get saved wallet accounts by [userWalletId] */
suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse?
} }

View file

@ -1,6 +1,7 @@
package com.tangem.data.tokens package com.tangem.data.tokens
import arrow.core.Either import arrow.core.Either
import arrow.core.right
import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.utils.catchOn import com.tangem.domain.core.utils.catchOn
@ -25,11 +26,15 @@ internal class AccountListCryptoCurrenciesFetcher(
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : MultiWalletCryptoCurrenciesFetcher { ) : MultiWalletCryptoCurrenciesFetcher {
override suspend fun invoke(params: Params) = Either.catchOn(dispatchers.default) { override suspend fun invoke(params: Params): Either<Throwable, Unit> {
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId) return Either.catchOn(dispatchers.default) {
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId)
if (!userWallet.isMultiCurrency) error("${this::class.simpleName} supports only multi-currency wallet") if (!userWallet.isMultiCurrency) error("${this::class.simpleName} supports only multi-currency wallet")
walletAccountsFetcher.fetch(userWalletId = params.userWalletId) walletAccountsFetcher.fetch(userWalletId = params.userWalletId)
Unit.right()
}
} }
} }

View file

@ -61,6 +61,7 @@ internal class AccountListCryptoCurrenciesFetcherTest {
val mockUserWallet = mockk<UserWallet> { every { isMultiCurrency } returns true } val mockUserWallet = mockk<UserWallet> { every { isMultiCurrency } returns true }
every { userWalletsStore.getSyncStrict(key = params.userWalletId) } returns mockUserWallet every { userWalletsStore.getSyncStrict(key = params.userWalletId) } returns mockUserWallet
coEvery { walletAccountsFetcher.fetch(userWalletId = params.userWalletId) } returns mockk()
// Act // Act
val actual = fetcher(params) val actual = fetcher(params)

View file

@ -1,96 +0,0 @@
package com.tangem.domain.tokens
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.repository.CurrenciesRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
class FetchCardTokenListUseCase(
private val currenciesRepository: CurrenciesRepository,
private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
private val stakingIdFactory: StakingIdFactory,
) {
suspend operator fun invoke(userWalletId: UserWalletId, refresh: Boolean = false): Either<TokenListError, Unit> {
return either {
val currencies = fetchCurrencies(userWalletId = userWalletId, refresh = refresh)
coroutineScope {
val fetchStatuses = async {
fetchNetworksStatuses(
userWalletId = userWalletId,
networks = currencies.mapTo(destination = hashSetOf(), transform = CryptoCurrency::network),
)
}
val fetchQuotes = async {
fetchQuotes(
currenciesIds = currencies.mapNotNullTo(destination = hashSetOf()) { it.id.rawCurrencyId },
)
}
val yieldBalances = async {
fetchYieldBalances(
userWalletId = userWalletId,
currencies = currencies,
)
}
awaitAll(fetchStatuses, fetchQuotes, yieldBalances)
}
}
}
private suspend fun Raise<TokenListError>.fetchCurrencies(
userWalletId: UserWalletId,
refresh: Boolean = false,
): List<CryptoCurrency> {
return catch(
block = {
currenciesRepository.getSingleCurrencyWalletWithCardCurrencies(
userWalletId = userWalletId,
refresh = refresh,
)
},
catch = { raise(TokenListError.DataError(it)) },
)
}
private suspend fun Raise<TokenListError>.fetchNetworksStatuses(
userWalletId: UserWalletId,
networks: Set<Network>,
) {
multiNetworkStatusFetcher(
MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = networks),
)
.mapLeft(TokenListError::DataError)
.bind()
}
private suspend fun fetchQuotes(currenciesIds: Set<CryptoCurrency.RawID>) {
multiQuoteStatusFetcher(
params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null),
)
}
private suspend fun fetchYieldBalances(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
val stakingIds = currencies.mapNotNullTo(hashSetOf()) {
stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull()
}
multiYieldBalanceFetcher(
params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = stakingIds),
)
}
}

View file

@ -1,117 +0,0 @@
package com.tangem.domain.tokens
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import arrow.core.raise.ensureNotNull
import arrow.core.toNonEmptyListOrNull
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.repository.CurrenciesRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
/**
* Use case responsible for fetching token list information, including currency data,
* network statuses, and quotes for tokens associated with a user's wallet.
*
* @param currenciesRepository The repository for retrieving currency-related data.
*/
class FetchTokenListUseCase(
private val currenciesRepository: CurrenciesRepository,
private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
private val stakingIdFactory: StakingIdFactory,
) {
/**
* Fetches the token list information for a user's wallet, including currency data,
* network statuses, and quotes for associated tokens.
*
* @param userWalletId The ID of the user's wallet.
* @return An [Either] representing success (Right) or an error (Left) in fetching the token list.
*/
suspend operator fun invoke(userWalletId: UserWalletId): Either<TokenListError, Unit> = either {
val currencies = fetchCurrencies(userWalletId)
invoke(userWalletId = userWalletId, currencies = currencies)
}
suspend operator fun invoke(
userWalletId: UserWalletId,
currencies: List<CryptoCurrency>,
): Either<TokenListError, Unit> = either {
coroutineScope {
val fetchStatuses = async {
fetchNetworksStatuses(
userWalletId = userWalletId,
networks = currencies.mapTo(hashSetOf()) { it.network },
)
}
val fetchQuotes = async {
fetchQuotes(
currenciesIds = currencies.mapTo(hashSetOf()) { it.id },
)
}
val yieldBalances = async {
fetchYieldBalances(userWalletId = userWalletId, currencies = currencies)
}
awaitAll(fetchStatuses, fetchQuotes, yieldBalances)
}
}
private suspend fun Raise<TokenListError>.fetchCurrencies(userWalletId: UserWalletId): List<CryptoCurrency> {
val currencies = catch(
block = { currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId, true) },
) {
raise(TokenListError.DataError(it))
}
return ensureNotNull(currencies.toNonEmptyListOrNull()) {
TokenListError.EmptyTokens
}
}
private suspend fun Raise<TokenListError>.fetchNetworksStatuses(
userWalletId: UserWalletId,
networks: Set<Network>,
) {
multiNetworkStatusFetcher(
params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = networks),
)
.mapLeft(TokenListError::DataError)
.bind()
}
private suspend fun Raise<TokenListError>.fetchQuotes(currenciesIds: Set<CryptoCurrency.ID>) {
multiQuoteStatusFetcher(
params = MultiQuoteStatusFetcher.Params(
currenciesIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet(),
appCurrencyId = null,
),
)
.mapLeft(TokenListError::DataError)
.bind()
}
private suspend fun fetchYieldBalances(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
val stakingIds = currencies.mapNotNullTo(hashSetOf()) {
stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull()
}
multiYieldBalanceFetcher(
params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = stakingIds),
)
}
}