Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-09 14:57:54 +03:00
commit 3fc5bf8fb3
388 changed files with 5078 additions and 2871 deletions

View file

@ -6,8 +6,6 @@
<ID>MultilineLambdaItParameter:DefaultAccountsCRUDRepository.kt$DefaultAccountsCRUDRepository${ if (it is HttpException &amp;&amp; it.code == HttpException.Code.NOT_MODIFIED) { null } else { throw it } }</ID>
<ID>MultilineLambdaItParameter:GetWalletAccountsResponseExt.kt${ enrichedTokensByAccountId[it].orEmpty().map { token -&gt; // Tokens from unexisting accounts should be copied to the main account token.copy(accountId = accountDTO.id) } }</ID>
<ID>NoNameShadowing:GetWalletAccountsResponseExt.kt$tokens</ID>
<ID>NullableToStringCall:AccountListCryptoCurrenciesProducer.kt$AccountListCryptoCurrenciesProducer$${this::class.simpleName}</ID>
<ID>NullableToStringCall:DefaultMultiWalletCryptoCurrenciesProducer.kt$DefaultMultiWalletCryptoCurrenciesProducer$${this::class.simpleName}</ID>
<ID>UnnecessaryLet:DefaultAccountsCRUDRepository.kt$DefaultAccountsCRUDRepository$let(AccountName::invoke)</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -36,6 +36,7 @@ internal class AccountListConverter @AssistedInject constructor(
userWalletId = userWallet.walletId,
accounts = value.accounts.map(cryptoPortfolioConverter::convert),
totalAccounts = value.wallet.totalAccounts,
totalArchivedAccounts = value.wallet.totalArchivedAccounts,
sortType = sortType,
groupType = groupType,
)

View file

@ -27,6 +27,7 @@ internal class GetWalletAccountsResponseConverter @AssistedInject constructor(
group = TokensGroupTypeConverter.convertBack(value.groupType),
sort = TokensSortTypeConverter.convertBack(value.sortType),
totalAccounts = value.totalAccounts,
totalArchivedAccounts = value.totalArchivedAccounts,
),
accounts = value.accounts
.filterIsInstance<Account.CryptoPortfolio>()

View file

@ -94,28 +94,30 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
override suspend fun push(
userWalletId: UserWalletId,
body: SaveWalletAccountsResponse,
): GetWalletAccountsResponse? {
return pushInternal(userWalletId = userWalletId, body = body)
}
private suspend fun pushInternal(
userWalletId: UserWalletId,
body: SaveWalletAccountsResponse,
eTag: String? = null,
): GetWalletAccountsResponse? {
return safeApiCall(
call = {
var eTag = getETag(userWalletId)
if (eTag == null) {
fetch(userWalletId)
eTag = getETag(userWalletId) ?: error("ETag is null after fetch")
}
val resolvedETag = eTag ?: getETagForPush(userWalletId)
val apiResponse = withContext(dispatchers.io) {
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
eTag = eTag,
eTag = resolvedETag,
body = body,
)
}
saveETag(userWalletId, apiResponse)
apiResponse.bind()
apiResponse.bind().enrichByAccountId()
},
onError = { error ->
if (error.isNetworkError(code = Code.PRECONDITION_FAILED)) {
@ -127,6 +129,19 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
)
}
private suspend fun getETagForPush(userWalletId: UserWalletId): String {
var savedETag = getETag(userWalletId)
if (savedETag == null) {
fetch(userWalletId)
savedETag = getETag(userWalletId)
?: error("Failed to retrieve ETag after fetching wallet accounts for wallet $userWalletId")
}
return savedETag
}
private suspend fun fetchWalletAccounts(
userWalletId: UserWalletId,
savedAccountsResponse: GetWalletAccountsResponse?,
@ -142,10 +157,11 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
saveETag(userWalletId, apiResponse)
val responseBody = apiResponse.bind()
store(userWalletId = userWalletId, response = responseBody)
val response = apiResponse.bind().enrichByAccountId()
FetchResult(responseBody)
store(userWalletId = userWalletId, response = response)
FetchResult(response)
},
onError = { throwable ->
// pushWalletAccounts and storeWalletAccounts help to avoid cyclic dependency
@ -153,7 +169,13 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
error = throwable,
userWalletId = userWalletId,
savedAccountsResponse = savedAccountsResponse,
pushWalletAccounts = ::push,
pushWalletAccounts = { accounts, eTag ->
pushInternal(
userWalletId = userWalletId,
body = SaveWalletAccountsResponse(accounts),
eTag = eTag,
)
},
storeWalletAccounts = ::store,
)
},
@ -215,6 +237,18 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
}
}
private fun GetWalletAccountsResponse.enrichByAccountId(): GetWalletAccountsResponse {
return copy(
accounts = accounts.map { accountDTO ->
accountDTO.copy(
tokens = accountDTO.tokens?.map { token ->
token.copy(accountId = accountDTO.id)
},
)
},
)
}
private fun getAccountsResponseStore(userWalletId: UserWalletId): AccountsResponseStore {
return accountsResponseStoreFactory.create(userWalletId = userWalletId)
}

View file

@ -2,7 +2,6 @@ package com.tangem.data.account.fetcher
import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher.FetchResult
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponse
@ -29,10 +28,10 @@ import javax.inject.Singleton
* Handles errors that occur during the fetching of wallet accounts
*
* @property tangemTechApi API for network requests
* @property userWalletsStore provides access to user wallets storage
* @property userTokensSaver saves user tokens to the storage
* @property userTokensResponseStore provides access to user token responses.
* @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse]
* @property eTagsStore store for ETags to manage caching
* @property dispatchers dispatchers
*
* @see DefaultWalletAccountsFetcher
@ -47,7 +46,6 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
private val userTokensSaver: UserTokensSaver,
private val userTokensResponseStore: UserTokensResponseStore,
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory,
private val eTagsStore: ETagsStore,
private val dispatchers: CoroutineDispatcherProvider,
) {
@ -66,7 +64,7 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
error: ApiResponseError,
userWalletId: UserWalletId,
savedAccountsResponse: GetWalletAccountsResponse?,
pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> GetWalletAccountsResponse?,
pushWalletAccounts: suspend (List<WalletAccountDTO>, String) -> GetWalletAccountsResponse?,
storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit,
): FetchResult {
val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED)
@ -87,9 +85,7 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
val eTag = createWallet(userWalletId)
if (eTag != null) {
eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = eTag)
pushWalletAccounts(userWalletId, accountDTOs)
pushWalletAccounts(accountDTOs, eTag)
userTokensSaver.pushWithRetryer(userWalletId, userTokensResponse)
}
}

View file

@ -36,11 +36,12 @@ internal class AccountListCryptoCurrenciesProducer @AssistedInject constructor(
override val fallback: Option<Set<CryptoCurrency>> = emptySet<CryptoCurrency>().some()
@Suppress("NullableToStringCall")
override fun produce(): Flow<Set<CryptoCurrency>> {
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId)
if (!userWallet.isMultiCurrency) {
error("${this::class.simpleName} supports only multi-currency wallet")
error("${this::class.simpleName ?: this::class.toString()} supports only multi-currency wallet")
}
return accountsResponseStoreFactory.create(userWalletId = userWallet.walletId).data
@ -49,10 +50,13 @@ internal class AccountListCryptoCurrenciesProducer @AssistedInject constructor(
if (response == null) return@map emptySet()
response.accounts.flatMapTo(hashSetOf()) { accountDTO ->
val accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull()
?: return@map emptySet()
responseCryptoCurrenciesFactory.createCurrencies(
tokens = accountDTO.tokens.orEmpty(),
userWallet = userWallet,
accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull(),
accountIndex = accountIndex,
)
}
}

View file

@ -5,6 +5,7 @@ import arrow.core.some
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
@ -39,7 +40,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constr
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId)
if (!userWallet.isMultiCurrency) {
error("${this::class.simpleName} supports only multi-currency wallet")
error("${this::class.simpleName ?: this::class.toString()} supports only multi-currency wallet")
}
return userTokensResponseStore.get(userWalletId = params.userWalletId)
@ -50,6 +51,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constr
responseCryptoCurrenciesFactory.createCurrencies(
response = response,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
).toSet()
}
.onEmpty { emit(emptySet()) }

View file

@ -61,7 +61,7 @@ internal class DefaultMainAccountTokensMigration(
val unassignedTokens = mainAccount.findUnassignedTokens(derivationIndex)
if (unassignedTokens == null) {
if (unassignedTokens.isNullOrEmpty()) {
Timber.i("No unassigned tokens found for migration")
return@either
}

View file

@ -43,6 +43,7 @@ internal class DefaultWalletAccountsResponseFactory @Inject constructor(
group = response.group,
sort = response.sort,
totalAccounts = accountDTOs.size,
totalArchivedAccounts = 0,
),
accounts = accountDTOs.assignTokens(userWalletId = userWalletId, tokens = response.tokens),
unassignedTokens = emptyList(),

View file

@ -53,6 +53,7 @@ internal fun createGetWalletAccountsResponse(
group = groupType,
sort = sortType,
totalAccounts = 1,
totalArchivedAccounts = 0,
),
accounts = buildList {
createWalletAccountDTO(
@ -79,6 +80,7 @@ internal fun createAccountList(
userWalletId = userWalletId,
accounts = listOf(createCryptoPortfolio(userWalletId)),
totalAccounts = 1,
totalArchivedAccounts = 0,
sortType = sortType,
groupType = groupType,
)

View file

@ -137,6 +137,7 @@ class AccountListConverterTest {
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
totalAccounts = 1,
totalArchivedAccounts = 0,
),
accounts = emptyList(),
unassignedTokens = emptyList(),

View file

@ -3,7 +3,6 @@ package com.tangem.data.account.fetcher
import com.tangem.data.account.converter.createGetWalletAccountsResponse
import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
@ -38,7 +37,6 @@ class FetchWalletAccountsErrorHandlerTest {
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk()
private val eTagsStore: ETagsStore = mockk(relaxUnitFun = true)
private val handler = FetchWalletAccountsErrorHandler(
tangemTechApi = tangemTechApi,
@ -46,17 +44,18 @@ class FetchWalletAccountsErrorHandlerTest {
userTokensSaver = userTokensSaver,
userTokensResponseStore = userTokensResponseStore,
defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory,
eTagsStore = eTagsStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> GetWalletAccountsResponse =
private val pushWalletAccounts: suspend (List<WalletAccountDTO>, String) -> GetWalletAccountsResponse =
mockk(relaxed = true)
private val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true)
@BeforeEach
fun setupEach() {
clearMocks(
tangemTechApi,
userWalletsStore,
userTokensSaver,
userTokensResponseStore,
defaultWalletAccountsResponseFactory,
@ -129,7 +128,7 @@ class FetchWalletAccountsErrorHandlerTest {
),
)
} returns apiResponse
coEvery { pushWalletAccounts(userWalletId, listOf(accountDTO)) } returns savedAccountsResponse
coEvery { pushWalletAccounts(listOf(accountDTO), eTagValue) } returns savedAccountsResponse
// Act
handler.handle(
@ -150,8 +149,7 @@ class FetchWalletAccountsErrorHandlerTest {
walletType = WalletType.COLD,
),
)
eTagsStore.store(userWalletId, ETagsStore.Key.WalletAccounts, eTagValue)
pushWalletAccounts(userWalletId, listOf(accountDTO))
pushWalletAccounts(listOf(accountDTO), eTagValue)
storeWalletAccounts(userWalletId, savedAccountsResponse)
}
@ -182,6 +180,7 @@ class FetchWalletAccountsErrorHandlerTest {
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 1,
totalArchivedAccounts = 0,
),
accounts = listOf(accountDTO),
unassignedTokens = emptyList(),

View file

@ -10,6 +10,7 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
@ -72,7 +73,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
}
verify(inverse = true) {
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any())
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any())
}
}
@ -115,6 +116,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
} returns cryptoCurrencies.toList()
@ -122,6 +124,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
responseCryptoCurrenciesFactory.createCurrencies(
response = updatedUserTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
} returns updatedCryptoCurrencies.toList()
@ -144,6 +147,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
@ -162,6 +166,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
responseCryptoCurrenciesFactory.createCurrencies(
response = updatedUserTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
}
@ -186,6 +191,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
} returns cryptoCurrencies.toList()
@ -208,6 +214,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
@ -252,6 +259,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
} returns cryptoCurrencies.toList()
@ -283,6 +291,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
}
@ -307,7 +316,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
}
verify(inverse = true) {
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any())
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any())
}
}
@ -335,7 +344,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
verify(inverse = true) {
userTokensResponseStore.get(any())
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any())
responseCryptoCurrenciesFactory.createCurrencies(response = any(), userWallet = any(), accountIndex = any())
}
}

View file

@ -179,6 +179,7 @@ class DefaultMainAccountTokensMigrationTest {
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 2,
totalArchivedAccounts = 0,
),
accounts = listOf(mainAccount, selectedAccount),
unassignedTokens = emptyList(),

View file

