Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-17 14:05:58 +03:00
commit 3cbc2dadfb
822 changed files with 12838 additions and 5366 deletions

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,21 +94,23 @@ 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,
)
}
@ -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?,
@ -154,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,
)
},

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

@ -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

@ -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

@ -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

@ -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,7 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:DefaultFeedbackRepository.kt$DefaultFeedbackRepository$private val useNewUserWalletsRepository: Boolean</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,
@ -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

@ -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

@ -1,15 +1,16 @@
package com.tangem.data.news.repository
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.news.NewsApi
import com.tangem.datasource.api.news.models.response.NewsTrendingResponse
import com.tangem.datasource.local.news.details.NewsDetailsStore
import com.tangem.datasource.local.news.trending.TrendingNewsStore
import com.tangem.domain.models.news.*
import com.tangem.domain.news.model.NewsListBatchFlow
import com.tangem.domain.news.model.NewsListBatchingContext
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.domain.models.news.ArticleCategory
import com.tangem.domain.models.news.DetailedArticle
import com.tangem.domain.models.news.ShortArticle
import com.tangem.domain.news.repository.NewsRepository
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListSource
@ -18,20 +19,19 @@ import com.tangem.pagination.fetcher.BatchFetcher
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import javax.inject.Inject
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlin.collections.orEmpty
import timber.log.Timber
/**
* Implementation of [NewsRepository].
[REDACTED_AUTHOR]
*/
internal class DefaultNewsRepository @Inject constructor(
internal class DefaultNewsRepository(
private val newsApi: NewsApi,
private val dispatchers: CoroutineDispatcherProvider,
private val newsDetailsStore: NewsDetailsStore,
@ -68,26 +68,26 @@ internal class DefaultNewsRepository @Inject constructor(
fetchDetailedArticlesInternal(newsIds = newsIds, language = language)
}
override suspend fun getTrendingNews(limit: Int, language: String?): List<ShortArticle> {
return fetchAndStoreTrendingNews(limit = limit, language = language)
}
override fun observeTrendingNews(): Flow<List<ShortArticle>> {
return trendingNewsStore.get(TRENDING_NEWS_KEY)
}
override suspend fun refreshTrendingNews(limit: Int, language: String?) {
override suspend fun fetchTrendingNews(limit: Int, language: String?) {
fetchAndStoreTrendingNews(limit = limit, language = language)
}
override fun observeTrendingNews(): Flow<TrendingNews> {
return trendingNewsStore.get(TRENDING_NEWS_KEY)
}
override suspend fun updateTrendingNewsViewed(articleIds: Collection<Int>, viewed: Boolean) {
if (articleIds.isEmpty()) return
val current = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY).orEmpty()
if (current.isEmpty()) return
val currentResult = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY) ?: return
val currentArticles = when (currentResult) {
is TrendingNews.Data -> currentResult.articles
is TrendingNews.Error -> return
}
if (currentArticles.isEmpty()) return
val ids = articleIds.toSet()
val updated = current.map { article ->
val updated = currentArticles.map { article ->
if (article.id in ids) {
article.copy(viewed = viewed)
} else {
@ -95,7 +95,7 @@ internal class DefaultNewsRepository @Inject constructor(
}
}
trendingNewsStore.store(TRENDING_NEWS_KEY, updated)
trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(updated))
}
override suspend fun getCategories(): List<ArticleCategory> {
@ -138,16 +138,46 @@ internal class DefaultNewsRepository @Inject constructor(
}
}
private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?): List<ShortArticle> {
private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?) {
return withContext(dispatchers.io) {
val response = newsApi.getTrendingNews(limit = limit, language = language).getOrThrow()
val freshArticles = response.items.map { it.toDomainShortArticle() }
val currentArticles = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY).orEmpty()
val merged = mergeTrendingArticles(current = currentArticles, fresh = freshArticles).take(limit)
trendingNewsStore.store(TRENDING_NEWS_KEY, merged)
merged
val apiResponse = newsApi.getTrendingNews(limit = limit, language = language)
when (val result = apiResponse) {
is ApiResponse.Error -> {
Timber.e(
result.cause.cause,
"Trending news fetch failed cause: ${
when (val error = result.cause) {
is ApiResponseError.HttpException -> error.code
is ApiResponseError.NetworkException -> "NetworkException"
is ApiResponseError.TimeoutException -> "TimeoutException"
is ApiResponseError.UnknownException -> "UnknownException"
}
}",
)
trendingNewsStore.clear()
trendingNewsStore.store(
key = TRENDING_NEWS_KEY,
value = TrendingNews.Error(
NewsError.Unknown(
message = result.cause.message,
code = null,
),
),
)
}
is ApiResponse.Success<NewsTrendingResponse> -> {
val freshArticles = result.data.items.map { it.toDomainShortArticle() }
val cachedArticles = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY)
val currentArticles = when (cachedArticles) {
is TrendingNews.Data -> cachedArticles.articles
is TrendingNews.Error -> emptyList()
null -> emptyList()
}
val merged = mergeTrendingArticles(current = currentArticles, fresh = freshArticles).take(limit)
trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(merged))
TrendingNews.Data(merged)
}
}
}
}

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

@ -3,7 +3,6 @@
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ NFTCollections( network = network, content = NFTCollections.Content.Collections( collections = it ?.map { collection -&gt; nftSdkCollectionConverter.convert(network to collection) } ?.filter { it.id !is NFTCollection.Identifier.Unknown }, source = StatusSource.CACHE, ), ) }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ getNFTPersistenceStore(userWalletId, it).clear() getNFTRuntimeStore(userWalletId, it).clear() }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ if (it !is UnsupportedOperationException) { saveFailedStateInRuntime( userWalletId = userWalletId, network = network, error = it, ) } }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ if (it.id == collectionId) { it.changeAssetsStatusSource(source) } else { it } }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ if (it.identifier == sdkCollectionId) { it.copy(assets = assets) } else { it } }</ID>

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