@ -81,6 +81,7 @@ class DefaultWalletAccountsResponseFactoryTest {
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
totalAccounts = 0,
totalArchivedAccounts = 0,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
@ -135,6 +136,7 @@ class DefaultWalletAccountsResponseFactoryTest {
group = defaultResponse.group,
sort = defaultResponse.sort,
totalAccounts = 1,
totalArchivedAccounts = 0,
),
accounts = listOf(accountsDTO.copy(tokens = listOf(token))),
unassignedTokens = emptyList(),
@ -190,6 +192,7 @@ class DefaultWalletAccountsResponseFactoryTest {
group = defaultResponse.group,
sort = defaultResponse.sort,
totalAccounts = 0,
totalArchivedAccounts = 0,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
@ -228,6 +231,7 @@ class DefaultWalletAccountsResponseFactoryTest {
group = userTokensResponse.group,
sort = userTokensResponse.sort,
totalAccounts = 1,
totalArchivedAccounts = 0,
),
accounts = listOf(accountsDTO.copy(tokens = userTokensResponse.tokens)),
unassignedTokens = emptyList(),

View file

@ -32,6 +32,7 @@ class GetWalletAccountsResponseExtTest {
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 0,
totalArchivedAccounts = 0,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
@ -55,6 +56,7 @@ class GetWalletAccountsResponseExtTest {
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 1,
totalArchivedAccounts = 0,
),
accounts = listOf(account),
unassignedTokens = emptyList(),
@ -84,6 +86,7 @@ class GetWalletAccountsResponseExtTest {
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 2,
totalArchivedAccounts = 0,
),
accounts = listOf(account1, account2, account3),
unassignedTokens = emptyList(),
@ -110,6 +113,7 @@ class GetWalletAccountsResponseExtTest {
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 0,
totalArchivedAccounts = 0,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
@ -141,6 +145,7 @@ class GetWalletAccountsResponseExtTest {
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
totalAccounts = 1,
totalArchivedAccounts = 0,
),
accounts = listOf(account),
unassignedTokens = listOf(token2),
@ -178,6 +183,7 @@ class GetWalletAccountsResponseExtTest {
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 2,
totalArchivedAccounts = 0,
),
accounts = listOf(account1, account2),
unassignedTokens = listOf(token1, token2),
@ -217,6 +223,7 @@ class GetWalletAccountsResponseExtTest {
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 1,
totalArchivedAccounts = 0,
),
accounts = listOf(account),
unassignedTokens = emptyList(),
@ -248,6 +255,7 @@ class GetWalletAccountsResponseExtTest {
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 2,
totalArchivedAccounts = 0,
),
accounts = listOf(account),
unassignedTokens = listOf(token1, token2),

View file

@ -5,12 +5,8 @@ import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.datasource.local.appcurrency.AvailableAppCurrenciesStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -24,7 +20,7 @@ import org.joda.time.Duration
internal class DefaultAppCurrencyRepository(
private val tangemTechApi: TangemTechApi,
private val appPreferencesStore: AppPreferencesStore,
private val appCurrencyResponseStore: AppCurrencyResponseStore,
private val availableAppCurrenciesStore: AvailableAppCurrenciesStore,
private val cacheRegistry: CacheRegistry,
private val dispatchers: CoroutineDispatcherProvider,
@ -35,15 +31,15 @@ internal class DefaultAppCurrencyRepository(
override fun getSelectedAppCurrency(): Flow<AppCurrency> {
return channelFlow {
launch {
appPreferencesStore
.getObject<CurrenciesResponse.Currency>(key = PreferencesKeys.SELECTED_APP_CURRENCY_KEY)
appCurrencyResponseStore
.get()
.filterNotNull()
.map(appCurrencyConverter::convert)
.collect(::send)
}
withContext(dispatchers.io) {
if (appPreferencesStore.getSyncOrNull(PreferencesKeys.SELECTED_APP_CURRENCY_KEY) == null) {
if (appCurrencyResponseStore.getSyncOrNull() == null) {
fetchDefaultAppCurrency()
}
}
@ -70,17 +66,14 @@ internal class DefaultAppCurrencyRepository(
"Unable to find app currency with provided code: $currencyCode"
}
appPreferencesStore.storeObject(
key = PreferencesKeys.SELECTED_APP_CURRENCY_KEY,
value = currency,
)
appCurrencyResponseStore.store(currency)
}
}
override suspend fun fetchDefaultAppCurrency(isRefresh: Boolean) {
withContext(dispatchers.io) {
fetchAvailableCurrenciesIfExpired(isRefresh)
val appCurrency = appPreferencesStore.getSyncOrNull(PreferencesKeys.SELECTED_APP_CURRENCY_KEY)
val appCurrency = appCurrencyResponseStore.getSyncOrNull()?.code
changeAppCurrency(appCurrency ?: DEFAULT_CURRENCY_CODE)
}
}

View file

@ -3,8 +3,8 @@ package com.tangem.data.appcurrency.di
import com.tangem.data.appcurrency.DefaultAppCurrencyRepository
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.datasource.local.appcurrency.AvailableAppCurrenciesStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -21,17 +21,17 @@ internal object AppCurrencyDataModule {
@Singleton
fun provideAppCurrencyRepository(
tangemTechApi: TangemTechApi,
appPreferencesStore: AppPreferencesStore,
appCurrencyResponseStore: AppCurrencyResponseStore,
availableAppCurrenciesStore: AvailableAppCurrenciesStore,
cacheRegistry: CacheRegistry,
dispatchers: CoroutineDispatcherProvider,
): AppCurrencyRepository {
return DefaultAppCurrencyRepository(
tangemTechApi = tangemTechApi,
appPreferencesStore = appPreferencesStore,
availableAppCurrenciesStore = availableAppCurrenciesStore,
cacheRegistry = cacheRegistry,
dispatchers = dispatchers,
appCurrencyResponseStore = appCurrencyResponseStore,
)
}
}

View file

@ -27,6 +27,7 @@ dependencies {
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.networks)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets)
/* Libs - SDK */

View file

@ -144,6 +144,9 @@ internal class DefaultCardCryptoCurrencyFactory(
?: return emptyMap()
response.accounts.flatMapTo(hashSetOf()) { accountDTO ->
val accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull()
?: return@flatMapTo emptySet()
responseCryptoCurrenciesFactory.createCurrencies(
tokens = accountDTO.tokens.orEmpty().filter { token ->
networks.any {
@ -151,7 +154,7 @@ internal class DefaultCardCryptoCurrencyFactory(
}
},
userWallet = userWallet,
accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull(),
accountIndex = accountIndex,
)
}
} else {
@ -163,6 +166,7 @@ internal class DefaultCardCryptoCurrencyFactory(
networks.any { it.backendId == token.networkId && it.derivationPath.value == token.derivationPath }
},
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
.groupBy(CryptoCurrency::network)
@ -181,10 +185,13 @@ internal class DefaultCardCryptoCurrencyFactory(
?: return emptyMap()
response.accounts.flatMapTo(hashSetOf()) { accountDTO ->
val accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull()
?: return@flatMapTo emptySet()
responseCryptoCurrenciesFactory.createCurrencies(
tokens = accountDTO.tokens.orEmpty().filter { token -> token.networkId in networkIds },
userWallet = userWallet,
accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull(),
accountIndex = accountIndex,
)
}
} else {
@ -194,6 +201,7 @@ internal class DefaultCardCryptoCurrencyFactory(
responseCryptoCurrenciesFactory.createCurrencies(
tokens = response.tokens.filter { token -> token.networkId in networkIds },
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
.groupBy { it.network.id.rawId }

View file

@ -22,7 +22,7 @@ class ResponseCryptoCurrenciesFactory @Inject constructor(
fun createCurrencies(
response: UserTokensResponse,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
accountIndex: DerivationIndex,
): List<CryptoCurrency> {
return createCurrencies(tokens = response.tokens, userWallet = userWallet, accountIndex = accountIndex)
}
@ -30,7 +30,7 @@ class ResponseCryptoCurrenciesFactory @Inject constructor(
fun createCurrencies(
tokens: List<UserTokensResponse.Token>,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
accountIndex: DerivationIndex,
): List<CryptoCurrency> {
return tokens
.asSequence()
@ -42,7 +42,7 @@ class ResponseCryptoCurrenciesFactory @Inject constructor(
fun createCurrency(
responseToken: UserTokensResponse.Token,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
accountIndex: DerivationIndex,
): CryptoCurrency? {
var blockchain = Blockchain.fromNetworkId(responseToken.networkId)
if (blockchain == null || blockchain == Blockchain.Unknown) {
@ -103,7 +103,7 @@ class ResponseCryptoCurrenciesFactory @Inject constructor(
blockchain: Blockchain,
responseToken: UserTokensResponse.Token,
network: Network,
): CryptoCurrency.Coin? {
): CryptoCurrency.Coin {
return CryptoCurrency.Coin(
id = getCoinId(network, blockchain.toCoinId()),
network = network,
@ -127,7 +127,7 @@ class ResponseCryptoCurrenciesFactory @Inject constructor(
}
}
private fun createToken(blockchain: Blockchain, sdkToken: Token, network: Network): CryptoCurrency.Token? {
private fun createToken(blockchain: Blockchain, sdkToken: Token, network: Network): CryptoCurrency.Token {
val id = getTokenId(network, sdkToken)
return CryptoCurrency.Token(

View file

@ -1,55 +1,45 @@
package com.tangem.data.common.currency
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.address.Address
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
class UserTokensResponseAddressesEnricher @Inject constructor(
private val walletsRepository: WalletsRepository,
private val dispatchers: CoroutineDispatcherProvider,
private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
private val walletManagersFacade: WalletManagersFacade,
) {
suspend operator fun invoke(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse {
val isNotificationsEnabled = walletsRepository.isNotificationsEnabled(userWalletId)
return withContext(dispatchers.default) {
val networksStatuses = if (isNotificationsEnabled) {
withTimeoutOrNull(
FETCH_TIMEOUT_SECONDS.seconds,
{ multiNetworkStatusSupplier.invoke(MultiNetworkStatusProducer.Params(userWalletId)).first() },
).orEmpty()
val addressByToken = if (isNotificationsEnabled) {
response.tokens.associateWith { token ->
val blockchain = Blockchain.fromNetworkId(token.networkId) ?: return@associateWith null
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = token.derivationPath,
)
walletManager?.wallet?.addresses?.map(Address::value)
}
} else {
emptySet()
emptyMap()
}
val enrichedTokens = response.tokens.map { token ->
if (isNotificationsEnabled) {
val matchingNetwork = networksStatuses.find { status ->
status.network.backendId == token.networkId &&
status.network.derivationPath.value == token.derivationPath
} ?: return@map token
val networkAddress = when (matchingNetwork.value) {
is NetworkStatus.Verified -> (matchingNetwork.value as NetworkStatus.Verified).address
is NetworkStatus.NoAccount -> (matchingNetwork.value as NetworkStatus.NoAccount).address
else -> null
}
val addresses = networkAddress
?.availableAddresses
?.map { it.value }
?.toList()
.orEmpty()
val addresses = addressByToken[token] ?: return@map token
token.copy(addresses = addresses)
} else {
@ -60,8 +50,4 @@ class UserTokensResponseAddressesEnricher @Inject constructor(
response.copy(tokens = enrichedTokens, notifyStatus = isNotificationsEnabled)
}
}
companion object {
private const val FETCH_TIMEOUT_SECONDS = 3
}
}

View file

@ -2,12 +2,17 @@ package com.tangem.data.common.currency
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.tokens.UserTokensBackwardCompatibility
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.isNetworkError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.WalletType
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.retryer.Retryer
@ -50,24 +55,26 @@ class UserTokensSaver(
response: UserTokensResponse,
useEnricher: Boolean = true,
onFailSend: () -> Unit = {},
) {
withContext(dispatchers.default) {
val userWallet = userWalletsStore.getSyncOrNull(key = userWalletId)
) = withContext(dispatchers.io) {
val userWallet = userWalletsStore.getSyncOrNull(key = userWalletId)
if (userWallet == null) {
Timber.e("UserWallet with id $userWalletId not found. Cannot push tokens.")
onFailSend()
return@withContext
}
if (accountsFeatureToggles.isFeatureEnabled) {
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher)
pushNew(userWallet = userWallet, response = enrichedResponse, onFailSend = onFailSend)
} else {
val enrichedResponse = response.enrichIf(userWalletId = userWalletId, condition = useEnricher).copy(
walletName = userWallet?.name,
walletName = userWallet.name,
walletType = WalletType.from(userWallet),
)
safeApiCall(
call = {
withContext(dispatchers.io) {
tangemTechApi.saveUserTokens(userId = userWalletId.stringValue, userTokens = enrichedResponse)
.bind()
}
},
onError = { onFailSend() },
)
pushLegacy(userWalletId = userWalletId, response = enrichedResponse, onFailSend = onFailSend)
}
}
@ -88,6 +95,39 @@ class UserTokensSaver(
)
}
private suspend fun pushLegacy(userWalletId: UserWalletId, response: UserTokensResponse, onFailSend: () -> Unit) {
safeApiCall(
call = { tangemTechApi.saveUserTokens(userId = userWalletId.stringValue, userTokens = response).bind() },
onError = { onFailSend() },
)
}
private suspend fun pushNew(userWallet: UserWallet, response: UserTokensResponse, onFailSend: () -> Unit) {
safeApiCall(
call = {
val apiResponse = tangemTechApi.saveTokens(
userId = userWallet.walletId.stringValue,
userTokens = response,
)
val isWalletNotFound = apiResponse is ApiResponse.Error &&
apiResponse.cause.isNetworkError(ApiResponseError.HttpException.Code.NOT_FOUND)
if (isWalletNotFound) {
tangemTechApi.createWallet(body = WalletIdBodyConverter.convert(userWallet)).bind()
tangemTechApi.saveTokens(
userId = userWallet.walletId.stringValue,
userTokens = response,
).bind()
} else {
apiResponse.bind()
}
},
onError = { onFailSend() },
)
}
private fun UserTokensResponse.applyCompatibility(): UserTokensResponse {
return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this)
}

View file

@ -13,7 +13,7 @@ import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.retryer.RetryerPool
@ -54,12 +54,12 @@ internal object DataCommonModule {
@Singleton
fun provideUserTokensEncricher(
walletsRepository: WalletsRepository,
multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
): UserTokensResponseAddressesEnricher {
return UserTokensResponseAddressesEnricher(
walletsRepository = walletsRepository,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
walletManagersFacade = walletManagersFacade,
dispatchers = dispatchers,
)
}

View file

@ -74,6 +74,7 @@ class NetworkFactory @Inject constructor(
blockchain = blockchain,
excludedBlockchains = excludedBlockchains,
),
shouldCheckChia = false,
)
}
@ -128,9 +129,10 @@ class NetworkFactory @Inject constructor(
derivationPath: Network.DerivationPath,
canHandleTokens: Boolean,
accountIndex: DerivationIndex? = null,
shouldCheckChia: Boolean = true,
): Network? {
if (!blockchain.isBlockchainSupported()) return null
if (blockchain == Blockchain.Chia && accountIndex != DerivationIndex.Main) return null
if (shouldCheckChia && blockchain == Blockchain.Chia && accountIndex != DerivationIndex.Main) return null
return runCatching {
Network(

View file

@ -1,92 +1,68 @@
package com.tangem.data.common.currency
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Wallet
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.common.address.Address
import com.tangem.blockchain.common.address.AddressType
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearAllMocks
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserTokensResponseAddressesEnricherTest {
private lateinit var walletsRepository: WalletsRepository
private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider()
private lateinit var multiNetworkStatusSupplier: MultiNetworkStatusSupplier
private lateinit var enricher: UserTokensResponseAddressesEnricher
private val walletsRepository: WalletsRepository = mockk()
private val walletManagersFacade: WalletManagersFacade = mockk()
private val enricher: UserTokensResponseAddressesEnricher = UserTokensResponseAddressesEnricher(
walletsRepository = walletsRepository,
walletManagersFacade = walletManagersFacade,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Before
fun setup() {
walletsRepository = mockk()
multiNetworkStatusSupplier = mockk()
private val userWalletId = UserWalletId("1234567890abcdef")
enricher = UserTokensResponseAddressesEnricher(
walletsRepository = walletsRepository,
dispatchers = dispatchers,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
)
}
@After
@AfterEach
fun tearDown() {
clearAllMocks()
}
@Test
fun `GIVEN notifications are disabled globally WHEN invoke THEN return original response`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val token = createToken()
val response = createUserTokensResponse(tokens = listOf(token))
// WHEN
val result = enricher(userWalletId, response)
// THEN
assertThat(result).isEqualTo(response)
clearMocks(walletsRepository, walletManagersFacade)
}
@Test
fun `GIVEN notifications are disabled for wallet WHEN invoke THEN return response with empty addresses`() =
runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val token = createToken()
val response = createUserTokensResponse(tokens = listOf(token))
val walletManager = mockk<WalletManager> {
val wallet = mockk<Wallet> {
every { addresses } returns setOf(
Address(value = "0x12345", type = AddressType.Default),
)
}
every { this@mockk.wallet } returns wallet
}
coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns false
coEvery {
multiNetworkStatusSupplier.invoke(any())
} returns flowOf(
setOf(
NetworkStatus(
network = mockk {
every { backendId } returns "ethereum"
every { derivationPath.value } returns "m/44'/60'/0'/0/0"
},
value = NetworkStatus.Verified(
address = mockk {
every { availableAddresses } returns emptySet()
},
amounts = emptyMap(),
pendingTransactions = emptyMap(),
yieldSupplyStatuses = emptyMap(),
source = StatusSource.ACTUAL,
),
),
),
)
walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = Blockchain.Ethereum,
derivationPath = token.derivationPath,
)
} returns walletManager
// WHEN
val result = enricher(userWalletId, response)
@ -100,75 +76,52 @@ class UserTokensResponseAddressesEnricherTest {
fun `GIVEN notifications are enabled and addresses available WHEN invoke THEN return enriched response`() =
runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val token = createToken()
val response = createUserTokensResponse(tokens = listOf(token))
val addresses = listOf("0x123", "0x456")
val addresses = setOf(
Address(value = "0x123", type = AddressType.Default),
Address(value = "0x456", type = AddressType.Legacy),
)
val walletManager = mockk<WalletManager> {
val wallet = mockk<Wallet> {
every { this@mockk.addresses } returns addresses
}
every { this@mockk.wallet } returns wallet
}
coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns true
coEvery {
multiNetworkStatusSupplier.invoke(any())
} returns flowOf(
setOf(
NetworkStatus(
network = mockk {
every { backendId } returns "ethereum"
every { derivationPath.value } returns "m/44'/60'/0'/0/0"
},
value = NetworkStatus.Verified(
address = mockk {
every { availableAddresses } returns addresses.map { address ->
mockk<NetworkAddress.Address> {
every { value } returns address
}
}.toSet()
},
amounts = emptyMap(),
pendingTransactions = emptyMap(),
yieldSupplyStatuses = emptyMap(),
source = StatusSource.ACTUAL,
),
),
),
)
walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = Blockchain.Ethereum,
derivationPath = token.derivationPath,
)
} returns walletManager
// WHEN
val result = enricher(userWalletId, response)
// THEN
assertThat(result.tokens).hasSize(1)
assertThat(result.tokens[0].addresses).containsExactlyElementsIn(addresses)
assertThat(result.tokens[0].addresses).containsExactlyElementsIn(addresses.map { it.value })
}
@Test
fun `GIVEN notifications are enabled but no matching network WHEN invoke THEN return original token`() = runTest {
// GIVEN
val userWalletId = UserWalletId("1234567890abcdef")
val token = createToken()
val response = createUserTokensResponse(tokens = listOf(token))
coEvery { walletsRepository.isNotificationsEnabled(userWalletId) } returns true
coEvery {
multiNetworkStatusSupplier.invoke(any())
} returns flowOf(
setOf(
NetworkStatus(
network = mockk {
every { backendId } returns "bitcoin"
every { derivationPath.value } returns "m/44'/0'/0'/0/0"
},
value = NetworkStatus.Verified(
address = mockk {
every { availableAddresses } returns emptySet()
},
amounts = emptyMap(),
pendingTransactions = emptyMap(),
yieldSupplyStatuses = emptyMap(),
source = StatusSource.ACTUAL,
),
),
),
)
walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = Blockchain.Ethereum,
derivationPath = token.derivationPath,
)
} returns null
// WHEN
val result = enricher(userWalletId, response)

View file

@ -73,7 +73,7 @@ class UserTokensSaverTest {
}
coVerify(inverse = true) {
tangemTechApi.saveUserTokens(any(), any())
tangemTechApi.saveTokens(any(), any())
}
}
@ -108,7 +108,8 @@ class UserTokensSaverTest {
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
coEvery { enricher(userWalletId, response) } returns enrichedResponse
coEvery { tangemTechApi.saveUserTokens(any(), any()) } returns ApiResponse.Error(error) as ApiResponse<Unit>
coEvery { tangemTechApi.saveTokens(any(), any()) } returns ApiResponse.Error(error) as ApiResponse<Unit>
coEvery { tangemTechApi.createWallet(body = any()) } returns ApiResponse.Error(error) as ApiResponse<Unit>
// WHEN
userTokensSaver.push(
@ -120,7 +121,7 @@ class UserTokensSaverTest {
// THEN
coVerifyOrder {
enricher(userWalletId, response)
tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse)
tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse)
}
assert(onFailSendCalled) { "onFailSend callback should be called when API call fails" }
@ -155,7 +156,7 @@ class UserTokensSaverTest {
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
coEvery { enricher(userWalletId, response) } returns enrichedResponse
coEvery {
tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse)
tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse)
} returns ApiResponse.Success(Unit)
// WHEN
@ -164,7 +165,7 @@ class UserTokensSaverTest {
// THEN
coVerifyOrder {
enricher(userWalletId, response)
tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse)
tangemTechApi.saveTokens(userWalletId.stringValue, enrichedResponse)
}
}
}

View file

@ -1,9 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:DefaultFeedbackRepository.kt$DefaultFeedbackRepository$private val useNewUserWalletsRepository: Boolean</ID>
<ID>MultilineLambdaItParameter:DefaultFeedbackRepository.kt$DefaultFeedbackRepository${ it.toMutableMap().apply { put(userWallet.walletId, error) } }</ID>
<ID>UseOrEmpty:BlockchainInfoConverter.kt$BlockchainInfoConverter$value.wallet.publicKey.derivationPath?.rawPath ?: ""</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -26,7 +26,7 @@ import java.io.File
*
* @property appLogsStore app logs store
* @property userWalletsListManager user wallets list manager
* @property useNewUserWalletsRepository flag to use new user wallets repository
* @property shouldUseNewUserWalletsRepository flag to use new user wallets repository
* @property userWalletsListRepository user wallets repository
* @property walletManagersStore wallet managers store
* @property emailSender email sender
@ -37,7 +37,7 @@ import java.io.File
@Suppress("LongParameterList")
internal class DefaultFeedbackRepository(
private val appLogsStore: AppLogsStore,
private val useNewUserWalletsRepository: Boolean,
private val shouldUseNewUserWalletsRepository: Boolean,
private val userWalletsListRepository: UserWalletsListRepository,
private val userWalletsListManager: UserWalletsListManager,
private val walletManagersStore: WalletManagersStore,
@ -97,9 +97,9 @@ internal class DefaultFeedbackRepository(
override fun saveBlockchainErrorInfo(error: BlockchainErrorInfo) {
val userWallet = getSelectedWalletUseCase.sync().getOrNull() ?: error("UserWallet is not selected")
blockchainsErrors.update {
it.toMutableMap().apply {
put(userWallet.walletId, error)
blockchainsErrors.update { map ->
map.toMutableMap().apply {
this[userWallet.walletId] = error
}
}
}
@ -126,7 +126,7 @@ internal class DefaultFeedbackRepository(
}
private suspend fun getUserWalletById(userWalletId: UserWalletId): UserWallet? {
return if (useNewUserWalletsRepository) {
return if (shouldUseNewUserWalletsRepository) {
userWalletsListRepository.userWalletsSync().find { it.walletId == userWalletId }
} else {
userWalletsListManager.userWalletsSync.find { it.walletId == userWalletId }
@ -134,7 +134,7 @@ internal class DefaultFeedbackRepository(
}
private fun totalUserWallets(): Int {
return if (useNewUserWalletsRepository) {
return if (shouldUseNewUserWalletsRepository) {
userWalletsListRepository.userWallets.value?.size ?: 0
} else {
userWalletsListManager.walletsCount

View file

@ -16,9 +16,11 @@ import com.tangem.domain.feedback.models.BlockchainInfo.Addresses as BlockchainA
internal object BlockchainInfoConverter : Converter<WalletManager, BlockchainInfo> {
override fun convert(value: WalletManager): BlockchainInfo {
val derivationPath = value.wallet.publicKey.derivationPath
return BlockchainInfo(
blockchain = value.wallet.blockchain.fullName,
derivationPath = value.wallet.publicKey.derivationPath?.rawPath ?: "",
derivationPath = derivationPath?.rawPath.orEmpty(),
outputsCount = value.outputsCount?.toString(),
host = value.currentHost,
addresses = value.wallet.mapAddresses(Address::value),

View file

@ -42,7 +42,7 @@ internal object FeedbackModule {
emailSender = emailSender,
appVersionProvider = appVersionProvider,
userWalletsListRepository = userWalletsListRepository,
useNewUserWalletsRepository = hotWalletFeatureToggles.isHotWalletEnabled,
shouldUseNewUserWalletsRepository = hotWalletFeatureToggles.isHotWalletEnabled,
getSelectedWalletUseCase = getSelectedWalletUseCase,
)
}

View file

@ -1,5 +1,7 @@
package com.tangem.data.hotwallet
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMap
@ -12,6 +14,13 @@ internal class DefaultHotWalletRepository(
private val appPreferencesStore: AppPreferencesStore,
) : HotWalletRepository {
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q)
override fun isWalletCreationSupported(): Boolean {
return BuildConfig.DEBUG || Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
}
override fun getLeastSupportedAndroidVersionName(): String = "Android 10"
override fun accessCodeSkipped(userWalletId: UserWalletId): Flow<Boolean> = appPreferencesStore
.getObjectMap<Boolean>(PreferencesKeys.ACCESS_CODE_SKIPPED_STATES_KEY)
.map { it[userWalletId.stringValue] == true }

View file

@ -3,15 +3,7 @@
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:DefaultCustomTokensRepository.kt$DefaultCustomTokensRepository${ // TODO: refactor https://tangem.atlassian.net/browse/AND-10006\ if (it.isTestnet() || it in excludedBlockchains || it in hotWalletExcludedBlockchains) { return@mapNotNull null } networkFactory.create( blockchain = it, extraDerivationPath = null, userWallet = userWallet, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultManageTokensRepository.kt$DefaultManageTokensRepository${ it.contractAddress != null &amp;&amp; it.networkId == network.backendId &amp;&amp; it.derivationPath == network.derivationPath.value }</ID>
<ID>MultilineLambdaItParameter:ManageTokensUpdateFetcher.kt$ManageTokensUpdateFetcher${ if (it.key == toUpdate[index].key) { Batch(it.key, updatedItems) } else { null } }</ID>
<ID>NamedArguments:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$create(coinsResponse, tokensResponse, userWallet, accountIndex)</ID>
<ID>NamedArguments:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$createToken(coin, tokensResponse, coinsResponse.imageHost, userWallet, accountIndex)</ID>
<ID>NamedArguments:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$findAddedInNetworks(coinResponse.id, tokensResponse, userWallet, accountIndex)</ID>
<ID>NamedArguments:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$findAddedInNetworks(testnetToken.id, tokensResponse, userWallet, accountIndex)</ID>
<ID>SuspendFunSwallowedCancellation:DefaultManageTokensRepository.kt$DefaultManageTokensRepository$runCatching</ID>
<ID>UnsafeCallOnNullableType:DefaultCustomTokensRepository.kt$DefaultCustomTokensRepository$coinNetwork.decimalCount!!</ID>
<ID>UseOrEmpty:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$testnetToken.networks?.mapNotNull { network -&gt; createSource( networkId = network.id, contractAddress = network.address, decimals = network.decimalCount, userWallet = userWallet, accountIndex = accountIndex, ) } ?: emptyList()</ID>
<ID>UseOrEmpty:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$tokensResponse ?.let { createCustomTokens(it, userWallet, accountIndex) } ?: emptyList()</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -41,6 +41,7 @@ import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher
import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher.Request
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
@Suppress("LongParameterList", "LargeClass")
internal class DefaultManageTokensRepository(
@ -176,7 +177,7 @@ internal class DefaultManageTokensRepository(
val shouldFetch = loadUserTokensFromRemote && userWallet != null
val fetchedResponse = if (shouldFetch) {
runCatching { walletAccountsFetcher.fetch(userWalletId = userWallet.walletId) }.getOrNull()
runSuspendCatching { walletAccountsFetcher.fetch(userWalletId = userWallet.walletId) }.getOrNull()
} else {
null
}
@ -214,19 +215,22 @@ internal class DefaultManageTokensRepository(
userWallet != null &&
query == null
val accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull()
?: return emptyList()
val items = if (isCreateWithCustom) {
managedCryptoCurrencyFactory.createWithCustomTokens(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull(),
accountIndex = accountIndex,
)
} else {
managedCryptoCurrencyFactory.create(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull(),
accountIndex = accountIndex,
)
}
@ -262,14 +266,14 @@ internal class DefaultManageTokensRepository(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = null,
accountIndex = DerivationIndex.Main,
)
} else {
managedCryptoCurrencyFactory.create(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = null,
accountIndex = DerivationIndex.Main,
)
}
}
@ -307,6 +311,13 @@ internal class DefaultManageTokensRepository(
)
}
val accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull()
?: return BatchFetchResult.Success(
data = emptyList(),
empty = true,
last = true,
)
val items = managedCryptoCurrencyFactory.createTestnetWithCustomTokens(
testnetTokensConfig = if (!searchText.isNullOrBlank()) {
testnetTokensConfig.copy(
@ -320,7 +331,7 @@ internal class DefaultManageTokensRepository(
},
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull(),
accountIndex = accountIndex,
)
return BatchFetchResult.Success(
@ -350,7 +361,7 @@ internal class DefaultManageTokensRepository(
},
tokensResponse = getSavedUserTokensResponseSync(userWallet.walletId),
userWallet = userWallet,
accountIndex = null,
accountIndex = DerivationIndex.Main,
)
return BatchFetchResult.Success(
@ -392,10 +403,10 @@ internal class DefaultManageTokensRepository(
)
val newTokensList = storedTokens.tokens + addedTokens - removedTokens.toSet()
return newTokensList.any {
it.contractAddress != null &&
it.networkId == network.backendId &&
it.derivationPath == network.derivationPath.value
return newTokensList.any { token ->
token.contractAddress != null &&
token.networkId == network.backendId &&
token.derivationPath == network.derivationPath.value
}
}

View file

@ -35,10 +35,16 @@ internal class ManagedCryptoCurrencyFactory(
coinsResponse: CoinsResponse,
tokensResponse: UserTokensResponse?,
userWallet: UserWallet?,
accountIndex: DerivationIndex?,
accountIndex: DerivationIndex,
): List<ManagedCryptoCurrency> {
return coinsResponse.coins.mapNotNull { coin ->
createToken(coin, tokensResponse, coinsResponse.imageHost, userWallet, accountIndex)
createToken(
coinResponse = coin,
tokensResponse = tokensResponse,
imageHost = coinsResponse.imageHost,
userWallet = userWallet,
accountIndex = accountIndex,
)
}
}
@ -46,10 +52,15 @@ internal class ManagedCryptoCurrencyFactory(
coinsResponse: CoinsResponse,
tokensResponse: UserTokensResponse,
userWallet: UserWallet,
accountIndex: DerivationIndex?,
accountIndex: DerivationIndex,
): List<ManagedCryptoCurrency> {
val customTokens = createCustomTokens(tokensResponse, userWallet, accountIndex)
val tokens = create(coinsResponse, tokensResponse, userWallet, accountIndex)
val tokens = create(
coinsResponse = coinsResponse,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = accountIndex,
)
return customTokens + tokens
}
@ -58,11 +69,11 @@ internal class ManagedCryptoCurrencyFactory(
testnetTokensConfig: TestnetTokensConfig,
tokensResponse: UserTokensResponse?,
userWallet: UserWallet,
accountIndex: DerivationIndex?,
accountIndex: DerivationIndex,
): List<ManagedCryptoCurrency> {
val customTokens = tokensResponse
?.let { createCustomTokens(it, userWallet, accountIndex) }
?: emptyList()
.orEmpty()
val testnetTokens = testnetTokensConfig.tokens.map { testnetToken ->
ManagedCryptoCurrency.Token(
id = ManagedCryptoCurrency.ID(testnetToken.id),
@ -77,8 +88,13 @@ internal class ManagedCryptoCurrencyFactory(
userWallet = userWallet,
accountIndex = accountIndex,
)
} ?: emptyList(),
addedIn = findAddedInNetworks(testnetToken.id, tokensResponse, userWallet, accountIndex),
}.orEmpty(),
addedIn = findAddedInNetworks(
currencyId = testnetToken.id,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = accountIndex,
),
)
}
@ -88,7 +104,7 @@ internal class ManagedCryptoCurrencyFactory(
private fun createCustomTokens(
tokensResponse: UserTokensResponse,
userWallet: UserWallet,
accountIndex: DerivationIndex?,
accountIndex: DerivationIndex,
): List<ManagedCryptoCurrency> = tokensResponse.tokens
.mapNotNull { token ->
maybeCreateCustomToken(token, userWallet, accountIndex)
@ -97,7 +113,7 @@ internal class ManagedCryptoCurrencyFactory(
private fun maybeCreateCustomToken(
token: UserTokensResponse.Token,
userWallet: UserWallet,
accountIndex: DerivationIndex?,
accountIndex: DerivationIndex,
): ManagedCryptoCurrency? {
val blockchain = Blockchain.fromNetworkId(token.networkId)
?.takeUnless { it in excludedBlockchains }
@ -161,7 +177,7 @@ internal class ManagedCryptoCurrencyFactory(
tokensResponse: UserTokensResponse?,
imageHost: String?,
userWallet: UserWallet?,
accountIndex: DerivationIndex?,
accountIndex: DerivationIndex,
): ManagedCryptoCurrency? {
if (coinResponse.networks.isEmpty() || !coinResponse.active) return null
@ -184,7 +200,12 @@ internal class ManagedCryptoCurrencyFactory(
symbol = coinResponse.symbol,
iconUrl = getIconUrl(coinResponse.id, imageHost),
availableNetworks = availableNetworks,
addedIn = findAddedInNetworks(coinResponse.id, tokensResponse, userWallet, accountIndex),
addedIn = findAddedInNetworks(
currencyId = coinResponse.id,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = accountIndex,
),
)
}
@ -194,7 +215,7 @@ internal class ManagedCryptoCurrencyFactory(
decimals: Int?,
userWallet: UserWallet?,
extraDerivationPath: String? = null,
accountIndex: DerivationIndex?,
accountIndex: DerivationIndex,
): SourceNetwork? {
val blockchain = Blockchain.fromNetworkId(networkId)
?.takeUnless { it in excludedBlockchains }
@ -235,7 +256,7 @@ internal class ManagedCryptoCurrencyFactory(
currencyId: String,
tokensResponse: UserTokensResponse?,
userWallet: UserWallet?,
accountIndex: DerivationIndex?,
accountIndex: DerivationIndex,
): Set<Network> {
if (tokensResponse == null) return emptySet()

View file

@ -12,6 +12,10 @@ android {
namespace = "com.tangem.data.nft"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Project - Data */
@ -53,4 +57,8 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
testImplementation(projects.test.core)
testImplementation(projects.common.test)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -1,5 +1,6 @@
package com.tangem.data.nft
import android.content.Context
import android.content.res.Resources
import arrow.core.Either
import com.tangem.blockchain.common.Blockchain
@ -26,15 +27,19 @@ import com.tangem.domain.nft.models.NFTCollection
import com.tangem.domain.nft.models.NFTCollections
import com.tangem.domain.nft.models.NFTSalePrice
import com.tangem.domain.nft.repository.NFTRepository
import com.tangem.domain.nft.utils.NFTCleaner
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.coroutines.saveIn
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
@ -49,9 +54,10 @@ internal class DefaultNFTRepository @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val networkFactory: NetworkFactory,
private val excludedBlockchains: ExcludedBlockchains,
resources: Resources,
) : NFTRepository {
@ApplicationContext private val context: Context,
) : NFTRepository, NFTCleaner {
private val resources: Resources by lazy { context.resources }
private val networkJobs = ConcurrentHashMap<Network, JobHolder>()
private val collectionJobs = ConcurrentHashMap<NFTCollection.Identifier, JobHolder>()
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
@ -218,10 +224,22 @@ internal class DefaultNFTRepository @Inject constructor(
assetIdentifier = assetIdConverter.convertBack(assetIdentifier),
)
override suspend fun clearCache(userWalletId: UserWalletId, networks: List<Network>) {
networks.forEach {
getNFTPersistenceStore(userWalletId, it).clear()
getNFTRuntimeStore(userWalletId, it).clear()
// NFTCleaner implementation
override suspend fun invoke(userWalletId: UserWalletId, networks: Set<Network>) {
if (networks.isEmpty()) {
Timber.d("No networks to clear for wallet: $userWalletId")
return
}
networks.forEach { network ->
runSuspendCatching {
getNFTPersistenceStore(userWalletId = userWalletId, network = network).clear()
// FIXME: nftRuntimeStore is created with only network, so clearing it may affect other wallets
// nftRuntimeStoreFactory.provide(network = network).clear()
}
.onFailure { throwable ->
Timber.e(throwable, "Failed to clear NFT data for network $network for wallet: $userWalletId")
}
}
}

View file

@ -1,45 +1,23 @@
package com.tangem.data.nft.di
import android.content.Context
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.nft.DefaultNFTRepository
import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory
import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.nft.repository.NFTRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.domain.nft.utils.NFTCleaner
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object NFTDataModule {
internal interface NFTDataModule {
@Provides
@Binds
@Singleton
fun provideNFTRepository(
@ApplicationContext context: Context,
nftPersistenceStoreFactory: NFTPersistenceStoreFactory,
nftRuntimeStoreFactory: NFTRuntimeStoreFactory,
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
excludedBlockchains: ExcludedBlockchains,
userWalletsStore: UserWalletsStore,
networkFactory: NetworkFactory,
): NFTRepository = DefaultNFTRepository(
nftPersistenceStoreFactory = nftPersistenceStoreFactory,
nftRuntimeStoreFactory = nftRuntimeStoreFactory,
walletManagersFacade = walletManagersFacade,
dispatchers = dispatchers,
excludedBlockchains = excludedBlockchains,
userWalletsStore = userWalletsStore,
networkFactory = networkFactory,
resources = context.resources,
)
fun bindNFTRepository(defaultNFTRepository: DefaultNFTRepository): NFTRepository
@Binds
@Singleton
fun bindNFTCleaner(defaultNFTRepository: DefaultNFTRepository): NFTCleaner
}

View file

@ -0,0 +1,78 @@
package com.tangem.data.nft
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.datasource.local.nft.NFTPersistenceStore
import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory
import com.tangem.datasource.local.nft.NFTRuntimeStore
import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class NFTCleanerTest {
private val nftPersistenceStoreFactory: NFTPersistenceStoreFactory = mockk()
private val nftRuntimeStoreFactory: NFTRuntimeStoreFactory = mockk()
private val nftCleaner = DefaultNFTRepository(
nftPersistenceStoreFactory = nftPersistenceStoreFactory,
nftRuntimeStoreFactory = nftRuntimeStoreFactory,
walletManagersFacade = mockk(),
dispatchers = mockk(),
userWalletsStore = mockk(),
networkFactory = mockk(),
excludedBlockchains = mockk(),
context = mockk(),
)
private val userWalletId = UserWalletId("011")
@AfterEach
fun tearDown() {
clearMocks(nftPersistenceStoreFactory, nftRuntimeStoreFactory)
}
@Test
fun `should call invoke with multiple networks`() = runTest {
// Arrange
val mockCryptoCurrencyFactory = MockCryptoCurrencyFactory()
val networks = mockCryptoCurrencyFactory.ethereumAndStellar.map(CryptoCurrency.Coin::network)
val persistenceByNetwork = networks.associateWith { mockk<NFTPersistenceStore>(relaxUnitFun = true) }
val runtimeByNetwork = networks.associateWith { mockk<NFTRuntimeStore>(relaxUnitFun = true) }
networks.forEach { network ->
every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceByNetwork[network]!!
every { nftRuntimeStoreFactory.provide(network) } returns runtimeByNetwork[network]!!
}
// Act
nftCleaner.invoke(userWalletId = userWalletId, networks = networks.toSet())
// Assert
coVerifyOrder {
networks.forEach { network ->
nftPersistenceStoreFactory.provide(userWalletId, network)
persistenceByNetwork[network]!!.clear()
// nftRuntimeStoreFactory.provide(network)
// runtimeByNetwork[network]!!.clear()
}
}
}
@Test
fun `should handle empty networks set`() = runTest {
// Act
nftCleaner.invoke(userWalletId, emptySet())
// Assert
coVerify(inverse = true) {
nftPersistenceStoreFactory.provide(userWalletId = any(), network = any())
nftRuntimeStoreFactory.provide(network = any())
}
}
}

View file

@ -11,13 +11,10 @@ import com.tangem.data.onramp.converters.HotCryptoCurrencyConverter
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.api.tangemTech.models.HotCryptoResponse
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.datasource.exchangeservice.hotcrypto.HotCryptoResponseStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.card.common.extensions.canHandleBlockchain
@ -39,13 +36,13 @@ import timber.log.Timber
/**
* Default implementation of [HotCryptoRepository]
*
* @property excludedBlockchains excluded blockchains
* @property hotCryptoResponseStore store of `HotCryptoResponse`
* @property userWalletsStore store of `UserWallet`
* @property tangemTechApi tangem tech api
* @property appPreferencesStore app preferences store
* @property dispatchers dispatchers
* @property analyticsEventHandler analytics event handler
* @property excludedBlockchains excluded blockchains
* @property hotCryptoResponseStore store of `HotCryptoResponse`
* @property userWalletsStore store of `UserWallet`
* @property tangemTechApi tangem tech api
* @property appCurrencyResponseStore store of current app currency
* @property dispatchers dispatchers
* @property analyticsEventHandler analytics event handler
*
[REDACTED_AUTHOR]
*/
@ -56,7 +53,7 @@ internal class DefaultHotCryptoRepository(
private val hotCryptoResponseStore: HotCryptoResponseStore,
private val userWalletsStore: UserWalletsStore,
private val tangemTechApi: TangemTechApi,
private val appPreferencesStore: AppPreferencesStore,
private val appCurrencyResponseStore: AppCurrencyResponseStore,
private val userTokensResponseStore: UserTokensResponseStore,
private val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
@ -112,8 +109,8 @@ internal class DefaultHotCryptoRepository(
}
private fun getHotCryptoFlow(): Flow<HotCryptoResponse?> {
return appPreferencesStore
.getObject<CurrenciesResponse.Currency>(key = PreferencesKeys.SELECTED_APP_CURRENCY_KEY)
return appCurrencyResponseStore
.get()
.map { it?.id ?: "usd" }
.distinctUntilChanged()
.map { getHotCrypto(appCurrencyId = it).getOrNull() }

View file

@ -13,6 +13,7 @@ import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.exchangeservice.hotcrypto.HotCryptoResponseStore
@ -104,7 +105,7 @@ internal object OnrampDataModule {
hotCryptoResponseStore: HotCryptoResponseStore,
userWalletsStore: UserWalletsStore,
tangemTechApi: TangemTechApi,
appPreferencesStore: AppPreferencesStore,
appCurrencyResponseStore: AppCurrencyResponseStore,
dispatchers: CoroutineDispatcherProvider,
analyticsEventHandler: AnalyticsEventHandler,
userTokensResponseStore: UserTokensResponseStore,
@ -114,7 +115,7 @@ internal object OnrampDataModule {
hotCryptoResponseStore = hotCryptoResponseStore,
userWalletsStore = userWalletsStore,
tangemTechApi = tangemTechApi,
appPreferencesStore = appPreferencesStore,
appCurrencyResponseStore = appCurrencyResponseStore,
dispatchers = dispatchers,
analyticsEventHandler = analyticsEventHandler,
userTokensResponseStore = userTokensResponseStore,

View file

@ -1,10 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>NullableBooleanCheck:DefaultPromoRepository.kt$DefaultPromoRepository$getSepaPromoBanner()?.isActive ?: false</ID>
<ID>NullableBooleanCheck:DefaultPromoRepository.kt$DefaultPromoRepository$getVisaPromoBanner()?.isActive ?: false</ID>
<ID>SuspendFunSwallowedCancellation:DefaultPromoRepository.kt$DefaultPromoRepository$runCatching</ID>
<ID>SuspendFunWithFlowReturnType:DefaultPromoRepository.kt$DefaultPromoRepository$suspend</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -19,6 +19,7 @@ import com.tangem.domain.promo.models.StoryContent
import com.tangem.feature.referral.domain.ReferralRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
@ -42,7 +43,7 @@ internal class DefaultPromoRepository(
.distinctUntilChanged()
.map { shouldShow ->
when (promoId) {
PromoId.Referral -> runCatching {
PromoId.Referral -> runSuspendCatching {
!referralRepository.isReferralParticipant(userWalletId) && shouldShow
}.getOrDefault(false)
PromoId.Sepa -> {
@ -81,7 +82,7 @@ internal class DefaultPromoRepository(
appPreferencesStore.store(PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name), false)
}
override suspend fun isMarketsStakingNotificationHideClicked(): Flow<Boolean> {
override fun isMarketsStakingNotificationHideClicked(): Flow<Boolean> {
return appPreferencesStore.get(
key = PreferencesKeys.MARKETS_STAKING_NOTIFICATION_HIDE_CLICKED_KEY,
default = false,
@ -105,7 +106,7 @@ internal class DefaultPromoRepository(
val storedPromo = promoStoriesStore.getSyncOrNull(storyId = id)
// Get last stored promo by id if possible or get from network
val story = if (storedPromo == null && refresh) {
val storyContent = runCatching {
val storyContent = runSuspendCatching {
// Important to return
withTimeoutOrNull(STORIES_LOAD_DELAY) {
tangemApi.getStoryById(storyId = id).getOrThrow()

View file

@ -51,6 +51,7 @@ dependencies {
implementation(deps.androidx.datastore)
implementation(deps.jodatime)
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.immutable.collections)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)

View file

@ -223,30 +223,18 @@ internal class DefaultP2PEthPoolRepository(
.map { vaults ->
if (vaults.isEmpty()) {
return@map StakingAvailability.TemporaryUnavailable
}
val vault = findPublicVault(vaults = vaults)
if (vault != null) {
StakingAvailability.Available(StakingOption.P2P(vault))
} else {
StakingAvailability.TemporaryUnavailable
StakingAvailability.Available(StakingOption.P2P(vaults))
}
}
}
override suspend fun getStakingAvailabilitySync(): StakingAvailability {
val vaults = getVaultsSync()
if (vaults.isEmpty()) {
return StakingAvailability.TemporaryUnavailable
}
val vault = findPublicVault(vaults = vaults)
return if (vault != null) {
StakingAvailability.Available(StakingOption.P2P(vault))
} else {
return if (vaults.isEmpty()) {
StakingAvailability.TemporaryUnavailable
} else {
StakingAvailability.Available(StakingOption.P2P(vaults))
}
}
@ -257,8 +245,4 @@ internal class DefaultP2PEthPoolRepository(
private fun getVaultsFlow(): Flow<List<P2PEthPoolVault>> {
return p2pEthPoolVaultsStore.get()
}
private fun findPublicVault(vaults: List<P2PEthPoolVault>): P2PEthPoolVault? {
return vaults.firstOrNull { vault -> !vault.isPrivate }
}
}

View file

@ -3,11 +3,11 @@ package com.tangem.data.staking
import arrow.core.getOrElse
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.domain.card.common.TapWorkarounds.isWallet2
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingAvailability
@ -30,7 +30,7 @@ import kotlinx.coroutines.withContext
internal class DefaultStakingRepository(
private val stakeKitRepository: StakeKitRepository,
private val p2pEthPoolRepository: P2PEthPoolRepository,
private val stakingBalanceStoreV2: YieldsBalancesStore,
private val stakingBalanceStoreV2: StakingBalancesStore,
private val dispatchers: CoroutineDispatcherProvider,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val stakingFeatureToggles: StakingFeatureToggles,
@ -106,13 +106,13 @@ internal class DefaultStakingRepository(
return withContext(dispatchers.default) {
val balances = stakingBalanceStoreV2.getAllSyncOrNull(userWalletId) ?: return@withContext false
val hasDataYieldBalance by lazy {
balances.any { yieldBalance ->
(yieldBalance as? YieldBalance.Data)?.balance?.items?.isNotEmpty() == true
val hasDataStakingBalance by lazy {
balances.any { stakingBalance ->
stakingBalance is StakingBalance.Data
}
}
balances.isNotEmpty() && hasDataYieldBalance
balances.isNotEmpty() && hasDataStakingBalance
}
}
@ -135,7 +135,7 @@ internal class DefaultStakingRepository(
address = address,
),
)
if (balance != null && balance is YieldBalance.Data && balance.balance.items.isNotEmpty()) {
if ((balance as? StakingBalance.Data.StakeKit)?.balance?.items?.isNotEmpty() == true) {
return true
} else {
stakingFeatureToggles.isCardanoStakingEnabled

View file

@ -0,0 +1,60 @@
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

@ -0,0 +1,92 @@
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

@ -0,0 +1,24 @@
package com.tangem.data.staking.di
import com.tangem.data.staking.multi.DefaultMultiStakingBalanceFetcher
import com.tangem.data.staking.single.DefaultSingleStakingBalanceFetcher
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.domain.staking.single.SingleStakingBalanceFetcher
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface StakingBalanceFetcherModule {
@Binds
@Singleton
fun bindSingleStakingBalanceFetcher(impl: DefaultSingleStakingBalanceFetcher): SingleStakingBalanceFetcher
@Binds
@Singleton
fun bindMultiStakingBalanceFetcher(impl: DefaultMultiStakingBalanceFetcher): MultiStakingBalanceFetcher
}

View file

@ -0,0 +1,28 @@
package com.tangem.data.staking.di
import com.tangem.data.staking.multi.DefaultMultiStakingBalanceProducer
import com.tangem.data.staking.single.DefaultSingleStakingBalanceProducer
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import com.tangem.domain.staking.single.SingleStakingBalanceProducer
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface StakingBalanceProducerFactoryModule {
@Binds
@Singleton
fun bindSingleStakingBalanceProducerFactory(
impl: DefaultSingleStakingBalanceProducer.Factory,
): SingleStakingBalanceProducer.Factory
@Binds
@Singleton
fun bindMultiStakingBalanceProducerFactory(
impl: DefaultMultiStakingBalanceProducer.Factory,
): MultiStakingBalanceProducer.Factory
}

View file

@ -0,0 +1,79 @@
package com.tangem.data.staking.di
import androidx.datastore.core.DataStore
import com.tangem.data.staking.store.DefaultP2PBalancesStore
import com.tangem.data.staking.store.DefaultStakingBalancesStore
import com.tangem.data.staking.store.P2PBalancesStore
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
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier
import com.tangem.domain.staking.single.SingleStakingBalanceProducer
import com.tangem.domain.staking.single.SingleStakingBalanceSupplier
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 StakingBalanceSupplierModule {
@Provides
@Singleton
fun provideStakingBalancesStore(
persistenceStore: DataStore<Map<String, Set<YieldBalanceWrapperDTO>>>,
dispatchers: CoroutineDispatcherProvider,
): StakingBalancesStore {
return DefaultStakingBalancesStore(
runtimeStore = RuntimeSharedStore(),
persistenceStore = persistenceStore,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideP2PBalancesStore(
persistenceStore: DataStore<Map<String, Set<P2PEthPoolAccountResponse>>>,
dispatchers: CoroutineDispatcherProvider,
): P2PBalancesStore {
return DefaultP2PBalancesStore(
runtimeStore = RuntimeSharedStore(),
persistenceStore = persistenceStore,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideSingleStakingBalanceSupplier(
factory: SingleStakingBalanceProducer.Factory,
): SingleStakingBalanceSupplier {
return object : SingleStakingBalanceSupplier(
factory = factory,
keyCreator = { params ->
listOf(
"single_staking_balance",
params.userWalletId.stringValue,
params.stakingId.integrationId,
params.stakingId.address,
)
.joinToString(separator = "_")
},
) {}
}
@Provides
@Singleton
fun provideMultiStakingBalanceSupplier(factory: MultiStakingBalanceProducer.Factory): MultiStakingBalanceSupplier {
return object : MultiStakingBalanceSupplier(
factory = factory,
keyCreator = { "multi_staking_balances_${it.userWalletId.stringValue}" },
) {}
}
}

View file

@ -5,7 +5,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.staking.*
import com.tangem.data.staking.converters.error.StakeKitErrorConverter
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.data.staking.toggles.DefaultStakingFeatureToggles
import com.tangem.data.staking.utils.DefaultStakingCleaner
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
@ -55,7 +55,7 @@ internal object StakingDataModule {
fun provideStakingRepository(
stakeKitRepository: StakeKitRepository,
p2pEthPoolRepository: P2PEthPoolRepository,
yieldsBalancesStore: YieldsBalancesStore,
stakingBalancesStore: StakingBalancesStore,
dispatchers: CoroutineDispatcherProvider,
getUserWalletUseCase: GetUserWalletUseCase,
stakingFeatureToggles: StakingFeatureToggles,
@ -64,7 +64,7 @@ internal object StakingDataModule {
return DefaultStakingRepository(
stakeKitRepository = stakeKitRepository,
p2pEthPoolRepository = p2pEthPoolRepository,
stakingBalanceStoreV2 = yieldsBalancesStore,
stakingBalanceStoreV2 = stakingBalancesStore,
dispatchers = dispatchers,
getUserWalletUseCase = getUserWalletUseCase,
walletManagersFacade = walletManagersFacade,
@ -134,11 +134,11 @@ internal object StakingDataModule {
@Provides
@Singleton
fun provideStakingCleaner(
yieldsBalancesStore: YieldsBalancesStore,
stakingBalancesStore: StakingBalancesStore,
dispatchers: CoroutineDispatcherProvider,
): StakingCleaner {
return DefaultStakingCleaner(
yieldsBalancesStore = yieldsBalancesStore,
stakingBalancesStore = stakingBalancesStore,
dispatchers = dispatchers,
)
}

View file

@ -1,24 +0,0 @@
package com.tangem.data.staking.di
import com.tangem.data.staking.multi.DefaultMultiYieldBalanceFetcher
import com.tangem.data.staking.single.DefaultSingleYieldBalanceFetcher
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.staking.single.SingleYieldBalanceFetcher
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface YieldBalanceFetcherModule {
@Binds
@Singleton
fun bindSingleYieldBalanceFetcher(impl: DefaultSingleYieldBalanceFetcher): SingleYieldBalanceFetcher
@Binds
@Singleton
fun bindMultiYieldBalanceFetcher(impl: DefaultMultiYieldBalanceFetcher): MultiYieldBalanceFetcher
}

View file

@ -1,28 +0,0 @@
package com.tangem.data.staking.di
import com.tangem.data.staking.multi.DefaultMultiYieldBalanceProducer
import com.tangem.data.staking.single.DefaultSingleYieldBalanceProducer
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.domain.staking.single.SingleYieldBalanceProducer
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface YieldBalanceProducerFactoryModule {
@Binds
@Singleton
fun bindSingleYieldBalanceProducerFactory(
impl: DefaultSingleYieldBalanceProducer.Factory,
): SingleYieldBalanceProducer.Factory
@Binds
@Singleton
fun bindMultiYieldBalanceProducerFactory(
impl: DefaultMultiYieldBalanceProducer.Factory,
): MultiYieldBalanceProducer.Factory
}

View file

@ -1,61 +0,0 @@
package com.tangem.data.staking.di
import androidx.datastore.core.DataStore
import com.tangem.data.staking.store.DefaultYieldsBalancesStore
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier
import com.tangem.domain.staking.single.SingleYieldBalanceProducer
import com.tangem.domain.staking.single.SingleYieldBalanceSupplier
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 YieldBalanceSupplierModule {
@Provides
@Singleton
fun provideYieldsBalancesStore(
persistenceStore: DataStore<Map<String, Set<YieldBalanceWrapperDTO>>>,
dispatchers: CoroutineDispatcherProvider,
): YieldsBalancesStore {
return DefaultYieldsBalancesStore(
runtimeStore = RuntimeSharedStore(),
persistenceStore = persistenceStore,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideSingleYieldBalanceSupplier(factory: SingleYieldBalanceProducer.Factory): SingleYieldBalanceSupplier {
return object : SingleYieldBalanceSupplier(
factory = factory,
keyCreator = { params ->
listOf(
"single_yield_balance",
params.userWalletId.stringValue,
params.stakingId.integrationId,
params.stakingId.address,
)
.joinToString(separator = "_")
},
) {}
}
@Provides
@Singleton
fun provideMultiYieldBalanceSupplier(factory: MultiYieldBalanceProducer.Factory): MultiYieldBalanceSupplier {
return object : MultiYieldBalanceSupplier(
factory = factory,
keyCreator = { "multi_yields_balances_${it.userWalletId.stringValue}" },
) {}
}
}

View file

@ -0,0 +1,344 @@
package com.tangem.data.staking.multi
import arrow.core.Either
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.StakingBalancesStore
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.models.staking.StakingID
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.multi.MultiStakingBalanceFetcher
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.launch
import timber.log.Timber
import javax.inject.Inject
/**
* Default implementation of [MultiStakingBalanceFetcher]
*
* Supports both StakeKit and P2P 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
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val stakingYieldsStore: StakingYieldsStore,
private val stakingBalancesStore: StakingBalancesStore,
private val p2pBalancesStore: P2PBalancesStore,
private val stakeKitApi: StakeKitApi,
private val p2pApi: P2PEthPoolApi,
private val p2pVaultsStore: P2PEthPoolVaultsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiStakingBalanceFetcher {
override suspend fun invoke(params: MultiStakingBalanceFetcher.Params): Either<Throwable, Unit> {
Timber.i("Start fetching staking balances for params:\n$params")
val stakingIds = params.stakingIds.ifEmpty {
Timber.i("Nothing to fetch, empty stakingIds for ${params.userWalletId}")
return Unit.right()
}
checkIsSupportedByWalletOrElse(userWalletId = params.userWalletId) {
return it.left()
}
val (stakeKitIds, p2pIds) = stakingIds.partition { stakingId ->
val stakingIntegrationID = StakingIntegrationID.entries.find {
it.value == stakingId.integrationId
}
stakingIntegrationID is StakingIntegrationID.StakeKit
}
Timber.i(
"""
Staking IDs to fetch:
- StakeKit: ${stakeKitIds.joinToString()}
- P2P: ${p2pIds.joinToString()}
""".trimIndent(),
)
return Either.catchOn(dispatchers.default) {
coroutineScope {
if (stakeKitIds.isNotEmpty()) {
launch { fetchStakeKitBalances(params.userWalletId, stakeKitIds.toSet()) }
}
if (p2pIds.isNotEmpty()) {
launch { fetchP2PBalances(params.userWalletId, p2pIds.toSet()) }
}
}
}
.onLeft { throwable ->
Timber.e(throwable, "Unable to fetch staking balances $params")
if (stakeKitIds.isNotEmpty()) {
stakingBalancesStore.storeError(
userWalletId = params.userWalletId,
stakingIds = stakeKitIds.toSet(),
)
}
if (p2pIds.isNotEmpty()) {
p2pBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = p2pIds.toSet())
}
}
}
private suspend fun fetchStakeKitBalances(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
stakingBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
val availableStakingIds = getAvailableStakingIds(
userWalletId = userWalletId,
stakingIds = stakingIds,
)
fetchFromStakeKit(userWalletId = userWalletId, stakingIds = availableStakingIds)
}
private suspend fun fetchP2PBalances(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
p2pBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
val vaults = runSuspendCatching { p2pVaultsStore.getSync() }.getOrNull().orEmpty()
if (vaults.isEmpty()) {
Timber.w("No P2P vaults available for $userWalletId")
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
return
}
fetchFromP2P(userWalletId = userWalletId, stakingIds = stakingIds, vaults = vaults)
}
private suspend fun fetchFromP2P(
userWalletId: UserWalletId,
stakingIds: Set<StakingID>,
vaults: List<com.tangem.domain.staking.model.ethpool.P2PEthPoolVault>,
) {
safeApiCall(
call = {
val addresses = stakingIds.map { it.address }.toSet()
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")
if (responses.isNotEmpty()) {
p2pBalancesStore.storeActual(userWalletId = userWalletId, values = responses)
val missingStakingIds = stakingIds.filter { stakingId ->
responses.none { response ->
response.delegatorAddress.equals(stakingId.address, ignoreCase = true)
}
}
if (missingStakingIds.isNotEmpty()) {
Timber.i("Missing responses for ${missingStakingIds.size} staking IDs: $missingStakingIds")
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = missingStakingIds.toSet())
}
} else {
Timber.i("No P2P responses received for $userWalletId")
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
}
},
onError = { throwable ->
Timber.e(throwable, "Unable to fetch P2P balances $userWalletId")
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
throw throwable
},
)
}
private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) {
val maybeUserWallet = userWalletsStore.getSyncOrNull(key = userWalletId).toOption()
val isSupportedByWallet = maybeUserWallet.isSome(UserWallet::isMultiCurrency)
if (!isSupportedByWallet) {
val exception = IllegalStateException("Wallet $userWalletId is not supported: $maybeUserWallet")
Timber.e(exception)
ifNotSupported(exception)
}
}
private suspend fun getAvailableStakingIds(userWalletId: UserWalletId, stakingIds: Set<StakingID>): Set<StakingID> {
val yieldIds = getYieldsIds(userWalletId = userWalletId)
// [true] -> available
// [false] -> unavailable
val groupedStakingIds = stakingIds.groupBy { stakingId ->
yieldIds.any { it == stakingId.integrationId }
}
val availableStakingIds = groupedStakingIds[true].orEmpty()
val unavailableStakingIds = groupedStakingIds[false].orEmpty()
Timber.i(
"""
Available staking IDs: ${availableStakingIds.joinToString()}
Unavailable staking IDs: ${unavailableStakingIds.joinToString()}
""".trimIndent(),
)
if (unavailableStakingIds.isNotEmpty()) {
stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = unavailableStakingIds.toSet())
}
return availableStakingIds.toSet().ifEmpty {
val exception = IllegalStateException(
"""
No available yields to fetch yield balances:
userWalletId: $userWalletId
stakingIds: ${stakingIds.joinToString()}
""".trimIndent(),
)
Timber.i(exception)
throw exception
}
}
private suspend fun getYieldsIds(userWalletId: UserWalletId): Set<String> {
val yieldsIds = stakingYieldsStore.getSyncWithTimeout().orEmpty()
.mapNotNullTo(destination = hashSetOf(), transform = YieldDTO::id)
if (yieldsIds.isEmpty()) {
val exception = IllegalStateException("No enabled yields for $userWalletId")
Timber.e(exception)
throw exception
}
return yieldsIds
}
private suspend fun fetchFromStakeKit(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
safeApiCall(
call = {
val requests = stakingIds.map(YieldBalanceRequestBodyFactory::create)
val yieldBalances = coroutineScope {
requests
// TODO: in the future, consider optimizing this part
.chunked(size = 15) // StakeKitApi limitation: no more than 15 requests at the same time
.map {
async(dispatchers.io) {
stakeKitApi.getMultipleYieldBalances(it).bind()
}
}
.awaitAll()
.flatten()
.toSet()
}
Timber.i(
"Successfully fetched staking balances for $userWalletId:\n${yieldBalances.joinToString("\n")}",
)
stakingBalancesStore.storeActual(userWalletId = userWalletId, values = yieldBalances)
if (!allResponsesReceived(requests, yieldBalances)) {
val values = stakingIds.filter { stakingId ->
yieldBalances.none { balanceWrapper ->
stakingId.integrationId == balanceWrapper.integrationId &&
stakingId.address == balanceWrapper.addresses.address
}
}
stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = values.toSet())
}
},
onError = { throwable ->
Timber.e(throwable, "Unable to fetch staking balances $userWalletId")
stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
throw throwable
},
)
}
private fun allResponsesReceived(
requests: List<YieldBalanceRequestBody>,
yieldBalances: Set<YieldBalanceWrapperDTO>,
): Boolean {
return requests.all { request ->
yieldBalances.any { balance ->
request.integrationId == balance.integrationId &&
request.addresses.address == balance.addresses.address
}
}
}
}

View file

@ -0,0 +1,56 @@
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.StakingBalancesStore
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEmpty
/**
* Default implementation of [MultiStakingBalanceProducer]
*
* Combines staking balances from both StakeKit and P2P providers.
*
* @property params params
* @property stakingBalancesStore StakeKit staking balances store
* @property p2pBalancesStore P2P balances store
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultMultiStakingBalanceProducer @AssistedInject constructor(
@Assisted val params: MultiStakingBalanceProducer.Params,
private val stakingBalancesStore: StakingBalancesStore,
private val p2pBalancesStore: P2PBalancesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiStakingBalanceProducer {
override val fallback: Option<Set<StakingBalance>> = emptySet<StakingBalance>().some()
override fun produce(): Flow<Set<StakingBalance>> {
val stakeKitFlow = stakingBalancesStore.get(userWalletId = params.userWalletId)
val p2pFlow = p2pBalancesStore.get(userWalletId = params.userWalletId)
return combine(stakeKitFlow, p2pFlow) { stakeKitBalances, p2pBalances ->
stakeKitBalances + p2pBalances
}
.distinctUntilChanged()
.onEmpty { emit(value = hashSetOf()) }
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : MultiStakingBalanceProducer.Factory {
override fun create(params: MultiStakingBalanceProducer.Params): DefaultMultiStakingBalanceProducer
}
}

View file

@ -1,196 +0,0 @@
package com.tangem.data.staking.multi
import arrow.core.Either
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.YieldsBalancesStore
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.models.staking.StakingID
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.multi.MultiYieldBalanceFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import timber.log.Timber
import javax.inject.Inject
/**
* Default implementation of [MultiYieldBalanceFetcher]
*
* @property userWalletsStore user wallets store
* @property stakingYieldsStore staking yields store
* @property yieldsBalancesStore yields balances store
* @property stakeKitApi stake kit API
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val stakingYieldsStore: StakingYieldsStore,
private val yieldsBalancesStore: YieldsBalancesStore,
private val stakeKitApi: StakeKitApi,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiYieldBalanceFetcher {
override suspend fun invoke(params: MultiYieldBalanceFetcher.Params): Either<Throwable, Unit> {
Timber.i("Start fetching yield balances for params:\n$params")
val stakingIds = params.stakingIds.ifEmpty {
Timber.i("Nothing to fetch, empty stakingIds for ${params.userWalletId}")
return Unit.right()
}
checkIsSupportedByWalletOrElse(userWalletId = params.userWalletId) {
return it.left()
}
Timber.i("Staking IDs to fetch:\n${stakingIds.joinToString("\n")}")
return Either.catchOn(dispatchers.default) {
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = stakingIds)
val availableStakingIds = getAvailableStakingIds(
userWalletId = params.userWalletId,
stakingIds = stakingIds,
)
fetch(userWalletId = params.userWalletId, stakingIds = availableStakingIds)
}
.onLeft { throwable ->
Timber.e(throwable, "Unable to fetch yield balances $params")
yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds)
}
}
private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) {
val maybeUserWallet = userWalletsStore.getSyncOrNull(key = userWalletId).toOption()
val isSupportedByWallet = maybeUserWallet.isSome(UserWallet::isMultiCurrency)
if (!isSupportedByWallet) {
val exception = IllegalStateException("Wallet $userWalletId is not supported: $maybeUserWallet")
Timber.e(exception)
ifNotSupported(exception)
}
}
private suspend fun getAvailableStakingIds(userWalletId: UserWalletId, stakingIds: Set<StakingID>): Set<StakingID> {
val yieldIds = getYieldsIds(userWalletId = userWalletId)
// [true] -> available
// [false] -> unavailable
val groupedStakingIds = stakingIds.groupBy { stakingId ->
yieldIds.any { it == stakingId.integrationId }
}
val availableStakingIds = groupedStakingIds[true].orEmpty()
val unavailableStakingIds = groupedStakingIds[false].orEmpty()
Timber.i(
"""
Available staking IDs: ${availableStakingIds.joinToString()}
Unavailable staking IDs: ${unavailableStakingIds.joinToString()}
""".trimIndent(),
)
if (unavailableStakingIds.isNotEmpty()) {
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = unavailableStakingIds.toSet())
}
return availableStakingIds.toSet().ifEmpty {
val exception = IllegalStateException(
"""
No available yields to fetch yield balances:
userWalletId: $userWalletId
stakingIds: ${stakingIds.joinToString()}
""".trimIndent(),
)
Timber.i(exception)
throw exception
}
}
private suspend fun getYieldsIds(userWalletId: UserWalletId): Set<String> {
val yieldsIds = stakingYieldsStore.getSyncWithTimeout().orEmpty()
.mapNotNullTo(destination = hashSetOf(), transform = YieldDTO::id)
if (yieldsIds.isEmpty()) {
val exception = IllegalStateException("No enabled yields for $userWalletId")
Timber.e(exception)
throw exception
}
return yieldsIds
}
private suspend fun fetch(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
safeApiCall(
call = {
val requests = stakingIds.map(YieldBalanceRequestBodyFactory::create)
val yieldBalances = coroutineScope {
requests
// TODO: in the future, consider optimizing this part
.chunked(size = 15) // StakeKitApi limitation: no more than 15 requests at the same time
.map {
async(dispatchers.io) {
stakeKitApi.getMultipleYieldBalances(it).bind()
}
}
.awaitAll()
.flatten()
.toSet()
}
Timber.i("Successfully fetched yield balances for $userWalletId:\n${yieldBalances.joinToString("\n")}")
yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = yieldBalances)
if (!allResponsesReceived(requests, yieldBalances)) {
val values = stakingIds.filter { stakingId ->
yieldBalances.none { balanceWrapper ->
stakingId.integrationId == balanceWrapper.integrationId &&
stakingId.address == balanceWrapper.addresses.address
}
}
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = values.toSet())
}
},
onError = { throwable ->
Timber.e(throwable, "Unable to fetch yield balances $userWalletId")
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
throw throwable
},
)
}
private fun allResponsesReceived(
requests: List<YieldBalanceRequestBody>,
yieldBalances: Set<YieldBalanceWrapperDTO>,
): Boolean {
return requests.all { request ->
yieldBalances.any { balance ->
request.integrationId == balance.integrationId &&
request.addresses.address == balance.addresses.address
}
}
}
}

View file

@ -1,45 +0,0 @@
package com.tangem.data.staking.multi
import arrow.core.Option
import arrow.core.some
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEmpty
/**
* Default implementation of [MultiYieldBalanceProducer]
*
* @property params params
* @property yieldsBalancesStore yields balances store
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultMultiYieldBalanceProducer @AssistedInject constructor(
@Assisted val params: MultiYieldBalanceProducer.Params,
private val yieldsBalancesStore: YieldsBalancesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiYieldBalanceProducer {
override val fallback: Option<Set<YieldBalance>> = emptySet<YieldBalance>().some()
override fun produce(): Flow<Set<YieldBalance>> {
return yieldsBalancesStore.get(userWalletId = params.userWalletId)
.distinctUntilChanged()
.onEmpty { emit(value = hashSetOf()) }
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : MultiYieldBalanceProducer.Factory {
override fun create(params: MultiYieldBalanceProducer.Params): DefaultMultiYieldBalanceProducer
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.data.staking.single
import arrow.core.Either
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.domain.staking.single.SingleStakingBalanceFetcher
import javax.inject.Inject
/**
* Default implementation of [SingleStakingBalanceFetcher]
*
* @property multiStakingBalanceFetcher multi staking balance fetcher
*
[REDACTED_AUTHOR]
*/
internal class DefaultSingleStakingBalanceFetcher @Inject constructor(
private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher,
) : SingleStakingBalanceFetcher {
override suspend fun invoke(params: SingleStakingBalanceFetcher.Params): Either<Throwable, Unit> {
return multiStakingBalanceFetcher(
params = MultiStakingBalanceFetcher.Params(
userWalletId = params.userWalletId,
stakingIds = setOf(params.stakingId),
),
)
}
}

View file

@ -4,10 +4,10 @@ import arrow.core.Option
import arrow.core.some
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier
import com.tangem.domain.staking.single.SingleYieldBalanceProducer
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier
import com.tangem.domain.staking.single.SingleStakingBalanceProducer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.indexOfFirstOrNull
import dagger.assisted.Assisted
@ -20,29 +20,29 @@ import kotlinx.coroutines.flow.mapNotNull
import timber.log.Timber
/**
* Default implementation of [SingleYieldBalanceProducer]
* Default implementation of [SingleStakingBalanceProducer]
*
* @property params params
* @property multiYieldBalanceSupplier multi yield balance supplier
* @property analyticsExceptionHandler analytics exception handler
* @property dispatchers dispatchers
* @property params params
* @property multiStakingBalanceSupplier multi staking balance supplier
* @property analyticsExceptionHandler analytics exception handler
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
@Assisted private val params: SingleYieldBalanceProducer.Params,
private val multiYieldBalanceSupplier: MultiYieldBalanceSupplier,
internal class DefaultSingleStakingBalanceProducer @AssistedInject constructor(
@Assisted private val params: SingleStakingBalanceProducer.Params,
private val multiStakingBalanceSupplier: MultiStakingBalanceSupplier,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleYieldBalanceProducer {
) : SingleStakingBalanceProducer {
override val fallback: Option<YieldBalance> = YieldBalance.Error(stakingId = params.stakingId).some()
override val fallback: Option<StakingBalance> = StakingBalance.Error(stakingId = params.stakingId).some()
override fun produce(): Flow<YieldBalance> {
Timber.i("Producing yield balance for params:\n$params")
override fun produce(): Flow<StakingBalance> {
Timber.i("Producing staking balance for params:\n$params")
return multiYieldBalanceSupplier(
params = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId),
return multiStakingBalanceSupplier(
params = MultiStakingBalanceProducer.Params(userWalletId = params.userWalletId),
)
.mapNotNull { balances ->
val currentStakingId = params.stakingId
@ -65,7 +65,7 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
currentBalances.joinToString("\n"),
)
val dataIndex = currentBalances.indexOfFirstOrNull { it is YieldBalance.Data }
val dataIndex = currentBalances.indexOfFirstOrNull { it is StakingBalance.Data }
if (dataIndex != null) {
currentBalances[dataIndex]
@ -75,7 +75,7 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
} else {
val balance = currentBalances.firstOrNull() ?: return@mapNotNull null
Timber.i("Yield balance found for $currentStakingId:\n$balance")
Timber.i("Staking balance found for $currentStakingId:\n$balance")
balance
}
}
@ -84,7 +84,7 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
}
@AssistedFactory
interface Factory : SingleYieldBalanceProducer.Factory {
override fun create(params: SingleYieldBalanceProducer.Params): DefaultSingleYieldBalanceProducer
interface Factory : SingleStakingBalanceProducer.Factory {
override fun create(params: SingleStakingBalanceProducer.Params): DefaultSingleStakingBalanceProducer
}
}

View file

@ -1,27 +0,0 @@
package com.tangem.data.staking.single
import arrow.core.Either
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.staking.single.SingleYieldBalanceFetcher
import javax.inject.Inject
/**
* Default implementation of [MultiYieldBalanceFetcher]
*
* @property multiYieldBalanceFetcher multi yield balance fetcher
*
[REDACTED_AUTHOR]
*/
internal class DefaultSingleYieldBalanceFetcher @Inject constructor(
private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
) : SingleYieldBalanceFetcher {
override suspend fun invoke(params: SingleYieldBalanceFetcher.Params): Either<Throwable, Unit> {
return multiYieldBalanceFetcher(
params = MultiYieldBalanceFetcher.Params(
userWalletId = params.userWalletId,
stakingIds = setOf(params.stakingId),
),
)
}
}

View file

@ -0,0 +1,191 @@
package com.tangem.data.staking.store
import androidx.datastore.core.DataStore
import com.tangem.data.staking.converters.ethpool.P2PStakingBalanceConverter
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
internal typealias WalletIdWithP2PStakingBalances = Map<UserWalletId, Set<StakingBalance>>
internal typealias WalletIdWithP2PResponses = Map<String, Set<P2PEthPoolAccountResponse>>
/**
* Default implementation of [P2PBalancesStore]
*
* Stores P2P ETH Pool staking balances.
*
* @property runtimeStore runtime store
* @property persistenceStore persistence store
* @param dispatchers coroutine dispatchers
*/
internal class DefaultP2PBalancesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithP2PStakingBalances>,
private val persistenceStore: DataStore<WalletIdWithP2PResponses>,
dispatchers: CoroutineDispatcherProvider,
) : P2PBalancesStore {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
val cachedData = persistenceStore.data.firstOrNull() ?: return@launch
runtimeStore.store(
value = cachedData.map { (stringWalletId, responses) ->
val key = UserWalletId(stringWalletId)
val value = responses.map { response ->
P2PStakingBalanceConverter.convert(
response = response,
source = StatusSource.CACHE,
)
}.toSet()
key to value
}.toMap(),
)
}
}
override fun get(userWalletId: UserWalletId): Flow<Set<StakingBalance>> {
return runtimeStore.get().map { it[userWalletId].orEmpty() }
}
override suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance? {
return runtimeStore.getSyncOrNull()
?.get(userWalletId)
?.firstOrNull { it.stakingId == stakingId }
}
override suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<StakingBalance>? {
return runtimeStore.getSyncOrNull()?.get(userWalletId)
}
override suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) {
refresh(userWalletId = userWalletId, stakingIds = setOf(stakingId))
}
override suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
updateInRuntime(userWalletId = userWalletId, stakingIds = stakingIds) {
it.copySealed(source = StatusSource.CACHE)
}
}
override suspend fun storeActual(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>) {
coroutineScope {
launch { storeInRuntime(userWalletId = userWalletId, values = values) }
launch { storeInPersistence(userWalletId = userWalletId, values = values) }
}
}
override suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
updateInRuntime(
userWalletId = userWalletId,
stakingIds = stakingIds,
ifNotFound = ::createErrorStakingBalance,
update = { it.copySealed(source = StatusSource.ONLY_CACHE) },
)
}
override suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
coroutineScope {
launch { clearInRuntime(userWalletId = userWalletId, stakingIds = stakingIds) }
launch { clearInPersistence(userWalletId = userWalletId, stakingIds = stakingIds) }
}
}
private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>) {
val newBalances = values.map { response ->
P2PStakingBalanceConverter.convert(
response = response,
source = StatusSource.ACTUAL,
)
}.toSet()
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = saved[userWalletId]
?.addOrReplace(newBalances) { old, new -> old.stakingId == new.stakingId }
?: newBalances
}
}
}
private suspend fun storeInPersistence(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>) {
persistenceStore.updateData { current ->
current.toMutableMap().apply {
this[userWalletId.stringValue] = this[userWalletId.stringValue]
?.addOrReplace(values) { old, new ->
old.delegatorAddress == new.delegatorAddress && old.vaultAddress == new.vaultAddress
}
?: values
}
}
}
private suspend fun clearInRuntime(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
this[userWalletId] = this[userWalletId].orEmpty()
.filterNot { it.stakingId in stakingIds }
.toSet()
}
}
}
private suspend fun clearInPersistence(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
val integrationIds = stakingIds.map { it.integrationId }.toSet()
persistenceStore.updateData { current ->
current.toMutableMap().apply {
this[userWalletId.stringValue] = this[userWalletId.stringValue].orEmpty()
.filterNot { response ->
StakingIntegrationID.P2P.EthereumPooled.value in integrationIds
}
.toSet()
}
}
}
private suspend fun updateInRuntime(
userWalletId: UserWalletId,
stakingIds: Set<StakingID>,
ifNotFound: (StakingID) -> StakingBalance? = { null },
update: (StakingBalance) -> StakingBalance,
) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
val portfolioBalances = stored[userWalletId].orEmpty()
val balances = stakingIds.mapNotNullTo(hashSetOf()) { stakingId ->
val balance = portfolioBalances
.firstOrNull { it.stakingId == stakingId }
?: ifNotFound(stakingId)
?: return@mapNotNullTo null
update(balance)
}
val updatedBalances = portfolioBalances.addOrReplace(items = balances) { old, new ->
old.stakingId == new.stakingId
}
put(key = userWalletId, value = updatedBalances)
}
}
}
private fun createErrorStakingBalance(id: StakingID): StakingBalance = StakingBalance.Error(stakingId = id)
}

View file

@ -3,10 +3,10 @@ package com.tangem.data.staking.store
import androidx.datastore.core.DataStore
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.token.converter.YieldBalanceConverter
import com.tangem.datasource.local.token.converter.StakingBalanceConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
@ -19,10 +19,10 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
internal typealias WalletIdWithWrappers = Map<String, Set<YieldBalanceWrapperDTO>>
internal typealias WalletIdWithBalances = Map<UserWalletId, Set<YieldBalance>>
internal typealias WalletIdWithStakingBalances = Map<UserWalletId, Set<StakingBalance>>
/**
* Default implementation of [YieldsBalancesStore]
* Default implementation of [StakingBalancesStore]
*
* @property runtimeStore runtime store
* @property persistenceStore persistence store
@ -30,11 +30,11 @@ internal typealias WalletIdWithBalances = Map<UserWalletId, Set<YieldBalance>>
*
[REDACTED_AUTHOR]
*/
internal class DefaultYieldsBalancesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithBalances>,
internal class DefaultStakingBalancesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithStakingBalances>,
private val persistenceStore: DataStore<WalletIdWithWrappers>,
dispatchers: CoroutineDispatcherProvider,
) : YieldsBalancesStore {
) : StakingBalancesStore {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
@ -45,7 +45,7 @@ internal class DefaultYieldsBalancesStore(
runtimeStore.store(
value = cachedStatuses.map { (stringWalletId, wrappers) ->
val key = UserWalletId(stringWalletId)
val value = YieldBalanceConverter(isCached = true).convertSet(input = wrappers)
val value = StakingBalanceConverter(isCached = true).convertSet(input = wrappers)
.filterNotNull()
.toSet()
@ -56,17 +56,17 @@ internal class DefaultYieldsBalancesStore(
}
}
override fun get(userWalletId: UserWalletId): Flow<Set<YieldBalance>> {
override fun get(userWalletId: UserWalletId): Flow<Set<StakingBalance>> {
return runtimeStore.get().map { it[userWalletId].orEmpty() }
}
override suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): YieldBalance? {
override suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance? {
return runtimeStore.getSyncOrNull()
?.get(userWalletId)
?.firstOrNull { it.stakingId == stakingId }
}
override suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<YieldBalance>? {
override suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<StakingBalance>? {
return runtimeStore.getSyncOrNull()?.get(userWalletId)
}
@ -91,7 +91,7 @@ internal class DefaultYieldsBalancesStore(
updateInRuntime(
userWalletId = userWalletId,
stakingIds = stakingIds,
ifNotFound = ::createErrorYieldBalance,
ifNotFound = ::createErrorStakingBalance,
update = { it.copySealed(source = StatusSource.ONLY_CACHE) },
)
}
@ -107,7 +107,7 @@ internal class DefaultYieldsBalancesStore(
}
private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>) {
val newBalances = YieldBalanceConverter(isCached = false).convertSet(input = values)
val newBalances = StakingBalanceConverter(isCached = false).convertSet(input = values)
.filterNotNull()
.toSet()
@ -140,8 +140,8 @@ internal class DefaultYieldsBalancesStore(
private suspend fun updateInRuntime(
userWalletId: UserWalletId,
stakingIds: Set<StakingID>,
ifNotFound: (StakingID) -> YieldBalance? = { null },
update: (YieldBalance) -> YieldBalance,
ifNotFound: (StakingID) -> StakingBalance? = { null },
update: (StakingBalance) -> StakingBalance,
) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
@ -165,7 +165,7 @@ internal class DefaultYieldsBalancesStore(
}
}
private fun createErrorYieldBalance(id: StakingID): YieldBalance = YieldBalance.Error(stakingId = id)
private fun createErrorStakingBalance(id: StakingID): StakingBalance = StakingBalance.Error(stakingId = id)
private fun YieldBalanceWrapperDTO.getStakingId(): StakingID? {
val integrationId = integrationId

View file

@ -0,0 +1,29 @@
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
*/
interface P2PBalancesStore {
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>)
suspend fun storeActual(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>)
suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
}

View file

@ -1,39 +1,27 @@
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.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* Store of [YieldBalance]'s set
*
[REDACTED_AUTHOR]
*/
interface YieldsBalancesStore {
/** Store of StakeKit [StakingBalance] */
interface StakingBalancesStore {
/** Get flow of [YieldBalance]'s set by [userWalletId] */
fun get(userWalletId: UserWalletId): Flow<Set<YieldBalance>>
fun get(userWalletId: UserWalletId): Flow<Set<StakingBalance>>
/** Get [YieldBalance] by [userWalletId] and [stakingId] synchronously or null */
suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): YieldBalance?
suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance?
/** Get all [YieldBalance] by [userWalletId] synchronously or null */
suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<YieldBalance>?
suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<StakingBalance>?
/** Refresh balance of [stakingId] by [userWalletId] */
suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID)
/** Refresh balances of [stakingIds] by [userWalletId] */
suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
/** Store actual [values] by [userWalletId] */
suspend fun storeActual(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>)
/** Store error by [userWalletId] and [stakingIds] */
suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
/** Clear balances of [stakingIds] by [userWalletId] */
suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
}

View file

@ -1,6 +1,6 @@
package com.tangem.data.staking.utils
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.utils.StakingCleaner
@ -9,13 +9,13 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
/**
* Default implementation of [StakingCleaner].
*
* @property yieldsBalancesStore Store to manage yields balances.
* @property stakingBalancesStore Store to manage staking balances.
* @property dispatchers Coroutine dispatchers provider.
*
[REDACTED_AUTHOR]
*/
internal class DefaultStakingCleaner(
private val yieldsBalancesStore: YieldsBalancesStore,
private val stakingBalancesStore: StakingBalancesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : StakingCleaner {
@ -23,7 +23,7 @@ internal class DefaultStakingCleaner(
if (stakingIds.isEmpty()) return
with(dispatchers.default) {
yieldsBalancesStore.clear(userWalletId, stakingIds)
stakingBalancesStore.clear(userWalletId, stakingIds)
}
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.data.staking
import com.tangem.data.staking.converters.ethpool.P2PStakingBalanceConverter
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
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingBalance
internal fun YieldBalanceWrapperDTO.toDomain(source: StatusSource = StatusSource.CACHE): StakingBalance {
return StakingBalanceConverter(isCached = source == StatusSource.CACHE).convert(this)!!
}
internal fun P2PEthPoolAccountResponse.toDomain(source: StatusSource = StatusSource.CACHE): StakingBalance.Data.P2P {
return P2PStakingBalanceConverter.convert(
response = this,
source = source,
)
}

View file

@ -1,10 +0,0 @@
package com.tangem.data.staking
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.token.converter.YieldBalanceConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.YieldBalance
internal fun YieldBalanceWrapperDTO.toDomain(source: StatusSource = StatusSource.CACHE): YieldBalance {
return YieldBalanceConverter(source = source).convert(this)!!
}

View file

@ -4,17 +4,20 @@ 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.YieldsBalancesStore
import com.tangem.data.staking.store.P2PBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.test.core.assertEitherLeft
import com.tangem.test.core.assertEitherRight
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -28,30 +31,36 @@ import org.junit.jupiter.api.TestInstance
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultMultiYieldBalanceFetcherTest {
internal class DefaultMultiStakingBalanceFetcherTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val stakingYieldsStore: StakingYieldsStore = mockk()
private val yieldsBalancesStore: YieldsBalancesStore = mockk(relaxUnitFun = true)
private val stakingBalancesStore: StakingBalancesStore = mockk(relaxUnitFun = true)
private val p2pBalancesStore: P2PBalancesStore = mockk(relaxUnitFun = true)
private val stakeKitApi: StakeKitApi = mockk()
private val p2pApi: P2PEthPoolApi = mockk()
private val p2pVaultsStore: P2PEthPoolVaultsStore = mockk()
private val fetcher = DefaultMultiYieldBalanceFetcher(
private val fetcher = DefaultMultiStakingBalanceFetcher(
userWalletsStore = userWalletsStore,
stakingYieldsStore = stakingYieldsStore,
yieldsBalancesStore = yieldsBalancesStore,
stakingBalancesStore = stakingBalancesStore,
p2pBalancesStore = p2pBalancesStore,
stakeKitApi = stakeKitApi,
p2pApi = p2pApi,
p2pVaultsStore = p2pVaultsStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun resetMocks() {
clearMocks(userWalletsStore, stakingYieldsStore, yieldsBalancesStore, stakeKitApi)
clearMocks(userWalletsStore, stakingYieldsStore, stakingBalancesStore, stakeKitApi)
}
@Test
fun `fetch yields balances successfully`() = runTest {
fun `fetch staking balances successfully`() = runTest {
// Arrange
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
val params = MultiStakingBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
@ -72,21 +81,21 @@ internal class DefaultMultiYieldBalanceFetcherTest {
// Assert
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getMultipleYieldBalances(requests)
yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result)
stakingBalancesStore.storeActual(userWalletId = userWalletId, values = result)
}
coVerify(inverse = true) { yieldsBalancesStore.storeError(any(), any()) }
coVerify(inverse = true) { stakingBalancesStore.storeError(any(), any()) }
assertEitherRight(actual)
}
@Test
fun `fetch yields balances successfully if one of stakingIds is unavailable`() = runTest {
fun `fetch staking balances successfully if one of stakingIds is unavailable`() = runTest {
// Arrange
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
val params = MultiStakingBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
@ -104,20 +113,20 @@ internal class DefaultMultiYieldBalanceFetcherTest {
// Assert
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(solanaId))
stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(solanaId))
stakeKitApi.getMultipleYieldBalances(requests)
yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result)
stakingBalancesStore.storeActual(userWalletId = userWalletId, values = result)
}
assertEitherRight(actual)
}
@Test
fun `fetch yields balances failure if user wallet is not supported`() = runTest {
fun `fetch staking balances failure if user wallet is not supported`() = runTest {
// Arrange
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
val params = MultiStakingBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
val userWallet = MockUserWalletFactory.create().copy(isMultiCurrency = false)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
@ -129,11 +138,11 @@ internal class DefaultMultiYieldBalanceFetcherTest {
coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) }
coVerify(inverse = true) {
yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any())
stakingBalancesStore.refresh(userWalletId = any(), stakingIds = any())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
yieldsBalancesStore.storeError(userWalletId = any(), stakingIds = any())
stakingBalancesStore.storeActual(userWalletId = any(), values = any())
stakingBalancesStore.storeError(userWalletId = any(), stakingIds = any())
}
val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${userWallet.toOption()}")
@ -142,9 +151,9 @@ internal class DefaultMultiYieldBalanceFetcherTest {
}
@Test
fun `fetch yields balances failure if userWalletsStore returns null`() = runTest {
fun `fetch staking balances failure if userWalletsStore returns null`() = runTest {
// Arrange
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
val params = MultiStakingBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns null
@ -155,11 +164,11 @@ internal class DefaultMultiYieldBalanceFetcherTest {
coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) }
coVerify(inverse = true) {
yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any())
stakingBalancesStore.refresh(userWalletId = any(), stakingIds = any())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
yieldsBalancesStore.storeError(userWalletId = any(), stakingIds = any())
stakingBalancesStore.storeActual(userWalletId = any(), values = any())
stakingBalancesStore.storeError(userWalletId = any(), stakingIds = any())
}
val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${null.toOption()}")
@ -168,9 +177,9 @@ internal class DefaultMultiYieldBalanceFetcherTest {
}
@Test
fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns null`() = runTest {
fun `fetch staking balances failure if stakingYieldsStore getSyncWithTimeout returns null`() = runTest {
// Arrange
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
val params = MultiStakingBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns null
@ -181,14 +190,14 @@ internal class DefaultMultiYieldBalanceFetcherTest {
// Assert
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds)
stakingBalancesStore.refresh(params.userWalletId, tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds)
}
coVerify(inverse = true) {
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
stakingBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
@ -197,9 +206,9 @@ internal class DefaultMultiYieldBalanceFetcherTest {
}
@Test
fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns empty list`() = runTest {
fun `fetch staking balances failure if stakingYieldsStore getSyncWithTimeout returns empty list`() = runTest {
// Arrange
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
val params = MultiStakingBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns emptyList()
@ -210,14 +219,14 @@ internal class DefaultMultiYieldBalanceFetcherTest {
// Assert
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds)
}
coVerify(inverse = true) {
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
stakingBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
@ -226,9 +235,9 @@ internal class DefaultMultiYieldBalanceFetcherTest {
}
@Test
fun `fetch yields balances failure if yields converting is failed`() = runTest {
fun `fetch staking balances failure if yields converting is failed`() = runTest {
// Arrange
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
val params = MultiStakingBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
@ -244,14 +253,14 @@ internal class DefaultMultiYieldBalanceFetcherTest {
// Assert
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds)
}
coVerify(inverse = true) {
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
stakingBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
@ -260,9 +269,9 @@ internal class DefaultMultiYieldBalanceFetcherTest {
}
@Test
fun `fetch yields balances failure if available yields does not contain ids from params`() = runTest {
fun `fetch staking balances failure if available yields does not contain ids from params`() = runTest {
// Arrange
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
val params = MultiStakingBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
@ -275,14 +284,14 @@ internal class DefaultMultiYieldBalanceFetcherTest {
// Assert
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds)
}
coVerify(inverse = true) {
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
stakingBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException(
@ -297,9 +306,9 @@ internal class DefaultMultiYieldBalanceFetcherTest {
}
@Test
fun `fetch yields balances failure if stakeKitApi getMultipleYieldBalances is failed`() = runTest {
fun `fetch staking balances failure if stakeKitApi getMultipleYieldBalances is failed`() = runTest {
// Arrange
val params = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
val params = MultiStakingBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
@ -320,13 +329,13 @@ internal class DefaultMultiYieldBalanceFetcherTest {
// Assert
coVerifyOrder {
userWalletsStore.getSyncOrNull(params.userWalletId)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getMultipleYieldBalances(requests)
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
}
coVerify(inverse = true) { yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) }
coVerify(inverse = true) { stakingBalancesStore.storeActual(userWalletId = any(), values = any()) }
val expected = ApiResponseError.NetworkException()

View file

@ -0,0 +1,283 @@
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.StakingBalancesStore
import com.tangem.data.staking.toDomain
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.*
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class DefaultMultiStakingBalanceProducerTest {
private val params = MultiStakingBalanceProducer.Params(userWalletId = UserWalletId("011"))
private val stakingBalancesStore = mockk<StakingBalancesStore>()
private val p2pBalancesStore = mockk<P2PBalancesStore>()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val producer = DefaultMultiStakingBalanceProducer(
params = params,
stakingBalancesStore = stakingBalancesStore,
p2pBalancesStore = p2pBalancesStore,
dispatchers = dispatchers,
)
@Test
fun `test that flow is mapped for user wallet id from params`() = runTest {
val balances = setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
)
val networksStatusesFlow = flowOf(balances)
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produce()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values.first()).isEqualTo(balances)
}
@Test
fun `test that flow is updated if balances are updated`() = runTest {
val networksStatusesFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2)
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produce()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
// first emit
val balances = setOf(
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(),
)
networksStatusesFlow.emit(balances)
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1.first()).isEqualTo(balances)
// second emit
val updatedWrappers = setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
)
networksStatusesFlow.emit(updatedWrappers)
val values2 = getEmittedValues(flow = actual)
val expected = listOf(balances, updatedWrappers)
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2).isEqualTo(expected)
}
@Test
fun `test that flow is filtered the same balance`() = runTest {
val networksStatusesFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2)
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produce()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
// first emit
val wrappers = setOf(
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(),
)
networksStatusesFlow.emit(wrappers)
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1.first()).isEqualTo(wrappers)
// second emit
networksStatusesFlow.emit(wrappers)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2.first()).isEqualTo(wrappers)
}
@Test
fun `test if flow throws exception`() = runTest {
val exception = IllegalStateException()
val balances = setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
)
val innerFlow = MutableStateFlow(value = false)
val networksStatusesFlow = flow {
if (innerFlow.value) {
emit(balances)
} else {
throw exception
}
}
.buffer(capacity = 5)
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produceWithFallback()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(emptySet<StakingBalance>()))
innerFlow.emit(value = true)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(balances))
}
@Test
fun `test that flow is empty`() = runTest {
every { stakingBalancesStore.get(params.userWalletId) } returns emptyFlow()
every { p2pBalancesStore.get(params.userWalletId) } returns emptyFlow()
val actual = producer.produce()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(emptySet<StakingBalance>()))
}
@Test
fun `test that StakeKit and P2P balances are combined`() = runTest {
val stakeKitBalances = createStakeKitBalances()
val p2pBalances = createP2PBalances()
every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances)
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(p2pBalances)
val actual = producer.produce()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values.first()).isEqualTo(stakeKitBalances + p2pBalances)
}
@Test
fun `test that P2P balances are updated independently from StakeKit`() = runTest {
val stakeKitBalances = createStakeKitBalancesWithTonOnly()
val p2pFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2)
every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances)
every { p2pBalancesStore.get(params.userWalletId) } returns p2pFlow
val actual = producer.produce()
// check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) }
// first emit - empty P2P
p2pFlow.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)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2.last()).isEqualTo(stakeKitBalances + p2pBalances)
}
private companion object {
val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
val solanaId = StakingID(
integrationId = "solana-sol-native-multivalidator-staking",
address = "0x1",
)
val p2pEthereumId = StakingID(
integrationId = StakingIntegrationID.P2P.EthereumPooled.value,
address = "0x5aa711F440Eb6d4361148bBD89d03464628ace84",
)
fun createStakeKitBalances(): Set<StakingBalance> {
return setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
)
}
fun createStakeKitBalancesWithTonOnly(): Set<StakingBalance> {
return setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
)
}
fun createP2PBalances(): Set<StakingBalance> {
return setOf(
MockP2PEthPoolAccountResponseFactory.createWithBalance(stakingId = p2pEthereumId).toDomain(
source = StatusSource.ACTUAL,
),
)
}
}
}

View file

@ -1,191 +0,0 @@
package com.tangem.data.staking.multi
import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.toDomain
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class DefaultMultiYieldBalanceProducerTest {
private val params = MultiYieldBalanceProducer.Params(userWalletId = UserWalletId("011"))
private val yieldsBalancesStore = mockk<YieldsBalancesStore>()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val producer = DefaultMultiYieldBalanceProducer(
params = params,
yieldsBalancesStore = yieldsBalancesStore,
dispatchers = dispatchers,
)
@Test
fun `test that flow is mapped for user wallet id from params`() = runTest {
val balances = setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
)
val networksStatusesFlow = flowOf(balances)
every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
val actual = producer.produce()
// check after producer.produce()
verify { yieldsBalancesStore.get(params.userWalletId) }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values.first()).isEqualTo(balances)
}
@Test
fun `test that flow is updated if balances are updated`() = runTest {
val networksStatusesFlow = MutableSharedFlow<Set<YieldBalance>>(replay = 2)
every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
val actual = producer.produce()
// check after producer.produce()
verify { yieldsBalancesStore.get(params.userWalletId) }
// first emit
val balances = setOf(
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(),
)
networksStatusesFlow.emit(balances)
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1.first()).isEqualTo(balances)
// second emit
val updatedWrappers = setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
)
networksStatusesFlow.emit(updatedWrappers)
val values2 = getEmittedValues(flow = actual)
val expected = listOf(balances, updatedWrappers)
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2).isEqualTo(expected)
}
@Test
fun `test that flow is filtered the same balance`() = runTest {
val networksStatusesFlow = MutableSharedFlow<Set<YieldBalance>>(replay = 2)
every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
val actual = producer.produce()
// check after producer.produce()
verify { yieldsBalancesStore.get(params.userWalletId) }
// first emit
val wrappers = setOf(
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(),
)
networksStatusesFlow.emit(wrappers)
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1.first()).isEqualTo(wrappers)
// second emit
networksStatusesFlow.emit(wrappers)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2.first()).isEqualTo(wrappers)
}
@Test
fun `test if flow throws exception`() = runTest {
val exception = IllegalStateException()
val balances = setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(),
)
val innerFlow = MutableStateFlow(value = false)
val networksStatusesFlow = flow {
if (innerFlow.value) {
emit(balances)
} else {
throw exception
}
}
.buffer(capacity = 5)
every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
val actual = producer.produceWithFallback()
// check after producer.produce()
verify { yieldsBalancesStore.get(params.userWalletId) }
val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(emptySet<YieldBalance>()))
innerFlow.emit(value = true)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(balances))
}
@Test
fun `test that flow is empty`() = runTest {
every { yieldsBalancesStore.get(params.userWalletId) } returns emptyFlow()
val actual = producer.produce()
// check after producer.produce()
verify { yieldsBalancesStore.get(params.userWalletId) }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(emptySet<YieldBalance>()))
}
private companion object {
val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
val solanaId = StakingID(
integrationId = "solana-sol-native-multivalidator-staking",
address = "0x1",
)
}
}

View file

@ -5,8 +5,8 @@ import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.staking.single.SingleYieldBalanceFetcher
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.domain.staking.single.SingleStakingBalanceFetcher
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
@ -20,32 +20,32 @@ import org.junit.jupiter.api.TestInstance
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultSingleYieldBalanceFetcherTest {
internal class DefaultSingleStakingBalanceFetcherTest {
private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher = mockk()
private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher = mockk()
private val fetcher = DefaultSingleYieldBalanceFetcher(
multiYieldBalanceFetcher = multiYieldBalanceFetcher,
private val fetcher = DefaultSingleStakingBalanceFetcher(
multiStakingBalanceFetcher = multiStakingBalanceFetcher,
)
@BeforeEach
fun resetMocks() {
clearMocks(multiYieldBalanceFetcher)
clearMocks(multiStakingBalanceFetcher)
}
@Test
fun `fetch yield balance successfully`() = runTest {
fun `fetch staking balance successfully`() = runTest {
// Arrange
val params = SingleYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingId = tonId)
val params = SingleStakingBalanceFetcher.Params(userWalletId = userWalletId, stakingId = tonId)
val multiParams = MultiYieldBalanceFetcher.Params(
val multiParams = MultiStakingBalanceFetcher.Params(
userWalletId = userWalletId,
stakingIds = setOf(tonId),
)
val multiResult = Unit.right()
coEvery { multiYieldBalanceFetcher(params = multiParams) } returns multiResult
coEvery { multiStakingBalanceFetcher(params = multiParams) } returns multiResult
// Act
val actual = fetcher.invoke(params).isRight()
@ -53,26 +53,26 @@ internal class DefaultSingleYieldBalanceFetcherTest {
// Assert
Truth.assertThat(actual).isTrue()
coVerify { multiYieldBalanceFetcher(params = multiParams) }
coVerify { multiStakingBalanceFetcher(params = multiParams) }
}
@Test
fun `fetch yield balance failure`() = runTest {
fun `fetch staking balance failure`() = runTest {
// Arrange
val params = SingleYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingId = tonId)
val params = SingleStakingBalanceFetcher.Params(userWalletId = userWalletId, stakingId = tonId)
val multiParams = MultiYieldBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = setOf(tonId))
val multiParams = MultiStakingBalanceFetcher.Params(userWalletId = userWalletId, stakingIds = setOf(tonId))
val multiResult = IllegalStateException().left()
coEvery { multiYieldBalanceFetcher(params = multiParams) } returns multiResult
coEvery { multiStakingBalanceFetcher(params = multiParams) } returns multiResult
// Act
val actual = fetcher.invoke(params)
// Assert
Truth.assertThat(actual).isEqualTo(multiResult)
coVerify { multiYieldBalanceFetcher(params = multiParams) }
coVerify { multiStakingBalanceFetcher(params = multiParams) }
}
private companion object {

View file

@ -4,12 +4,12 @@ import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.data.staking.toDomain
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.multi.MultiYieldBalanceProducer
import com.tangem.domain.staking.multi.MultiYieldBalanceSupplier
import com.tangem.domain.staking.single.SingleYieldBalanceProducer
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier
import com.tangem.domain.staking.single.SingleStakingBalanceProducer
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
@ -26,20 +26,20 @@ import org.junit.jupiter.api.TestInstance
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultSingleYieldBalanceProducerTest {
internal class DefaultSingleStakingBalanceProducerTest {
private val params = SingleYieldBalanceProducer.Params(
private val params = SingleStakingBalanceProducer.Params(
userWalletId = UserWalletId(stringValue = "011"),
stakingId = tonId,
)
private val multiNetworkStatusSupplier = mockk<MultiYieldBalanceSupplier>()
private val multiNetworkStatusSupplier = mockk<MultiStakingBalanceSupplier>()
private val analyticsExceptionHandler = mockk<AnalyticsExceptionHandler>(relaxUnitFun = true)
private val dispatchers = TestingCoroutineDispatcherProvider()
private val producer = DefaultSingleYieldBalanceProducer(
private val producer = DefaultSingleStakingBalanceProducer(
params = params,
multiYieldBalanceSupplier = multiNetworkStatusSupplier,
multiStakingBalanceSupplier = multiNetworkStatusSupplier,
analyticsExceptionHandler = analyticsExceptionHandler,
dispatchers = dispatchers,
)
@ -61,7 +61,7 @@ internal class DefaultSingleYieldBalanceProducerTest {
),
)
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
val multiParams = MultiStakingBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
// Act
@ -74,17 +74,17 @@ internal class DefaultSingleYieldBalanceProducerTest {
}
@Test
fun `flow is updated if yield balance is updated`() = runTest {
fun `flow is updated if staking balance is updated`() = runTest {
// Arrange
val multiFlow = MutableSharedFlow<Set<YieldBalance>>(replay = 2, extraBufferCapacity = 1)
val multiFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2, extraBufferCapacity = 1)
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
val multiParams = MultiStakingBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val producerFlow = producer.produceWithFallback()
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
val updatedBalance = YieldBalance.Error(stakingId = tonId)
val updatedBalance = StakingBalance.Error(stakingId = tonId)
// Act (first emit)
multiFlow.emit(value = setOf(balance))
@ -108,9 +108,9 @@ internal class DefaultSingleYieldBalanceProducerTest {
@Test
fun `flow is filtered the same status`() = runTest {
// Arrange
val multiFlow = MutableSharedFlow<Set<YieldBalance>>(replay = 2, extraBufferCapacity = 1)
val multiFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2, extraBufferCapacity = 1)
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
val multiParams = MultiStakingBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val producerFlow = producer.produceWithFallback()
@ -153,7 +153,7 @@ internal class DefaultSingleYieldBalanceProducerTest {
}
.buffer(capacity = 5)
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
val multiParams = MultiStakingBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val producerFlow = producer.produceWithFallback()
@ -162,7 +162,7 @@ internal class DefaultSingleYieldBalanceProducerTest {
val actual1 = getEmittedValues(flow = producerFlow)
// Assert (first emit)
val fallbackStatus = YieldBalance.Error(stakingId = tonId.copy(address = "0x1"))
val fallbackStatus = StakingBalance.Error(stakingId = tonId.copy(address = "0x1"))
Truth.assertThat(actual1).hasSize(1)
Truth.assertThat(actual1).containsExactly(fallbackStatus)
@ -184,7 +184,7 @@ internal class DefaultSingleYieldBalanceProducerTest {
val multiFlow = flowOf(setOf(balance))
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
val multiParams = MultiStakingBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val producerFlow = producer.produce()

View file

@ -5,7 +5,7 @@ import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.data.staking.toDomain
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -15,12 +15,12 @@ import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class YieldsBalancesStoreGetMethodTest {
internal class StakingBalancesStoreGetMethodTest {
private val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
private val runtimeStore = RuntimeSharedStore<WalletIdWithStakingBalances>()
private val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
private val store = DefaultYieldsBalancesStore(
private val store = DefaultStakingBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
@ -32,7 +32,7 @@ internal class YieldsBalancesStoreGetMethodTest {
val values = getEmittedValues(flow = actual)
val expected = listOf(emptySet<YieldBalance>())
val expected = listOf(emptySet<StakingBalance>())
Truth.assertThat(values).isEqualTo(expected)
}
@ -44,7 +44,7 @@ internal class YieldsBalancesStoreGetMethodTest {
val values = getEmittedValues(flow = actual)
val expected = listOf(emptySet<YieldBalance>())
val expected = listOf(emptySet<StakingBalance>())
Truth.assertThat(values).isEqualTo(expected)
}
@ -59,7 +59,7 @@ internal class YieldsBalancesStoreGetMethodTest {
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(emptySet<YieldBalance>()))
Truth.assertThat(values).isEqualTo(listOf(emptySet<StakingBalance>()))
}
@Test

View file

@ -6,7 +6,7 @@ import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.data.staking.toDomain
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
@ -18,16 +18,16 @@ import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class YieldsBalancesStoreInitializationTest {
internal class StakingBalancesStoreInitializationTest {
@Test
fun `test initialization if cache store is empty`() = runTest {
val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
val runtimeStore = RuntimeSharedStore<WalletIdWithStakingBalances>()
val persistenceStore: DataStore<WalletIdWithWrappers> = mockk()
every { persistenceStore.data } returns emptyFlow()
DefaultYieldsBalancesStore(
DefaultStakingBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
@ -38,21 +38,21 @@ internal class YieldsBalancesStoreInitializationTest {
@Test
fun `test initialization if cache store contains empty map`() = runTest {
val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
val runtimeStore = RuntimeSharedStore<WalletIdWithStakingBalances>()
val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
DefaultYieldsBalancesStore(
DefaultStakingBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptyMap<String, Set<YieldBalance>>())
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptyMap<String, Set<StakingBalance>>())
}
@Test
fun `test initialization if cache store is not empty`() = runTest {
val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
val runtimeStore = RuntimeSharedStore<WalletIdWithStakingBalances>()
val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance()
@ -63,7 +63,7 @@ internal class YieldsBalancesStoreInitializationTest {
}
}
DefaultYieldsBalancesStore(
DefaultStakingBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),

View file

@ -7,8 +7,8 @@ import com.tangem.data.staking.toDomain
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.flow.firstOrNull
@ -18,12 +18,12 @@ import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class YieldsBalancesStoreUpdateMethodsTest {
internal class StakingBalancesStoreUpdateMethodsTest {
private val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
private val runtimeStore = RuntimeSharedStore<WalletIdWithStakingBalances>()
private val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
private val store = DefaultYieldsBalancesStore(
private val store = DefaultStakingBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
@ -33,7 +33,7 @@ internal class YieldsBalancesStoreUpdateMethodsTest {
fun `refresh the single id if runtime store is empty`() = runTest {
store.refresh(userWalletId = userWalletId, stakingId = stakingId)
val runtimeExpected = mapOf(userWalletId to emptySet<YieldBalance>())
val runtimeExpected = mapOf(userWalletId to emptySet<StakingBalance>())
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
@ -63,7 +63,7 @@ internal class YieldsBalancesStoreUpdateMethodsTest {
fun `refresh the multi ids if runtime store is empty`() = runTest {
store.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
val runtimeExpected = mapOf(userWalletId to emptySet<YieldBalance>())
val runtimeExpected = mapOf(userWalletId to emptySet<StakingBalance>())
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
@ -129,7 +129,7 @@ internal class YieldsBalancesStoreUpdateMethodsTest {
store.storeError(userWalletId = userWalletId, stakingIds = setOf(stakingId))
val runtimeExpected = mapOf(
userWalletId to setOf(YieldBalance.Error(stakingId)),
userWalletId to setOf(StakingBalance.Error(stakingId)),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)

View file

@ -1,6 +1,6 @@
package com.tangem.data.staking.utils
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingIntegrationID
@ -16,9 +16,9 @@ import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultStakingCleanerTest {
private val yieldsBalancesStore = mockk<YieldsBalancesStore>(relaxed = true)
private val stakingBalancesStore = mockk<StakingBalancesStore>(relaxed = true)
private val cleaner = DefaultStakingCleaner(
yieldsBalancesStore = yieldsBalancesStore,
stakingBalancesStore = stakingBalancesStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId("011")
@ -28,7 +28,7 @@ class DefaultStakingCleanerTest {
@BeforeEach
fun setUp() {
clearMocks(yieldsBalancesStore)
clearMocks(stakingBalancesStore)
}
@Test
@ -38,7 +38,7 @@ class DefaultStakingCleanerTest {
// Assert
coVerifyOrder {
yieldsBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds)
stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds)
}
}
@ -49,7 +49,7 @@ class DefaultStakingCleanerTest {
// Assert
coVerifyOrder(inverse = true) {
yieldsBalancesStore.clear(userWalletId = any(), stakingIds = any())
stakingBalancesStore.clear(userWalletId = any(), stakingIds = any())
}
}
}

View file

@ -44,6 +44,7 @@ dependencies {
/** Libs */
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
/** Other */
implementation(deps.androidx.datastore)

View file

@ -403,7 +403,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
value = NetworkStatus.MissedDerivation, // Caution!!! Do not change this status
).some(),
maybeQuoteStatus = quoteStatus.toOption(),
maybeYieldBalance = none(),
maybeStakingBalance = none(),
)
}

View file

@ -1,12 +1,16 @@
package com.tangem.data.swap.converter.transaction
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.swap.models.SwapStatusDTO
import com.tangem.data.swap.models.SwapTransactionDTO
import com.tangem.data.swap.models.SwapTxTypeDTO
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapTransactionModel
import com.tangem.domain.swap.models.SwapTxType
import com.tangem.lib.crypto.derivation.AccountNodeRecognizer
import com.tangem.utils.converter.TwoWayConverter
internal class SavedSwapTransactionConverter(
@ -48,9 +52,23 @@ internal class SavedSwapTransactionConverter(
): SwapTransactionModel {
val status = txStatuses[value.txId]
val refundCurrency = status?.refundTokensResponse?.let { id ->
val blockchain = Blockchain.fromNetworkId(id.networkId) ?: return@let null
val derivationPath = id.derivationPath ?: return@let null
val accountIndex = if (blockchain == Blockchain.Chia) {
DerivationIndex.Main
} else {
val recognizer = AccountNodeRecognizer(blockchain = blockchain)
val index = recognizer.recognize(derivationPathValue = derivationPath)?.toInt()
?: return@let null
DerivationIndex(index).getOrNull() ?: return@let null
}
responseCryptoCurrenciesFactory.createCurrency(
responseToken = id,
userWallet = userWallet,
accountIndex = accountIndex,
)
}
val statusWithRefundCurrency = status?.copy(refundCurrency = refundCurrency)

View file

@ -72,7 +72,11 @@ internal class SavedSwapTransactionListConverter(
return SwapTransactionListModel(
transactions = value.transactions.map { tx ->
savedSwapTransactionConverter.convertBack(tx, userWallet, txStatuses)
savedSwapTransactionConverter.convertBack(
value = tx,
userWallet = userWallet,
txStatuses = txStatuses,
)
},
userWalletId = value.userWalletId,
fromCryptoCurrencyId = value.fromCryptoCurrencyId,

View file

@ -3,11 +3,8 @@
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:CustomTokensMerger.kt$CustomTokensMerger${ Timber.e(it, "Unable to fetch token:\n$token") null }</ID>
<ID>MultilineLambdaItParameter:DefaultCurrenciesRepository.kt$DefaultCurrenciesRepository${ it.networkId == blockchainNetworkId &amp;&amp; compareIdWithMigrations(it, coinId) &amp;&amp; it.derivationPath == derivationPath.value }</ID>
<ID>NullableToStringCall:AccountListCryptoCurrenciesFetcher.kt$AccountListCryptoCurrenciesFetcher$${this::class.simpleName}</ID>
<ID>NullableToStringCall:DefaultMultiWalletCryptoCurrenciesFetcher.kt$DefaultMultiWalletCryptoCurrenciesFetcher$${this::class.simpleName}</ID>
<ID>SuspendFunSwallowedCancellation:DefaultCurrenciesRepository.kt$DefaultCurrenciesRepository$runCatching</ID>
<ID>SuspendFunWithFlowReturnType:DefaultCurrenciesRepository.kt$DefaultCurrenciesRepository$suspend</ID>
<ID>UseOrEmpty:DefaultYieldSupplyWarningsViewedRepository.kt$DefaultYieldSupplyWarningsViewedRepository$appPreferencesStore.getObjectSet&lt;String&gt;(PreferencesKeys.YIELD_SUPPLY_WARNINGS_STATES_KEY).firstOrNull() ?: emptySet()</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -21,6 +21,7 @@ import com.tangem.domain.core.error.DataError
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -31,6 +32,7 @@ import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import timber.log.Timber
@ -301,8 +303,9 @@ internal class DefaultCurrenciesRepository(
)
responseCryptoCurrenciesFactory.createCurrencies(
storedTokens,
response = storedTokens,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
@ -357,15 +360,16 @@ internal class DefaultCurrenciesRepository(
val coinId = blockchain.toCoinId()
val storedCoin = storedTokens.tokens
.find {
it.networkId == blockchainNetworkId &&
compareIdWithMigrations(it, coinId) &&
it.derivationPath == derivationPath.value
.find { token ->
token.networkId == blockchainNetworkId &&
compareIdWithMigrations(token, coinId) &&
token.derivationPath == derivationPath.value
} ?: error("Coin in this network $networkId not found")
val coin = responseCryptoCurrenciesFactory.createCurrency(
responseToken = storedCoin,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
coin as? CryptoCurrency.Coin ?: error("Unable to create currency")
@ -525,6 +529,7 @@ internal class DefaultCurrenciesRepository(
}
}
@Suppress("SuspendFunWithFlowReturnType")
private suspend fun getCurrenciesForWallet(
userWallet: UserWallet,
currencyRawId: CryptoCurrency.RawID,
@ -539,6 +544,7 @@ internal class DefaultCurrenciesRepository(
responseCryptoCurrenciesFactory.createCurrencies(
response = storedTokens.copy(tokens = filterResponse),
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
}
@ -570,7 +576,7 @@ internal class DefaultCurrenciesRepository(
}
override suspend fun syncTokens(userWalletId: UserWalletId) {
runCatching {
runSuspendCatching {
val savedCurrencies = requireNotNull(
value = getSavedUserTokensResponseSync(key = userWalletId),
lazyMessage = { "Saved tokens empty. Can not perform add currencies action" },
@ -591,6 +597,7 @@ internal class DefaultCurrenciesRepository(
responseCryptoCurrenciesFactory.createCurrencies(
response = storedTokens,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
}
}

View file

@ -9,7 +9,7 @@ import com.tangem.data.tokens.converters.UtxoConverter
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.YieldBalance
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.utils.getTotalStakingBalance
import com.tangem.domain.tokens.model.CurrencyAmount
@ -136,7 +136,7 @@ internal class DefaultCurrencyChecksRepository(
val rentData = walletManagersFacade.getRentInfo(userWalletId, currencyStatus.currency.network) ?: return null
val balanceValue = currencyStatus.value as? CryptoCurrencyStatus.Loaded ?: return null
val stakingBalance = balanceValue.yieldBalance as? YieldBalance.Data
val stakingBalance = balanceValue.stakingBalance as? StakingBalance.Data
val stakingTotalBalance = stakingBalance?.getTotalStakingBalance(
blockchainId = currencyStatus.currency.network.rawId,
).orZero()

View file

@ -1,11 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CastNullableToNonNullableType:DefaultTransactionRepository.kt$DefaultTransactionRepository$as</ID>
<ID>NoNameShadowing:DefaultTransactionRepository.kt$DefaultTransactionRepository$amount</ID>
<ID>NoNameShadowing:DefaultTransactionRepository.kt$DefaultTransactionRepository$destination</ID>
<ID>NullableBooleanCheck:DefaultWalletAddressServiceRepository.kt$DefaultWalletAddressServiceRepository$(walletManager as? NearWalletManager)?.validateAddress(address) ?: false</ID>
<ID>NullableToStringCall:DefaultTransactionRepository.kt$DefaultTransactionRepository$${walletManager?.wallet?.blockchain}</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -38,7 +38,7 @@ import timber.log.Timber
import java.math.BigDecimal
import java.math.BigInteger
@Suppress("LargeClass")
@Suppress("LargeClass", "NullableToStringCall")
internal class DefaultTransactionRepository(
private val tangemTechApi: TangemTechApi,
private val walletManagersFacade: WalletManagersFacade,
@ -64,12 +64,12 @@ internal class DefaultTransactionRepository(
val extras = txExtras ?: getMemoExtras(networkId = network.rawId, memo)
val destination = if (amount.type is AmountType.TokenYieldSupply) {
val patchedDestination = if (amount.type is AmountType.TokenYieldSupply) {
walletManager.getYieldModuleAddress()
} else {
destination
}
val amount = if (amount.type is AmountType.TokenYieldSupply) {
val patchedAmount = if (amount.type is AmountType.TokenYieldSupply) {
amount.copy(value = BigDecimal.ZERO)
} else {
amount
@ -77,17 +77,17 @@ internal class DefaultTransactionRepository(
return@withContext if (fee != null) {
walletManager.createTransaction(
amount = amount,
amount = patchedAmount,
fee = fee,
destination = destination,
destination = patchedDestination,
).copy(
extras = extras,
)
} else {
TransactionData.Uncompiled(
amount = amount,
amount = patchedAmount,
sourceAddress = walletManager.wallet.address,
destinationAddress = destination,
destinationAddress = patchedDestination,
extras = extras,
fee = null,
)
@ -288,7 +288,7 @@ internal class DefaultTransactionRepository(
blockchain = blockchain,
derivationPath = network.derivationPath.value,
)
(walletManager as TransactionSender).send(txData, signer)
(requireNotNull(walletManager) as TransactionSender).send(txData, signer)
}
override suspend fun sendMultipleTransactions(
@ -304,7 +304,7 @@ internal class DefaultTransactionRepository(
blockchain = blockchain,
derivationPath = network.derivationPath.value,
)
(walletManager as TransactionSender).sendMultiple(txsData, signer, sendMode)
(requireNotNull(walletManager) as TransactionSender).sendMultiple(txsData, signer, sendMode)
}
override fun createTransactionDataExtras(

View file

@ -89,7 +89,7 @@ class DefaultWalletAddressServiceRepository(
blockchain = blockchain,
derivationPath = network.derivationPath.value,
) ?: return@withContext false
(walletManager as? NearWalletManager)?.validateAddress(address) ?: false
(walletManager as? NearWalletManager)?.validateAddress(address) == true
} else {
blockchain.validateAddress(address)
}

View file

@ -17,7 +17,6 @@
<ID>NullableToStringCall:TangemPayRequestPerformer.kt$TangemPayRequestPerformer$${error.message}</ID>
<ID>RedundantSuspendModifier:DefaultVisaRepository.kt$DefaultVisaRepository$suspend</ID>
<ID>SuspendFunSwallowedCancellation:DefaultVisaRepository.kt$DefaultVisaRepository$runCatching</ID>
<ID>SuspendFunSwallowedCancellation:TangemPayRequestPerformer.kt$TangemPayRequestPerformer$runCatching</ID>
<ID>SuspendFunSwallowedCancellation:VisaApiRequestMaker.kt$VisaApiRequestMaker$runCatching</ID>
<ID>UnreachableCode:VisaApiRequestMaker.kt$VisaApiRequestMaker$if (status is VisaCardActivationStatus.RefreshTokenExpired) { throw RefreshTokenExpiredException() }</ID>
<ID>UnreachableCode:VisaApiRequestMaker.kt$VisaApiRequestMaker$return (status as? VisaCardActivationStatus.Activated)?.visaAuthTokens ?: error("Visa card is not activated")</ID>

View file

@ -271,6 +271,7 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
ApiEnvironment.DEV_2,
ApiEnvironment.DEV_3,
ApiEnvironment.STAGE,
ApiEnvironment.STAGE_2,
ApiEnvironment.MOCK,
-> visaLibLoader.getOrCreateConfig().rainRSAPublicKey.dev
ApiEnvironment.PROD -> visaLibLoader.getOrCreateConfig().rainRSAPublicKey.prod

View file

@ -53,7 +53,7 @@ internal class DefaultGetTangemPayCurrencyStatusUseCase @Inject constructor(
),
sources = CryptoCurrencyStatus.Sources(),
pendingTransactions = emptySet(),
yieldBalance = null,
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
),

View file

@ -171,6 +171,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
ApiEnvironment.DEV_2,
ApiEnvironment.DEV_3,
ApiEnvironment.STAGE,
ApiEnvironment.STAGE_2,
ApiEnvironment.MOCK,
-> rsaPublicKey.dev
ApiEnvironment.PROD -> rsaPublicKey.prod

View file

@ -2,10 +2,7 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:DefaultWalletManagersFacade.kt$DefaultWalletManagersFacade${ Token( name = it.name, symbol = it.symbol, contractAddress = it.contractAddress, decimals = it.decimals, id = it.id, ) }</ID>
<ID>MultilineLambdaItParameter:UpdateWalletManagerResultFactory.kt$UpdateWalletManagerResultFactory${ createCurrencyTransaction( txHistoryItemConverter = txHistoryItemConverter, data = it, ) }</ID>
<ID>NamedArguments:DefaultWalletManagersFacade.kt$DefaultWalletManagersFacade$getAndUpdateWalletManager(userWallet, blockchain, derivationPath, extraTokens)</ID>
<ID>UnnecessaryLet:DefaultWalletManagersFacade.kt$DefaultWalletManagersFacade$let(txHistoryStateConverter::convert)</ID>
<ID>UnsafeCallOnNullableType:WalletManagerFactory.kt$blockchain.getTestnetVersion()!!</ID>
<ID>UnsafeCallOnNullableType:WalletManagerFactory.kt$scanResponse.secondTwinPublicKey!!</ID>
</CurrentIssues>

View file

@ -85,7 +85,12 @@ internal class DefaultWalletManagersFacade @Inject constructor(
val blockchain = network.toBlockchain()
val derivationPath = network.derivationPath.value
return getAndUpdateWalletManager(userWallet, blockchain, derivationPath, extraTokens)
return getAndUpdateWalletManager(
userWallet = userWallet,
blockchain = blockchain,
derivationPath = derivationPath,
extraTokens = extraTokens,
)
}
override suspend fun remove(userWalletId: UserWalletId, networks: Set<Network>) {
@ -123,18 +128,18 @@ internal class DefaultWalletManagersFacade @Inject constructor(
if (tokenInfos.isEmpty()) return
tokenInfos
.groupBy { it.network }
.groupBy(TokenInfo::network)
.forEach { (network, tokenInfoList) ->
removeTokens(
userWalletId = userWalletId,
network = network,
networkTokens = tokenInfoList.map {
networkTokens = tokenInfoList.map { tokenInfo ->
Token(
name = it.name,
symbol = it.symbol,
contractAddress = it.contractAddress,
decimals = it.decimals,
id = it.id,
name = tokenInfo.name,
symbol = tokenInfo.symbol,
contractAddress = tokenInfo.contractAddress,
decimals = tokenInfo.decimals,
id = tokenInfo.id,
)
},
)
@ -215,24 +220,24 @@ internal class DefaultWalletManagersFacade @Inject constructor(
"Unable to get a wallet manager for blockchain: ${currency.network}"
}
return walletManager
.getTransactionHistoryState(
address = walletManager.wallet.address,
filterType = when (currency) {
is CryptoCurrency.Coin -> TransactionHistoryRequest.FilterType.Coin
is CryptoCurrency.Token -> {
val blockchainToken = Token(
name = currency.name,
symbol = currency.symbol,
contractAddress = currency.contractAddress,
decimals = currency.decimals,
id = currency.id.rawCurrencyId?.value,
)
TransactionHistoryRequest.FilterType.Contract(blockchainToken)
}
},
)
.let(txHistoryStateConverter::convert)
val transactionHistoryState = walletManager.getTransactionHistoryState(
address = walletManager.wallet.address,
filterType = when (currency) {
is CryptoCurrency.Coin -> TransactionHistoryRequest.FilterType.Coin
is CryptoCurrency.Token -> {
val blockchainToken = Token(
name = currency.name,
symbol = currency.symbol,
contractAddress = currency.contractAddress,
decimals = currency.decimals,
id = currency.id.rawCurrencyId?.value,
)
TransactionHistoryRequest.FilterType.Contract(blockchainToken)
}
},
)
return txHistoryStateConverter.convert(transactionHistoryState)
}
override suspend fun getTxHistoryItems(
@ -366,7 +371,7 @@ internal class DefaultWalletManagersFacade @Inject constructor(
blockchain: Blockchain,
derivationPath: String?,
): WalletManager? {
getWmInitializationMutex(blockchain, derivationPath).withLock {
getWmInitializationMutex(userWalletId, blockchain, derivationPath).withLock {
val userWallet = getUserWallet(userWalletId)
var walletManager = walletManagersStore.getSyncOrNull(
@ -738,15 +743,24 @@ internal class DefaultWalletManagersFacade @Inject constructor(
return initializableAccountWalletManger.accountInitializationState == InitializableAccount.State.INITIALIZED
}
private fun getWmInitializationMutex(blockchain: Blockchain, derivationPath: String?): Mutex {
val key = createMutexMapKey(blockchain, derivationPath)
private fun getWmInitializationMutex(
userWalletId: UserWalletId,
blockchain: Blockchain,
derivationPath: String?,
): Mutex {
val key = createMutexMapKey(userWalletId, blockchain, derivationPath)
return wmInitializationMutexes.computeIfAbsent(key) {
Mutex()
}
}
private fun createMutexMapKey(blockchain: Blockchain, derivationPath: String?): String {
return blockchain.toNetworkId() + "|" + derivationPath
private fun createMutexMapKey(userWalletId: UserWalletId, blockchain: Blockchain, derivationPath: String?): String {
return listOf(
userWalletId.stringValue,
blockchain.toNetworkId(),
derivationPath,
)
.joinToString(separator = "|")
}
private fun updateWalletManagerTokensIfNeeded(walletManager: WalletManager, tokens: Set<CryptoCurrency.Token>) {

View file

@ -25,13 +25,12 @@ dependencies {
implementation(projects.core.utils)
/** Domain */
implementation(projects.domain.wallets)
implementation(projects.domain.account)
implementation(projects.domain.card)
api(projects.domain.models)
/** Domain models */
implementation(projects.domain.wallets.models)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
/** DI */
implementation(deps.hilt.android)
@ -41,15 +40,15 @@ dependencies {
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.retrofit)
implementation(deps.timber)
/** tests */
testImplementation(projects.domain.models)
testImplementation(projects.common.test)
testImplementation(deps.test.junit)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
testImplementation(deps.moshi)
testImplementation(deps.moshi.kotlin)
}

View file

@ -3,8 +3,6 @@
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:DefaultColdMapDerivationsRepository.kt$DefaultColdMapDerivationsRepository${ networkFactory.create( blockchain = Blockchain.fromNetworkId(it.value) ?: return@mapNotNull null, extraDerivationPath = null, userWallet = userWallet, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultDerivationsRepository.kt$DefaultDerivationsRepository${ userWallet.update(it.first) it.second }</ID>
<ID>MultilineLambdaItParameter:DefaultHotMapDerivationsRepository.kt$DefaultHotMapDerivationsRepository${ networkFactory.create( blockchain = Blockchain.fromNetworkId(it.value) ?: return@mapNotNull null, extraDerivationPath = null, userWallet = userWallet, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultHotWalletAccessCodeAttemptsRepository.kt$DefaultHotWalletAccessCodeAttemptsRepository${ AttemptsPersistentData( attempts = it[PreferencesKeys.getHotWalletUnlockAttemptsKey(id.attemptIdKey())] ?: 0, bootCount = it[PreferencesKeys.getHotWalletUnlockBootKey(id.attemptIdKey())] ?: 0, deadline = it[PreferencesKeys.getHotWalletUnlockDeadlineKey(id.attemptIdKey())] ?: 0L, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultHotWalletAccessCodeAttemptsRepository.kt$DefaultHotWalletAccessCodeAttemptsRepository${ it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(authAttemptId.attemptIdKey())) it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(noAuthAttemptId.attemptIdKey())) it.remove(PreferencesKeys.getHotWalletUnlockBootKey(authAttemptId.attemptIdKey())) it.remove(PreferencesKeys.getHotWalletUnlockBootKey(noAuthAttemptId.attemptIdKey())) it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(authAttemptId.attemptIdKey())) it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(noAuthAttemptId.attemptIdKey())) }</ID>
<ID>MultilineLambdaItParameter:DefaultHotWalletAccessCodeAttemptsRepository.kt$DefaultHotWalletAccessCodeAttemptsRepository${ while (true) { emit(toState(id, it.attempts, it.deadline, it.bootCount)) val remaining = remainingSeconds(it.deadline, it.bootCount) if (remaining &lt;= 0) break delay(timeMillis = 1000) } }</ID>
@ -13,12 +11,9 @@
<ID>MultilineLambdaItParameter:TangemHotWalletSigner.kt$TangemHotWalletSigner${ Timber.e(it) return if (it is TangemSdkError) { CompletionResult.Failure(it) } else { CompletionResult.Failure(TangemSdkError.ExceptionError(it)) } }</ID>
<ID>NamedArguments:DefaultHotWalletAccessCodeAttemptsRepository.kt$DefaultHotWalletAccessCodeAttemptsRepository$toState(id, count, deadline, boot)</ID>
<ID>NamedArguments:DefaultHotWalletAccessCodeAttemptsRepository.kt$DefaultHotWalletAccessCodeAttemptsRepository$toState(id, it.attempts, it.deadline, it.bootCount)</ID>
<ID>SuspendFunSwallowedCancellation:DefaultHotWalletAccessor.kt$DefaultHotWalletAccessor$runCatching</ID>
<ID>SuspendFunSwallowedCancellation:TangemHotWalletSigner.kt$TangemHotWalletSigner$runCatching</ID>
<ID>UnnecessaryLet:MissedDerivationsFinder.kt$MissedDerivationsFinder$let(::findByNetworks)</ID>
<ID>UnusedImports:DefaultDerivationsRepository.kt$import com.tangem.common.map</ID>
<ID>UseOrEmpty:DefaultColdMapDerivationsRepository.kt$DefaultColdMapDerivationsRepository$oldKeys[walletKey] ?: emptyMap()</ID>
<ID>UseOrEmpty:DefaultHotMapDerivationsRepository.kt$DefaultHotMapDerivationsRepository$oldKeys[walletKey] ?: emptyMap()</ID>
<ID>VarCouldBeVal:DefaultHotWalletAccessor.kt$DefaultHotWalletAccessor$private var contextualUnlockHotWallet: ConcurrentHashMap&lt;HotWalletId, UnlockHotWallet?&gt; = ConcurrentHashMap()</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -5,22 +5,22 @@ import arrow.core.left
import arrow.core.right
import com.tangem.data.wallets.converters.UserWalletRemoteInfoConverter
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException
import com.tangem.datasource.api.common.response.fold
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.common.response.isNetworkError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter
import com.tangem.datasource.api.tangemTech.models.PromocodeActivationBody
import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO
import com.tangem.datasource.api.tangemTech.models.*
import com.tangem.datasource.api.tangemTech.models.SeedPhraseNotificationDTO.Status
import com.tangem.datasource.api.tangemTech.models.WalletBody
import com.tangem.datasource.api.tangemTech.models.WalletType
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.PreferencesKeys.SEED_FIRST_NOTIFICATION_SHOW_TIME
import com.tangem.datasource.local.preferences.utils.*
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.models.SeedPhraseNotificationsStatus
@ -30,13 +30,15 @@ import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.WEEK_MILLIS
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
typealias SeedPhraseNotificationsStatuses = Map<UserWalletId, SeedPhraseNotificationsStatus>
@Suppress("TooManyFunctions", "LargeClass")
@Suppress("TooManyFunctions", "LargeClass", "LongParameterList")
internal class DefaultWalletsRepository(
private val appPreferencesStore: AppPreferencesStore,
private val tangemTechApi: TangemTechApi,
@ -44,6 +46,8 @@ internal class DefaultWalletsRepository(
private val seedPhraseNotificationVisibilityStore: RuntimeStateStore<SeedPhraseNotificationsStatuses>,
private val dispatchers: CoroutineDispatcherProvider,
private val authProvider: AuthProvider,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val moshi: com.squareup.moshi.Moshi,
) : WalletsRepository {
private val upgradeWalletNotificationDisabled: MutableStateFlow<Set<UserWalletId>> =
@ -344,18 +348,27 @@ internal class DefaultWalletsRepository(
upgradeWalletNotificationDisabled.update { it.plus(userWalletId) }
}
override suspend fun setWalletName(walletId: String, walletName: String) = withContext(dispatchers.io) {
val userWallet = userWalletsStore.getSyncOrNull(key = UserWalletId(walletId))
override suspend fun setWalletName(walletId: UserWalletId, walletName: String) = withContext(dispatchers.io) {
val userWallet = userWalletsStore.getSyncOrNull(key = walletId)
tangemTechApi.updateWallet(
walletId = walletId,
walletId = walletId.stringValue,
body = WalletBody(name = walletName, type = WalletType.from(userWallet)),
).getOrThrow()
}
override suspend fun getWalletInfo(walletId: String): UserWalletRemoteInfo = withContext(dispatchers.io) {
override suspend fun upgradeWallet(walletId: UserWalletId) = withContext(dispatchers.io) {
val userWallet = userWalletsStore.getSyncStrict(key = walletId)
tangemTechApi.updateWallet(
walletId = walletId.stringValue,
body = WalletBody(name = userWallet.name, type = WalletType.from(userWallet)),
).getOrThrow()
}
override suspend fun getWalletInfo(walletId: UserWalletId): UserWalletRemoteInfo = withContext(dispatchers.io) {
UserWalletRemoteInfoConverter.convert(
value = tangemTechApi.getWalletById(walletId).getOrThrow(),
value = tangemTechApi.getWalletById(walletId.stringValue).getOrThrow(),
)
}
@ -379,24 +392,58 @@ internal class DefaultWalletsRepository(
override suspend fun associateWallets(applicationId: String, wallets: List<UserWallet>) =
withContext(dispatchers.io) {
val publicKeys = authProvider.getCardsPublicKeys()
val walletsBody = wallets.map { userWallet ->
WalletIdBodyConverter.convert(
userWallet = userWallet,
publicKeys = if (userWallet is UserWallet.Cold) {
publicKeys.filterKeys {
userWallet.cardsInWallet.contains(it)
}
} else {
emptyMap()
},
)
}
if (accountsFeatureToggles.isFeatureEnabled) {
val associateApplicationIdWithWallets: suspend () -> ApiResponse<Unit> = {
tangemTechApi.associateApplicationIdWithWalletsV2(
applicationId = applicationId,
body = AssociateApplicationIdWithWalletsBody(
walletIds = wallets.map { it.walletId.stringValue }.distinct(),
),
)
}
tangemTechApi.associateApplicationIdWithWallets(
applicationId = applicationId,
body = walletsBody,
).getOrThrow()
val apiResponse = associateApplicationIdWithWallets()
if (apiResponse is ApiResponse.Success) return@withContext
if (apiResponse is ApiResponse.Error &&
apiResponse.cause.isNetworkError(HttpException.Code.BAD_REQUEST)
) {
val errorBody = (apiResponse.cause as? HttpException)?.errorBody
?: error("Bad Request must have error body")
val adapter = moshi.adapter(AssociateAppWithWalletsErrorResponse::class.java)
val errorResponse = adapter.fromJson(errorBody)
?: error("Cannot parse error body: $errorBody")
errorResponse.missingWalletIds
.map {
async { createWallet(userWalletId = UserWalletId(it)) }
}
.awaitAll()
associateApplicationIdWithWallets().getOrThrow()
}
} else {
val publicKeys = authProvider.getCardsPublicKeys()
val walletsBody = wallets.map { userWallet ->
WalletIdBodyConverter.convert(
userWallet = userWallet,
publicKeys = if (userWallet is UserWallet.Cold) {
publicKeys.filterKeys {
userWallet.cardsInWallet.contains(it)
}
} else {
emptyMap()
},
)
}
tangemTechApi.associateApplicationIdWithWallets(
applicationId = applicationId,
body = walletsBody,
).getOrThrow()
}
}
override suspend fun activatePromoCode(

View file

@ -2,16 +2,16 @@ package com.tangem.data.wallets.derivations
import com.tangem.common.CompletionResult
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.map
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.wallets.derivations.ColdMapDerivationsRepository
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.derivations.ColdMapDerivationsRepository
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository
import com.tangem.domain.wallets.usecase.BackendId
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -29,11 +29,17 @@ internal class DefaultDerivationsRepository @Inject constructor(
derivePublicKeysByNetworks(userWalletId = userWalletId, networks = currencies.map(CryptoCurrency::network))
}
override suspend fun derivePublicKeysByNetworkIds(userWalletId: UserWalletId, networkIds: List<Network.RawID>) {
override suspend fun derivePublicKeysByNetworkIds(
userWalletId: UserWalletId,
networkIds: List<Network.RawID>,
accountIndex: DerivationIndex,
) {
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
when (userWallet) {
is UserWallet.Cold -> coldDerivationsRepository.derivePublicKeysByNetworkIds(userWallet, networkIds)
is UserWallet.Hot -> hotDerivationsRepository.derivePublicKeysByNetworkIds(userWallet, networkIds)
is UserWallet.Hot -> {
hotDerivationsRepository.derivePublicKeysByNetworkIds(userWallet, networkIds, accountIndex)
}
}.also {
userWallet.update(it)
}
@ -57,9 +63,9 @@ internal class DefaultDerivationsRepository @Inject constructor(
return when (userWallet) {
is UserWallet.Cold -> coldDerivationsRepository.derivePublicKeys(userWallet, derivations)
is UserWallet.Hot -> hotDerivationsRepository.derivePublicKeys(userWallet, derivations)
}.let {
userWallet.update(it.first)
it.second
}.let { publicKeysMapByUserWallet ->
userWallet.update(publicKeysMapByUserWallet.first)
publicKeysMapByUserWallet.second
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.data.wallets.di
import com.squareup.moshi.Moshi
import com.tangem.data.wallets.DefaultWalletNamesMigrationRepository
import com.tangem.data.wallets.DefaultWalletsRepository
import com.tangem.data.wallets.cold.DefaultColdMapDerivationsRepository
@ -8,9 +9,11 @@ import com.tangem.data.wallets.hot.DefaultHotMapDerivationsRepository
import com.tangem.data.wallets.hot.DefaultHotWalletAccessCodeAttemptsRepository
import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.wallets.derivations.ColdMapDerivationsRepository
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository
@ -37,6 +40,8 @@ internal object WalletsDataModule {
userWalletsStore: UserWalletsStore,
dispatchers: CoroutineDispatcherProvider,
authProvider: AuthProvider,
accountsFeatureToggles: AccountsFeatureToggles,
@NetworkMoshi moshi: Moshi,
): WalletsRepository {
return DefaultWalletsRepository(
appPreferencesStore = appPreferencesStore,
@ -45,6 +50,8 @@ internal object WalletsDataModule {
seedPhraseNotificationVisibilityStore = RuntimeStateStore(defaultValue = emptyMap()),
dispatchers = dispatchers,
authProvider = authProvider,
accountsFeatureToggles = accountsFeatureToggles,
moshi = moshi,
)
}

View file

@ -7,6 +7,8 @@ import com.tangem.common.extensions.toMapKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.wallets.derivations.MissedDerivationsFinder
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -21,6 +23,7 @@ import timber.log.Timber
import javax.inject.Inject
internal class DefaultHotMapDerivationsRepository @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val networkFactory: NetworkFactory,
private val hotWalletAccessor: HotWalletAccessor,
private val dispatchers: CoroutineDispatcherProvider,
@ -36,14 +39,16 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor(
override suspend fun derivePublicKeysByNetworkIds(
userWallet: UserWallet.Hot,
networkIds: List<Network.RawID>,
accountIndex: DerivationIndex,
): UserWallet.Hot {
return derivePublicKeysByNetworks(
userWallet = userWallet,
networks = networkIds.mapNotNull {
networks = networkIds.mapNotNull { networkRawId ->
networkFactory.create(
blockchain = Blockchain.fromNetworkId(it.value) ?: return@mapNotNull null,
blockchain = Blockchain.fromNetworkId(networkRawId.value) ?: return@mapNotNull null,
extraDerivationPath = null,
userWallet = userWallet,
accountIndex = accountIndex,
)
},
)
@ -82,10 +87,15 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor(
hotWalletId = userWallet.hotWalletId,
request = request,
)
// Get the updated user wallet from the store to ensure we have the latest data
// in case it was modified during the derive operation
val updatedUserWallet = userWalletsStore.getSyncStrict(userWallet.walletId) as UserWallet.Hot
val newKeys =
result.responses.associate { ByteArrayKey(it.seedKey.publicKey) to ExtendedPublicKeysMap(it.publicKeys) }
return userWallet.updateWithNewKeys(newKeys) to newKeys
return updatedUserWallet.updateWithNewKeys(newKeys) to newKeys
}
override suspend fun hasMissedDerivations(
@ -131,7 +141,7 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor(
): Map<ByteArrayKey, ExtendedPublicKeysMap> {
return (oldKeys.keys + newKeys.keys).toSet()
.associateWith { walletKey ->
val oldDerivations = ExtendedPublicKeysMap(oldKeys[walletKey] ?: emptyMap())
val oldDerivations = ExtendedPublicKeysMap(oldKeys[walletKey].orEmpty())
val newDerivations = newKeys[walletKey] ?: ExtendedPublicKeysMap(emptyMap())
ExtendedPublicKeysMap(oldDerivations + newDerivations)

Some files were not shown because too many files have changed in this diff Show more