@ -1,6 +1,7 @@
package com.tangem.data.onramp
import com.tangem.data.onramp.converters.TransactionConverter
import com.tangem.data.onramp.models.OnrampTerminalTransactionDTO
import com.tangem.data.onramp.models.OnrampTransactionDTO
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
@ -15,7 +16,9 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
internal class DefaultOnrampTransactionRepository(
private val appPreferencesStore: AppPreferencesStore,
@ -47,6 +50,7 @@ internal class DefaultOnrampTransactionRepository(
override fun getAllTransactions(): Flow<List<OnrampTransaction>> {
return appPreferencesStore
.getObjectSet<OnrampTransactionDTO>(PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY)
.onStart { cleanupExpiredTerminalTransactions() }
.map { transactions -> transactions.map(transactionConverter::convert) }
}
@ -63,6 +67,7 @@ internal class DefaultOnrampTransactionRepository(
cryptoCurrencyId: CryptoCurrency.ID,
): Flow<List<OnrampTransaction>> = appPreferencesStore
.getObjectSet<OnrampTransactionDTO>(PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY)
.onStart { cleanupExpiredTerminalTransactions() }
.map { transactions ->
transactions.filter {
it.userWalletId == userWalletId && it.toCurrencyId == cryptoCurrencyId.value
@ -101,4 +106,64 @@ internal class DefaultOnrampTransactionRepository(
}
}
}
override suspend fun isHandledTransaction(txId: String): Boolean = withContext(dispatchers.io) {
val terminated = appPreferencesStore.getObjectSetSync<OnrampTerminalTransactionDTO>(
PreferencesKeys.ONRAMP_HANDLED_TRANSACTIONS_KEY,
)
terminated.any { it.txId == txId }
}
override suspend fun storeHandledTransaction(txId: String) {
withContext(dispatchers.io) {
appPreferencesStore.editData { mutablePreferences ->
runCatching {
val archived = mutablePreferences.getObjectSet<OnrampTerminalTransactionDTO>(
PreferencesKeys.ONRAMP_HANDLED_TRANSACTIONS_KEY,
).orEmpty().toMutableSet()
val record = OnrampTerminalTransactionDTO(
txId = txId,
terminatedAt = System.currentTimeMillis(),
)
archived.add(record)
mutablePreferences.setObjectSet(
key = PreferencesKeys.ONRAMP_HANDLED_TRANSACTIONS_KEY,
value = archived,
)
}
}
}
}
private suspend fun cleanupExpiredTerminalTransactions() {
withContext(dispatchers.io) {
appPreferencesStore.editData { mutablePreferences ->
runCatching {
val archived = mutablePreferences.getObjectSet<OnrampTerminalTransactionDTO>(
PreferencesKeys.ONRAMP_HANDLED_TRANSACTIONS_KEY,
)?.toMutableSet() ?: return@editData
val oneWeekAgo = System.currentTimeMillis() -
TimeUnit.DAYS.toMillis(HANDLED_TRANSACTIONS_RETENTION_DAYS)
val cleaned = archived
.filterTo(mutableSetOf()) { it.terminatedAt > oneWeekAgo }
if (cleaned.size != archived.size) {
mutablePreferences.setObjectSet(
key = PreferencesKeys.ONRAMP_HANDLED_TRANSACTIONS_KEY,
value = cleaned,
)
}
}
}
}
}
private companion object {
const val HANDLED_TRANSACTIONS_RETENTION_DAYS = 7L
}
}

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

@ -0,0 +1,18 @@
package com.tangem.data.onramp.models
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Model for terminal onramp transaction
*
* @property txId Transaction ID
* @property terminatedAt Termination timestamp in milliseconds
*/
@JsonClass(generateAdapter = true)
data class OnrampTerminalTransactionDTO(
@Json(name = "txId")
val txId: String,
@Json(name = "terminatedAt")
val terminatedAt: Long,
)

View file

@ -1,8 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>SuspendFunSwallowedCancellation:DefaultPromoRepository.kt$DefaultPromoRepository$runCatching</ID>
<ID>SuspendFunWithFlowReturnType:DefaultPromoRepository.kt$DefaultPromoRepository$suspend</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -20,6 +20,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
@ -44,7 +45,7 @@ internal class DefaultPromoRepository(
.distinctUntilChanged()
.map { shouldShow ->
when (promoId) {
PromoId.Referral -> runCatching {
PromoId.Referral -> runSuspendCatching {
!referralRepository.isReferralParticipant(userWalletId) && shouldShow
}.getOrDefault(false)
PromoId.Sepa -> {
@ -89,7 +90,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,
@ -125,7 +126,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()
@ -193,8 +194,8 @@ internal class DefaultPromoRepository(
const val SEPA_NAME = "sepa"
const val VISA_NAME = "visa-waitlist"
const val BLACK_FRIDAY_NAME = "black-friday"
const val ONE_PLUS_ONE_NAME = "one-plus-one"
const val MOONPAY_NAME = "moonpay"
const val ONE_PLUS_ONE_NAME = "one-plus-one"
const val STORIES_LOAD_DELAY = 1000L
}
}

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

@ -1,19 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MaxChainedCallsOnSameLine:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2$fromCryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value.orEmpty()</ID>
<ID>MultilineLambdaItParameter:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2${ Timber.w(it, "Unable to get pairs") throw it }</ID>
<ID>MultilineLambdaItParameter:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2${ it.currency.getContractAddress() == pair.from.contractAddress &amp;&amp; it.currency.network.backendId == pair.from.network }</ID>
<ID>MultilineLambdaItParameter:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2${ it.currency.getContractAddress() == pair.to.contractAddress &amp;&amp; it.currency.network.backendId == pair.to.network }</ID>
<ID>MultilineLambdaItParameter:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2${ it.getContractAddress() == pair.from.contractAddress &amp;&amp; it.network.backendId == pair.from.network }</ID>
<ID>MultilineLambdaItParameter:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2${ it.getContractAddress() == pair.to.contractAddress &amp;&amp; it.network.backendId == pair.to.network }</ID>
<ID>MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ it.checkId( checkUserWalletId = userWalletId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ it.userWalletId == userWallet.walletId.stringValue &amp;&amp; ( it.toCryptoCurrencyId == cryptoCurrencyId.value || it.fromCryptoCurrencyId == cryptoCurrencyId.value ) }</ID>
<ID>MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ listConverter.convertBack( value = it, multiAccountList = multiAccountList, userWallet = userWallet, txStatuses = txStatuses, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ storeTransactionState( txId = transaction.txId, status = it, accountWithCurrency = fromAccount?.accountId to fromCryptoCurrency, ) }</ID>
<ID>MultilineLambdaItParameter:SwapDataConverter.kt$SwapDataConverter${ if (it == "0") { BigDecimal.ZERO } else { requireNotNull(it.toBigDecimalOrNull()) { "wrong amount format, use only digits" } } }</ID>
<ID>NoNameShadowing:DefaultSwapRepositoryV2.kt$DefaultSwapRepositoryV2$mappedProviders</ID>
<ID>NoNameShadowing:DefaultSwapTransactionRepository.kt$DefaultSwapTransactionRepository${ it.txId == txId }</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -79,23 +79,23 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
userWallet = userWallet,
filterProviderTypes = filterProviderTypes,
)
val mappedProviders = providers.associateBy(ExpressProvider::providerId)
val expressProviders = providers.associateBy(ExpressProvider::providerId)
allPairs.map { pair ->
async {
val statusFrom = cryptoCurrencyStatusList
.firstOrNull {
it.currency.getContractAddress() == pair.from.contractAddress &&
it.currency.network.backendId == pair.from.network
.firstOrNull { currencyStatus ->
currencyStatus.currency.getContractAddress() == pair.from.contractAddress &&
currencyStatus.currency.network.backendId == pair.from.network
}
val statusTo = cryptoCurrencyStatusList
.firstOrNull {
it.currency.getContractAddress() == pair.to.contractAddress &&
it.currency.network.backendId == pair.to.network
.firstOrNull { currencyStatus ->
currencyStatus.currency.getContractAddress() == pair.to.contractAddress &&
currencyStatus.currency.network.backendId == pair.to.network
}
val mappedProviders = pair.providers.mapNotNull {
mappedProviders[it.providerId]
expressProviders[it.providerId]
}.filterYieldSupplyProvider(statusFrom)
if (statusFrom != null && statusTo != null && mappedProviders.isNotEmpty()) {
@ -135,16 +135,16 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
async {
val statusFromDeferred = async {
cryptoCurrencyList
.firstOrNull {
it.getContractAddress() == pair.from.contractAddress &&
it.network.backendId == pair.from.network
.firstOrNull { currency ->
currency.getContractAddress() == pair.from.contractAddress &&
currency.network.backendId == pair.from.network
}
}
val statusToDeferred = async {
cryptoCurrencyList
.firstOrNull {
it.getContractAddress() == pair.to.contractAddress &&
it.network.backendId == pair.to.network
.firstOrNull { currency ->
currency.getContractAddress() == pair.to.contractAddress &&
currency.network.backendId == pair.to.network
}
}
@ -213,27 +213,27 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
expressOperationType: ExpressOperationType,
): SwapDataModel = withContext(coroutineDispatcher.io) {
val requestId = UUID.randomUUID().toString()
val fromCryptoCurrency = fromCryptoCurrencyStatus.currency
val (fromCurrency, fromStatus) = fromCryptoCurrencyStatus
val refundData = when (expressProvider.type) {
ExpressProviderType.CEX,
ExpressProviderType.DEX_BRIDGE,
ExpressProviderType.DEX,
-> SwapRefundData(
refundAddress = fromCryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value,
refundAddress = fromStatus.networkAddress?.defaultAddress?.value,
refundExtraId = null, // currently always null
)
else -> null
}
val response = tangemExpressApi.getExchangeData(
fromContractAddress = fromCryptoCurrency.getContractAddress(),
fromContractAddress = fromCurrency.getContractAddress(),
toContractAddress = toCryptoCurrency.getContractAddress(),
fromNetwork = fromCryptoCurrency.network.backendId,
fromNetwork = fromCurrency.network.backendId,
toNetwork = toCryptoCurrency.network.backendId,
fromAddress = fromCryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value.orEmpty(),
fromAddress = fromStatus.networkAddress?.defaultAddress?.value.orEmpty(),
toAddress = toAddress,
fromDecimals = fromCryptoCurrency.decimals,
fromDecimals = fromCurrency.decimals,
toDecimals = toCryptoCurrency.decimals,
fromAmount = fromAmount,
providerId = expressProvider.providerId,
@ -277,6 +277,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
txHash: String,
txExtraId: String?,
) {
val (currency, status) = fromCryptoCurrencyStatus
withContext(coroutineDispatcher.io) {
tangemExpressApi.exchangeSent(
userWalletId = userWallet.walletId.stringValue,
@ -286,8 +287,8 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
),
body = ExchangeSentRequestBody(
txId = txId,
fromNetwork = fromCryptoCurrencyStatus.currency.network.backendId,
fromAddress = fromCryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value.orEmpty(),
fromNetwork = currency.network.backendId,
fromAddress = status.networkAddress?.defaultAddress?.value.orEmpty(),
payinAddress = payInAddress,
payinExtraId = txExtraId,
txHash = txHash,
@ -364,9 +365,9 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
),
).getOrThrow()
},
onError = {
Timber.w(it, "Unable to get pairs")
throw it
onError = { error ->
Timber.w(error, "Unable to get pairs")
throw error
},
)
}
@ -403,7 +404,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

@ -64,10 +64,10 @@ internal class DefaultSwapTransactionRepository(
toAccount: Account.CryptoPortfolio?,
transaction: SwapTransactionModel,
) {
transaction.status?.let {
transaction.status?.let { swapTxList ->
storeTransactionState(
txId = transaction.txId,
status = it,
status = swapTxList,
accountWithCurrency = fromAccount?.accountId to fromCryptoCurrency,
)
}
@ -76,8 +76,8 @@ internal class DefaultSwapTransactionRepository(
key = PreferencesKeys.SWAP_TRANSACTIONS_KEY,
)
val tokenTransactions = savedTransactions
?.firstOrNull {
it.checkId(
?.firstOrNull { swapTxList ->
swapTxList.checkId(
checkUserWalletId = userWalletId,
fromCurrencyId = fromCryptoCurrency.id,
toCurrencyId = toCryptoCurrency.id,
@ -129,17 +129,17 @@ internal class DefaultSwapTransactionRepository(
},
) { savedTransactions, txStatuses, multiAccountList ->
val currencyTxs = savedTransactions
?.filter {
it.userWalletId == userWallet.walletId.stringValue &&
?.filter { swapTxList ->
swapTxList.userWalletId == userWallet.walletId.stringValue &&
(
it.toCryptoCurrencyId == cryptoCurrencyId.value ||
it.fromCryptoCurrencyId == cryptoCurrencyId.value
swapTxList.toCryptoCurrencyId == cryptoCurrencyId.value ||
swapTxList.fromCryptoCurrencyId == cryptoCurrencyId.value
)
}
currencyTxs?.mapNotNull {
currencyTxs?.mapNotNull { swapTxList ->
listConverter.convertBack(
value = it,
value = swapTxList,
multiAccountList = multiAccountList,
userWallet = userWallet,
txStatuses = txStatuses,
@ -155,8 +155,8 @@ internal class DefaultSwapTransactionRepository(
)
val tokenTransactions = savedList
?.asSequence()
?.map {
it.copy(transactions = it.transactions.filterNot { it.txId == txId })
?.map { swapTxList ->
swapTxList.copy(transactions = swapTxList.transactions.filterNot { swapTx -> swapTx.txId == txId })
}?.filterNot { it.transactions.isEmpty() }
?.toList()
@ -257,8 +257,8 @@ internal class DefaultSwapTransactionRepository(
toAccount = toAccount,
tokenTransactions = transactions,
),
predicate = {
it.checkId(
predicate = { swapTxList ->
swapTxList.checkId(
checkUserWalletId = userWalletId,
fromCurrencyId = fromCryptoCurrency.id,
toCurrencyId = toCryptoCurrency.id,

View file

@ -31,11 +31,11 @@ internal class SwapDataConverter : Converter<ExchangeDataResponseWithTxDetails,
)
return if (transactionDto.txType == TxType.SWAP) {
val otherNativeFeeWei = transactionDto.otherNativeFee?.let {
if (it == "0") {
val otherNativeFeeWei = transactionDto.otherNativeFee?.let { otherFee ->
if (otherFee == "0") {
BigDecimal.ZERO
} else {
requireNotNull(it.toBigDecimalOrNull()) { "wrong amount format, use only digits" }
requireNotNull(otherFee.toBigDecimalOrNull()) { "wrong amount format, use only digits" }
}
}
SwapDataTransactionModel.DEX(

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

@ -14,10 +14,8 @@
<ID>NullCheckOnMutableProperty:VisaLibLoader.kt$VisaLibLoader$if (config != null) return@withLock requireNotNull(config)</ID>
<ID>NullCheckOnMutableProperty:VisaLibLoader.kt$VisaLibLoader$if (provider != null) return@withLock requireNotNull(provider)</ID>
<ID>NullableToStringCall:DefaultOnboardingRepository.kt$DefaultOnboardingRepository$${error.message}</ID>
<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

@ -0,0 +1,94 @@
package com.tangem.data.pay
import com.tangem.common.card.FirmwareVersion
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
internal class DefaultTangemPayEligibilityManager @Inject constructor(
private val userWalletsListManager: UserWalletsListManager,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val onboardingRepository: OnboardingRepository,
) : TangemPayEligibilityManager {
private var cachedEligibleWallets: List<UserWallet>? = null
private var eligibleWalletsDeferred: Deferred<List<UserWallet>>? = null
private val loadMutex = Mutex()
override suspend fun getEligibleWallets(): List<UserWallet> {
cachedEligibleWallets?.let { return it }
return loadMutex.withLock {
cachedEligibleWallets?.let { return it }
eligibleWalletsDeferred?.let { return it.await() }
coroutineScope {
val deferred = async {
getPossibleWalletsForTangemPay()
.excludePaeraCustomers()
.also { cachedEligibleWallets = it }
}
eligibleWalletsDeferred = deferred
try {
deferred.await()
} finally {
eligibleWalletsDeferred = null
}
}
}
}
private suspend fun getPossibleWalletsForTangemPay(): List<UserWallet> {
if (!onboardingRepository.checkCustomerEligibility()) {
return emptyList()
}
val wallets = if (hotWalletFeatureToggles.isHotWalletEnabled) {
userWalletsListRepository.userWallets.value
} else {
userWalletsListManager.userWalletsSync
} ?: return emptyList()
return wallets.filter { wallet ->
wallet.isMultiCurrency && !wallet.isLocked && wallet.isCompatible()
}
}
private fun UserWallet.isCompatible(): Boolean = when (this) {
is UserWallet.Cold ->
scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable
is UserWallet.Hot -> true
}
private suspend fun List<UserWallet>.excludePaeraCustomers(): List<UserWallet> {
if (isEmpty()) return this
return coroutineScope {
map { wallet ->
async {
val isCustomer = onboardingRepository
.checkCustomerWallet(wallet.walletId)
.getOrNull() == true
wallet to isCustomer
}
}
.awaitAll()
.mapNotNull { (wallet, isCustomer) ->
wallet.takeUnless { isCustomer }
}
}
}
}

View file

@ -1,10 +1,12 @@
package com.tangem.data.pay.di
import com.tangem.data.pay.DefaultTangemPayCryptoCurrencyFactory
import com.tangem.data.pay.DefaultTangemPayEligibilityManager
import com.tangem.data.pay.repository.*
import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase
import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawUseCase
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
@ -62,6 +64,10 @@ internal interface TangemPayDataModule {
@Singleton
fun bindTangemPayWithdrawUseCase(impl: DefaultTangemPayWithdrawUseCase): TangemPayWithdrawUseCase
@Binds
@Singleton
fun bindTangemPayEligibilityManager(impl: DefaultTangemPayEligibilityManager): TangemPayEligibilityManager
companion object {
@Provides
@Singleton
@ -69,11 +75,13 @@ internal interface TangemPayDataModule {
repository: OnboardingRepository,
customerOrderRepository: CustomerOrderRepository,
tangemPayOnboardingRepository: OnboardingRepository,
eligibilityManager: TangemPayEligibilityManager,
): TangemPayMainScreenCustomerInfoUseCase {
return TangemPayMainScreenCustomerInfoUseCase(
repository = repository,
customerOrderRepository = customerOrderRepository,
tangemPayOnboardingRepository = tangemPayOnboardingRepository,
eligibilityManager = eligibilityManager,
)
}

View file

@ -84,7 +84,6 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo> {
// TODO implement selector
return requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.getCustomerMe(authHeader) }
.map { response -> getCustomerInfo(userWalletId = userWalletId, response = response.result) }
}
@ -182,7 +181,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
customerWalletId = userWalletId.stringValue,
)
}.map { response ->
val id = response.id
val id = response.result?.id
val isPaeraCustomer = !id.isNullOrEmpty()
tangemPayStorage.storeCheckCustomerWalletResult(userWalletId = userWalletId, isPaeraCustomer)
isPaeraCustomer
@ -193,4 +192,19 @@ internal class DefaultOnboardingRepository @Inject constructor(
error
}
}
override suspend fun checkCustomerEligibility(): Boolean {
val response = requestHelper.performWithoutToken {
tangemPayApi.checkCustomerEligibility()
}.getOrNull()
return response?.result?.isTangemPayAvailable == true
}
override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean {
return tangemPayStorage.getHideMainOnboardingBanner(userWalletId)
}
override suspend fun setHideMainOnboardingBanner(userWalletId: UserWalletId) {
tangemPayStorage.storeHideOnboardingBanner(userWalletId, hide = true)
}
}

View file

@ -279,6 +279,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

@ -89,6 +89,19 @@ internal class TangemPayRequestPerformer @Inject constructor(
)
}
suspend fun <T : Any> performWithoutToken(requestBlock: suspend () -> ApiResponse<T>): Either<VisaApiError, T> =
withContext(dispatchers.io) {
catch(
block = {
when (val apiResponse = requestBlock()) {
is ApiResponse.Error -> errorConverter.convert(apiResponse.cause).left()
is ApiResponse.Success<T> -> apiResponse.data.right()
}
},
catch = { errorConverter.convert(it).left() },
)
}
suspend fun <T : Any> performRequest(
userWalletId: UserWalletId,
requestBlock: suspend (header: String) -> ApiResponse<T>,

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

@ -2,7 +2,7 @@ package com.tangem.data.pay.util
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.pay.models.response.VisaErrorResponse
import com.tangem.datasource.api.pay.models.response.TangemPayErrorResponse
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.converter.Converter
@ -14,7 +14,7 @@ internal class TangemPayErrorConverter @Inject constructor(
@NetworkMoshi moshi: Moshi,
) : Converter<Throwable, VisaApiError> {
private val visaErrorAdapter by lazy { moshi.adapter(VisaErrorResponse::class.java) }
private val tangemPayErrorAdapter by lazy { moshi.adapter(TangemPayErrorResponse::class.java) }
override fun convert(value: Throwable): VisaApiError {
return if (value is ApiResponseError.HttpException) {
@ -23,7 +23,7 @@ internal class TangemPayErrorConverter @Inject constructor(
val errorBody = value.errorBody ?: return VisaApiError.UnknownWithoutCode
return runCatching {
visaErrorAdapter.fromJson(errorBody)?.error?.code ?: value.code.numericCode
tangemPayErrorAdapter.fromJson(errorBody)?.error?.code ?: value.code.numericCode
}.map {
VisaApiError.fromBackendError(it)
}.getOrElse {

View file

@ -1,39 +0,0 @@
package com.tangem.data.pay.util
import com.tangem.common.card.FirmwareVersion
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import javax.inject.Inject
// TODO remove after implement wallet selector in pay
class TangemPayWalletsManager @Inject constructor(
private val manager: UserWalletsListManager,
private val repository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
) {
@Deprecated("Don't use and put userWallet in features that need it")
suspend fun getDefaultWalletForTangemPay(): UserWallet.Cold {
val userWalletsFlow = if (useNewRepository()) repository.userWallets else manager.userWallets
val userWallets = userWalletsFlow.filter { !it.isNullOrEmpty() }.first()
return findColdWallet(userWallets)
}
@Deprecated("Don't use and put userWallet in features that need it")
fun getDefaultWalletForTangemPayBlocking(): UserWallet.Cold {
val userWallets = if (useNewRepository()) repository.userWallets.value else manager.userWalletsSync
return findColdWallet(userWallets)
}
private fun useNewRepository(): Boolean = hotWalletFeatureToggles.isHotWalletEnabled
private fun findColdWallet(userWallets: List<UserWallet>?): UserWallet.Cold {
return userWallets?.find {
it is UserWallet.Cold && it.scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable
} as? UserWallet.Cold ?: error("Cannot find cold user wallet")
}
}

View file

@ -0,0 +1,87 @@
package com.tangem.data.visa
import arrow.core.Either
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.TangemPayAuthApi
import com.tangem.datasource.api.pay.models.request.GenerateNonceByCustomerWalletRequest
import com.tangem.datasource.api.pay.models.request.GetTokenByCustomerWalletRequest
import com.tangem.datasource.api.pay.models.response.TangemPayErrorResponse
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.domain.visa.datasource.TangemPayRemoteDataSource
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayAuthTokens
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaAuthSession
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class DefaultTangemPayRemoteDataSource @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
private val tangemPayAuthApi: TangemPayAuthApi,
private val dispatchers: CoroutineDispatcherProvider,
) : TangemPayRemoteDataSource {
private val errorAdapter by lazy { moshi.adapter(TangemPayErrorResponse::class.java) }
override suspend fun getCustomerWalletAuthChallenge(
customerWalletAddress: String,
customerWalletId: String,
): Either<VisaApiError, VisaAuthChallenge.Wallet> = withContext(dispatchers.io) {
request {
tangemPayAuthApi.generateNonceByCustomerWallet(
request = GenerateNonceByCustomerWalletRequest(
customerWalletAddress = customerWalletAddress,
customerWalletId = customerWalletId,
),
).getOrThrow()
}.map { response ->
VisaAuthChallenge.Wallet(
challenge = response.nonce,
session = VisaAuthSession(response.sessionId),
)
}
}
override suspend fun getTokenWithCustomerWallet(
sessionId: String,
signature: String,
nonce: String,
): Either<VisaApiError, TangemPayAuthTokens> = withContext(dispatchers.io) {
request {
tangemPayAuthApi.getTokenByCustomerWallet(
request = GetTokenByCustomerWalletRequest(
authType = "customer_wallet",
sessionId = sessionId,
signature = signature,
messageFormat = "Tangem Pay wants to sign in with your account. Nonce: $nonce",
),
).getOrThrow()
}.map { response ->
TangemPayAuthTokens(
accessToken = response.accessToken,
expiresAt = response.expiresAt,
refreshToken = response.refreshToken,
refreshExpiresAt = response.refreshExpiresAt,
)
}
}
private suspend fun <T : Any> request(requestBlock: suspend () -> T): Either<VisaApiError, T> {
return runCatching {
Either.Right(requestBlock())
}.getOrElse { responseError ->
if (responseError is ApiResponseError.HttpException &&
responseError.errorBody != null
) {
val errorCode =
errorAdapter.fromJson(responseError.errorBody)?.error?.code ?: responseError.code.numericCode
return Either.Left(VisaApiError.fromBackendError(errorCode))
}
return Either.Left(VisaApiError.UnknownWithoutCode)
}
}
}

View file

@ -10,15 +10,16 @@ import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.*
import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter
import com.tangem.datasource.api.pay.models.request.SetPinCodeRequest
import com.tangem.datasource.api.pay.models.response.TangemPayErrorResponse
import com.tangem.datasource.api.visa.VisaApi
import com.tangem.datasource.api.visa.models.request.*
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -29,7 +30,7 @@ import kotlinx.coroutines.withContext
internal class DefaultVisaActivationRepository @AssistedInject constructor(
@Assisted private val visaCardId: VisaCardId,
@NetworkMoshi private val moshi: Moshi,
private val visaApi: TangemPayApi,
private val visaApi: VisaApi,
private val dispatcherProvider: CoroutineDispatcherProvider,
private val visaAuthTokenStorage: VisaAuthTokenStorage,
private val visaAuthRemoteDataSource: VisaAuthRemoteDataSource,
@ -37,7 +38,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
private val apiConfigsManager: ApiConfigsManager,
) : VisaActivationRepository {
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)
private val errorAdapter by lazy { moshi.adapter(TangemPayErrorResponse::class.java) }
override suspend fun getActivationRemoteState(): Either<VisaApiError, VisaActivationRemoteState> =
withContext(dispatcherProvider.io) {
@ -171,6 +172,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
@ -186,7 +188,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
responseError.errorBody != null
) {
val errorCode =
visaErrorAdapter.fromJson(responseError.errorBody!!)?.error?.code ?: responseError.code.numericCode
errorAdapter.fromJson(responseError.errorBody!!)?.error?.code ?: responseError.code.numericCode
return Either.Left(VisaApiError.fromBackendError(errorCode))
}
@ -206,7 +208,7 @@ internal class DefaultVisaActivationRepository @AssistedInject constructor(
}.getOrElse { responseError ->
if (responseError is ApiResponseError.HttpException && responseError.errorBody != null) {
val errorCode =
visaErrorAdapter.fromJson(responseError.errorBody!!)?.error?.code
errorAdapter.fromJson(responseError.errorBody!!)?.error?.code
?: responseError.code.numericCode
Either.Left(VisaApiError.fromBackendError(errorCode))
} else {

View file

@ -4,33 +4,35 @@ import arrow.core.Either
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.TangemPayAuthApi
import com.tangem.datasource.api.pay.models.request.*
import com.tangem.datasource.api.pay.models.response.VisaErrorResponseJsonAdapter
import com.tangem.datasource.api.pay.models.request.RefreshTokenByCardIdRequest
import com.tangem.datasource.api.pay.models.response.TangemPayErrorResponse
import com.tangem.datasource.api.visa.VisaApi
import com.tangem.datasource.api.visa.models.request.*
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.*
import com.tangem.domain.visa.model.VisaAuthChallenge
import com.tangem.domain.visa.model.VisaAuthSession
import com.tangem.domain.visa.model.VisaAuthSignedChallenge
import com.tangem.domain.visa.model.VisaAuthTokens
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
@NetworkMoshi private val moshi: Moshi,
private val visaAuthApi: TangemPayApi,
private val tangemPayAuthApi: TangemPayAuthApi,
private val visaApi: VisaApi,
private val dispatchers: CoroutineDispatcherProvider,
) : VisaAuthRemoteDataSource {
private val visaErrorAdapter = VisaErrorResponseJsonAdapter(moshi)
private val errorAdapter by lazy { moshi.adapter(TangemPayErrorResponse::class.java) }
override suspend fun getCardAuthChallenge(
cardId: String,
cardPublicKey: String,
): Either<VisaApiError, VisaAuthChallenge.Card> = withContext(dispatchers.io) {
request {
visaAuthApi.generateNonceByCardId(
visaApi.generateNonceByCardId(
GenerateNoneByCardIdRequest(
cardId = cardId,
cardPublicKey = cardPublicKey,
@ -49,7 +51,7 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
cardWalletAddress: String,
): Either<VisaApiError, VisaAuthChallenge.Wallet> = withContext(dispatchers.io) {
request {
visaAuthApi.generateNonceByCardWallet(
visaApi.generateNonceByCardWallet(
GenerateNoneByCardWalletRequest(
cardWalletAddress = cardWalletAddress,
cardId = cardId,
@ -63,56 +65,13 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
}
}
override suspend fun getCustomerWalletAuthChallenge(
customerWalletAddress: String,
customerWalletId: String,
): Either<VisaApiError, VisaAuthChallenge.Wallet> = withContext(dispatchers.io) {
request {
tangemPayAuthApi.generateNonceByCustomerWallet(
request = GenerateNonceByCustomerWalletRequest(
customerWalletAddress = customerWalletAddress,
customerWalletId = customerWalletId,
),
).getOrThrow()
}.map { response ->
VisaAuthChallenge.Wallet(
challenge = response.nonce,
session = VisaAuthSession(response.sessionId),
)
}
}
override suspend fun getTokenWithCustomerWallet(
sessionId: String,
signature: String,
nonce: String,
): Either<VisaApiError, TangemPayAuthTokens> = withContext(dispatchers.io) {
request {
tangemPayAuthApi.getTokenByCustomerWallet(
request = GetTokenByCustomerWalletRequest(
authType = "customer_wallet",
sessionId = sessionId,
signature = signature,
messageFormat = "Tangem Pay wants to sign in with your account. Nonce: $nonce",
),
).getOrThrow()
}.map { response ->
TangemPayAuthTokens(
accessToken = response.accessToken,
expiresAt = response.expiresAt,
refreshToken = response.refreshToken,
refreshExpiresAt = response.refreshExpiresAt,
)
}
}
override suspend fun getAccessTokens(
signedChallenge: VisaAuthSignedChallenge,
): Either<VisaApiError, VisaAuthTokens> = withContext(dispatchers.io) {
request {
when (signedChallenge) {
is VisaAuthSignedChallenge.ByCardPublicKey -> {
visaAuthApi.getAccessTokenByCardId(
visaApi.getAccessTokenByCardId(
GetAccessTokenByCardIdRequest(
sessionId = signedChallenge.challenge.session.sessionId,
signature = signedChallenge.signature,
@ -121,7 +80,7 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
).getOrThrow()
}
is VisaAuthSignedChallenge.ByWallet -> {
visaAuthApi.getAccessTokenByCardWallet(
visaApi.getAccessTokenByCardWallet(
GetAccessTokenByCardWalletRequest(
sessionId = signedChallenge.challenge.session.sessionId,
signature = signedChallenge.signature,
@ -150,11 +109,11 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
request {
when (refreshToken.authType) {
VisaAuthTokens.RefreshToken.Type.CardId ->
visaAuthApi.refreshCardIdAccessToken(
visaApi.refreshCardIdAccessToken(
RefreshTokenByCardIdRequest(refreshToken = refreshToken.value),
)
VisaAuthTokens.RefreshToken.Type.CardWallet ->
visaAuthApi.refreshCardIdAccessToken(
visaApi.refreshCardIdAccessToken(
RefreshTokenByCardIdRequest(refreshToken = refreshToken.value),
)
}.getOrThrow()
@ -169,7 +128,7 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
override suspend fun exchangeAccessToken(tokens: VisaAuthTokens): Either<VisaApiError, VisaAuthTokens> =
withContext(dispatchers.io) {
request {
visaAuthApi.exchangeAccessToken(
visaApi.exchangeAccessToken(
ExchangeAccessTokenRequest(
accessToken = tokens.accessToken,
refreshToken = tokens.refreshToken.value,
@ -194,7 +153,7 @@ internal class DefaultVisaAuthRemoteDataSource @Inject constructor(
responseError.errorBody != null
) {
val errorCode =
visaErrorAdapter.fromJson(responseError.errorBody!!)?.error?.code ?: responseError.code.numericCode
errorAdapter.fromJson(responseError.errorBody!!)?.error?.code ?: responseError.code.numericCode
return Either.Left(VisaApiError.fromBackendError(errorCode))
}

View file

@ -14,8 +14,8 @@ import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.common.quote.QuotesFetcher
import com.tangem.data.visa.config.VisaLibLoader
import com.tangem.data.visa.utils.*
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse
import com.tangem.datasource.api.visa.VisaApi
import com.tangem.datasource.api.visa.models.response.VisaTxHistoryResponse
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
@ -43,7 +43,7 @@ internal class DefaultVisaRepository @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val dispatchers: CoroutineDispatcherProvider,
private val visaApiRequestMaker: VisaApiRequestMaker,
private val visaApi: TangemPayApi,
private val visaApi: VisaApi,
private val visaCurrencyFactory: VisaCurrencyFactory,
) : VisaRepository {

View file

@ -1,6 +1,6 @@
package com.tangem.data.visa.converter
import com.tangem.datasource.api.pay.models.response.CardActivationRemoteStateResponse
import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse
import com.tangem.domain.visa.model.VisaActivationOrderInfo
import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.utils.converter.Converter

View file

@ -1,10 +1,12 @@
package com.tangem.data.visa.di
import com.tangem.data.pay.datasource.DefaultTangemPayAuthDataSource
import com.tangem.data.visa.DefaultTangemPayRemoteDataSource
import com.tangem.data.visa.DefaultVisaActivationRepository
import com.tangem.data.visa.DefaultVisaAuthRemoteDataSource
import com.tangem.data.visa.MockVisaRepository
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.datasource.TangemPayRemoteDataSource
import com.tangem.domain.visa.repository.VisaActivationRepository
import com.tangem.domain.visa.datasource.VisaAuthRemoteDataSource
import com.tangem.domain.visa.repository.VisaRepository
@ -22,22 +24,16 @@ internal interface VisaDataModule {
@Singleton
fun bindVisaAuthRemoteDataSource(repository: DefaultVisaAuthRemoteDataSource): VisaAuthRemoteDataSource
@Binds
@Singleton
fun bindTangemPayRemoteDataSource(impl: DefaultTangemPayRemoteDataSource): TangemPayRemoteDataSource
@Binds
@Singleton
fun bindVisaActivationRepositoryFactory(
repository: DefaultVisaActivationRepository.Factory,
): VisaActivationRepository.Factory
// Mocked
// @Binds
// @Singleton
// fun bindVisaActivationRepositoryFactory(
// repository: MockVisaActivationRepository.Factory,
// ): VisaActivationRepository.Factory
// @Binds
// fun bindVisaRepository(repository: DefaultVisaRepository): VisaRepository
// Mocked
@Binds
fun bindVisaRepository(repository: MockVisaRepository): VisaRepository

View file

@ -5,8 +5,8 @@ import com.tangem.data.visa.model.AccessCodeData
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.RefreshTokenByCardWalletRequest
import com.tangem.datasource.api.visa.VisaApi
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
@ -24,7 +24,7 @@ typealias VisaAuthorizationHeader = String
internal class VisaApiRequestMaker @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val visaAuthApi: TangemPayApi,
private val visaAuthApi: VisaApi,
private val accessCodeDataConverter: AccessCodeDataConverter,
private val dispatcherProvider: CoroutineDispatcherProvider,
) {

View file

@ -2,7 +2,7 @@ package com.tangem.data.visa.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.externallinkprovider.TxExploreState
import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse
import com.tangem.datasource.api.visa.models.response.VisaTxHistoryResponse
import com.tangem.domain.visa.model.VisaTxDetails
internal class VisaTxDetailsFactory {

View file

@ -1,6 +1,6 @@
package com.tangem.data.visa.utils
import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse
import com.tangem.datasource.api.visa.models.response.VisaTxHistoryResponse
import com.tangem.domain.visa.model.VisaTxHistoryItem
import com.tangem.utils.converter.Converter

View file

@ -3,7 +3,7 @@ package com.tangem.data.visa.utils
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.datasource.api.pay.models.response.VisaTxHistoryResponse
import com.tangem.datasource.api.visa.models.response.VisaTxHistoryResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.visa.model.VisaTxHistoryItem
import com.tangem.utils.coroutines.CoroutineDispatcherProvider

View file

@ -47,7 +47,6 @@
<ID>SuspendFunSwallowedCancellation:DefaultWcPairUseCase.kt$DefaultWcPairUseCase$runCatching</ID>
<ID>UseAnyOrNoneInsteadOfFind:DefaultWcSessionsManager.kt$DefaultWcSessionsManager$find { it.sdkModel.topic == dto.topic }</ID>
<ID>UseEmptyCounterpart:AssociateNetworksDelegate.kt$AssociateNetworksDelegate.Companion$listOf()</ID>
<ID>UseEmptyCounterpart:DefaultWcRequestService.kt$DefaultWcRequestService$setOf()</ID>
<ID>UseEmptyCounterpart:WcAppMetaDataConverter.kt$WcAppMetaDataConverter$listOf()</ID>
<ID>UseEmptyCounterpart:WcNetworksConverter.kt$WcNetworksConverter$listOf()</ID>
<ID>UseEmptyCounterpart:WcSdkSessionConverter.kt$WcSdkSessionConverter$listOf()</ID>

View file

@ -166,7 +166,6 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
}
override fun approve(sessionForApprove: WcSessionApprove) {
analytics.send(WcAnalyticEvents.PairButtonConnect)
onCallTerminalAction.trySend(TerminalAction.Approve(sessionForApprove))
}

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>

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