Updated on 2026-08-14
This commit is contained in:
commit
7e48e67354
942 changed files with 40912 additions and 7544 deletions
|
|
@ -70,5 +70,6 @@ dependencies {
|
|||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(deps.test.turbine)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
package com.tangem.data.account.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.dataStoreFile
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.data.account.converter.AccountConverterFactoryContainer
|
||||
import com.tangem.data.account.featuretoggle.DefaultAccountsFeatureToggles
|
||||
import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher
|
||||
import com.tangem.data.account.repository.AccountsExpandedDTO
|
||||
import com.tangem.data.account.repository.DefaultAccountsCRUDRepository
|
||||
import com.tangem.data.account.repository.DefaultAccountsExpandedRepository
|
||||
import com.tangem.data.account.store.AccountsResponseStoreFactory
|
||||
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
|
||||
import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration
|
||||
|
|
@ -13,11 +18,16 @@ import com.tangem.data.common.account.WalletAccountsFetcher
|
|||
import com.tangem.data.common.account.WalletAccountsSaver
|
||||
import com.tangem.data.common.currency.UserTokensSaver
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.local.accounts.AccountTokenMigrationStore
|
||||
import com.tangem.datasource.local.datastore.RuntimeStateStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.datasource.utils.MoshiDataStoreSerializer
|
||||
import com.tangem.datasource.utils.mapWithStringKeyTypes
|
||||
import com.tangem.datasource.utils.setTypes
|
||||
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
|
||||
import com.tangem.domain.account.repository.AccountsCRUDRepository
|
||||
import com.tangem.domain.account.repository.AccountsExpandedRepository
|
||||
import com.tangem.domain.account.tokens.MainAccountTokensMigration
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
|
|
@ -25,6 +35,8 @@ import dagger.Provides
|
|||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
|
|
@ -63,6 +75,28 @@ internal object AccountDataModule {
|
|||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAccountsExpandedRepository(
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
@ApplicationContext context: Context,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): AccountsExpandedRepository {
|
||||
val store = DataStoreFactory.create<Map<String, Set<AccountsExpandedDTO>>>(
|
||||
serializer = MoshiDataStoreSerializer(
|
||||
moshi = moshi,
|
||||
types = mapWithStringKeyTypes(valueTypes = setTypes<AccountsExpandedDTO>()),
|
||||
defaultValue = emptyMap(),
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = "account_expanded_store") },
|
||||
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
|
||||
)
|
||||
|
||||
return DefaultAccountsExpandedRepository(
|
||||
store = store,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWalletAccountsFetcher(impl: DefaultWalletAccountsFetcher): WalletAccountsFetcher = impl
|
||||
|
|
@ -74,10 +108,16 @@ internal object AccountDataModule {
|
|||
@Provides
|
||||
@Singleton
|
||||
fun provideMainAccountTokensMigration(
|
||||
defaultMainAccountTokensMigration: DefaultMainAccountTokensMigration,
|
||||
): MainAccountTokensMigration = defaultMainAccountTokensMigration
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDefaultMainAccountTokensMigration(
|
||||
accountsResponseStoreFactory: AccountsResponseStoreFactory,
|
||||
userTokensSaver: UserTokensSaver,
|
||||
accountTokenMigrationStore: AccountTokenMigrationStore,
|
||||
): MainAccountTokensMigration {
|
||||
): DefaultMainAccountTokensMigration {
|
||||
return DefaultMainAccountTokensMigration(
|
||||
accountsResponseStoreFactory = accountsResponseStoreFactory,
|
||||
accountTokenMigrationStore = accountTokenMigrationStore,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.data.account.fetcher
|
|||
|
||||
import com.tangem.data.account.store.AccountsResponseStore
|
||||
import com.tangem.data.account.store.AccountsResponseStoreFactory
|
||||
import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration
|
||||
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
|
||||
import com.tangem.data.account.utils.assignTokens
|
||||
import com.tangem.data.common.account.WalletAccountsFetcher
|
||||
|
|
@ -50,6 +51,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
|
|||
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory,
|
||||
private val eTagsStore: ETagsStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val mainAccountTokensMigration: DefaultMainAccountTokensMigration,
|
||||
) : WalletAccountsFetcher, WalletAccountsSaver {
|
||||
|
||||
override suspend fun fetch(userWalletId: UserWalletId): GetWalletAccountsResponse {
|
||||
|
|
@ -71,7 +73,8 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
|
|||
throw fetchResult.error
|
||||
}
|
||||
|
||||
return updatedResponse
|
||||
val migratedResponse = mainAccountTokensMigration.migrate(userWalletId).getOrNull() ?: updatedResponse
|
||||
return migratedResponse
|
||||
}
|
||||
|
||||
override suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse? {
|
||||
|
|
@ -203,7 +206,12 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
|
|||
|
||||
store(userWalletId = userWalletId, response = response)
|
||||
|
||||
push(userWalletId = userWalletId, accounts = response.accounts)
|
||||
val isFailed = push(userWalletId = userWalletId, accounts = response.accounts) == null
|
||||
if (isFailed) {
|
||||
// Clear ETags if push failed to avoid different state in the cache and API
|
||||
eTagsStore.clear(userWalletId, ETagsStore.Key.WalletAccounts)
|
||||
}
|
||||
|
||||
userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse())
|
||||
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
package com.tangem.data.account.repository
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.tangem.data.account.converter.toAccountId
|
||||
import com.tangem.domain.account.models.AccountExpandedState
|
||||
import com.tangem.domain.account.repository.AccountsExpandedRepository
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
internal class DefaultAccountsExpandedRepository(
|
||||
private val store: DataStore<Map<String, Set<AccountsExpandedDTO>>>,
|
||||
) : AccountsExpandedRepository {
|
||||
|
||||
override val expandedAccounts: Flow<Map<UserWalletId, Set<AccountExpandedState>>> = store.data
|
||||
.map { stored ->
|
||||
stored.map { (rawWalletId, setOfDto) ->
|
||||
val walletId = UserWalletId(rawWalletId)
|
||||
val setOfState = setOfDto.mapTo(mutableSetOf()) { dto ->
|
||||
AccountExpandedState(
|
||||
accountId = dto.accountId.toAccountId(walletId),
|
||||
isExpanded = dto.isExpanded,
|
||||
)
|
||||
}
|
||||
walletId to setOfState
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
override suspend fun syncStore(walletId: UserWalletId, existAccounts: Set<AccountId>) {
|
||||
store.updateData { map ->
|
||||
val setOfDto = map[walletId.stringValue] ?: return@updateData map
|
||||
val existingAccountIds = existAccounts.map { it.value }.toSet()
|
||||
val syncedSet = setOfDto
|
||||
.filterTo(mutableSetOf()) { (accountId, _) -> existingAccountIds.contains(accountId) }
|
||||
|
||||
map.plus(walletId.stringValue to syncedSet)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun update(accountState: AccountExpandedState) {
|
||||
store.updateData { map ->
|
||||
val walletId = accountState.accountId.userWalletId
|
||||
val setOfDto = map[walletId.stringValue].orEmpty()
|
||||
val newDto = AccountsExpandedDTO(
|
||||
accountId = accountState.accountId.value,
|
||||
isExpanded = accountState.isExpanded,
|
||||
)
|
||||
val updatedSet = setOfDto
|
||||
.filterTo(mutableSetOf()) { it.accountId != accountState.accountId.value }
|
||||
.plus(newDto)
|
||||
|
||||
map.plus(walletId.stringValue to updatedSet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
internal data class AccountsExpandedDTO(
|
||||
@Json(name = "accountId")
|
||||
val accountId: String,
|
||||
@Json(name = "isExpanded")
|
||||
val isExpanded: Boolean,
|
||||
)
|
||||
|
|
@ -7,6 +7,7 @@ import arrow.core.raise.ensureNotNull
|
|||
import arrow.core.toNonEmptyListOrNull
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.data.account.converter.toDerivationIndex
|
||||
import com.tangem.data.account.store.AccountsResponseStoreFactory
|
||||
import com.tangem.data.account.utils.assignTokens
|
||||
import com.tangem.data.common.currency.UserTokensSaver
|
||||
|
|
@ -37,6 +38,67 @@ internal class DefaultMainAccountTokensMigration(
|
|||
private val userTokensSaver: UserTokensSaver,
|
||||
) : MainAccountTokensMigration {
|
||||
|
||||
internal suspend fun migrate(userWalletId: UserWalletId): Either<Throwable, GetWalletAccountsResponse> = either {
|
||||
val store = accountsResponseStoreFactory.create(userWalletId)
|
||||
val response = store.getSyncOrNull()
|
||||
ensureNotNull(response) {
|
||||
val exception = IllegalStateException("No cached accounts response found")
|
||||
Timber.e(exception)
|
||||
exception
|
||||
}
|
||||
val mainAccount = findAccount(response = response, derivationIndex = DerivationIndex.Main)
|
||||
val notMainAccounts = response.accounts
|
||||
.filterNot { accountDTO -> accountDTO.derivationIndex.toDerivationIndex().isMain }
|
||||
|
||||
if (notMainAccounts.isEmpty()) {
|
||||
Timber.i("There is only the Main account. Nothing to migrate")
|
||||
return@either response
|
||||
}
|
||||
|
||||
val unassignedTokens = mainAccount.groupUnassignedTokens()
|
||||
|
||||
if (unassignedTokens.isEmpty()) {
|
||||
Timber.i("No unassigned tokens found for migration")
|
||||
return@either response
|
||||
}
|
||||
|
||||
var updatedMainAccount = mainAccount
|
||||
val assignedTokensAccounts = notMainAccounts.mapNotNull { accountDTO ->
|
||||
val derivationIndex = accountDTO.derivationIndex.toDerivationIndex()
|
||||
val tokensForAccount = unassignedTokens[derivationIndex]
|
||||
if (tokensForAccount.isNullOrEmpty()) return@mapNotNull null
|
||||
updatedMainAccount = updatedMainAccount.copy(
|
||||
tokens = updatedMainAccount.tokens.orEmpty() - tokensForAccount,
|
||||
)
|
||||
accountDTO.assignTokens(userWalletId, tokensForAccount)
|
||||
}
|
||||
|
||||
val updatedResponse = response.copy(
|
||||
accounts = response.accounts.map { account ->
|
||||
val assignAccount = assignedTokensAccounts.find { it.id == account.id }
|
||||
when {
|
||||
account.id == mainAccount.id -> updatedMainAccount
|
||||
assignAccount != null -> assignAccount
|
||||
else -> account
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
store.updateData { updatedResponse }
|
||||
|
||||
val userTokensResponse = updatedResponse.toUserTokensResponse()
|
||||
userTokensSaver.pushWithRetryer(
|
||||
userWalletId = userWalletId,
|
||||
response = userTokensResponse,
|
||||
onFailSend = {
|
||||
val exception = IllegalStateException("Failed to push updated tokens after migration")
|
||||
Timber.e(exception)
|
||||
raise(exception)
|
||||
},
|
||||
)
|
||||
return@either updatedResponse
|
||||
}
|
||||
|
||||
override suspend fun migrate(
|
||||
userWalletId: UserWalletId,
|
||||
derivationIndex: DerivationIndex,
|
||||
|
|
@ -113,6 +175,22 @@ internal class DefaultMainAccountTokensMigration(
|
|||
}
|
||||
}
|
||||
|
||||
private fun WalletAccountDTO.groupUnassignedTokens(): Map<DerivationIndex, List<UserTokensResponse.Token>> =
|
||||
this.tokens
|
||||
.orEmpty()
|
||||
.mapNotNull { token ->
|
||||
val blockchain = Blockchain.fromNetworkId(token.networkId) ?: return@mapNotNull null
|
||||
val derivationPath = token.derivationPath ?: return@mapNotNull null
|
||||
val accountNode = AccountNodeRecognizer(blockchain)
|
||||
.recognize(derivationPath)
|
||||
?: return@mapNotNull null
|
||||
|
||||
if (accountNode == DerivationIndex.Main.value.toLong()) return@mapNotNull null
|
||||
val derivationIndex = DerivationIndex(accountNode.toInt()).getOrNull() ?: return@mapNotNull null
|
||||
derivationIndex to token.copy(accountId = null)
|
||||
}
|
||||
.groupBy({ it.first }, { it.second })
|
||||
|
||||
private fun WalletAccountDTO.findUnassignedTokens(
|
||||
derivationIndex: DerivationIndex,
|
||||
): List<UserTokensResponse.Token>? {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package com.tangem.data.account.fetcher
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.data.account.converter.createGetWalletAccountsResponse
|
||||
import com.tangem.data.account.converter.createWalletAccountDTO
|
||||
import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher.FetchResult
|
||||
import com.tangem.data.account.store.AccountsResponseStore
|
||||
import com.tangem.data.account.store.AccountsResponseStoreFactory
|
||||
import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration
|
||||
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
|
||||
import com.tangem.data.common.cache.etag.ETagsStore
|
||||
import com.tangem.data.common.currency.UserTokensSaver
|
||||
|
|
@ -16,6 +18,7 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi
|
|||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
|
|
@ -35,9 +38,10 @@ class DefaultWalletAccountsFetcherTest {
|
|||
private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk()
|
||||
private val accountsResponseStore: AccountsResponseStore = mockk()
|
||||
private val accountsResponseStoreFlow = MutableStateFlow<GetWalletAccountsResponse?>(value = null)
|
||||
private val tokensMigration: DefaultMainAccountTokensMigration = mockk()
|
||||
|
||||
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
|
||||
private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler = mockk(relaxUnitFun = true)
|
||||
private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler = mockk()
|
||||
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk()
|
||||
private val eTagsStore: ETagsStore = mockk(relaxUnitFun = true)
|
||||
|
||||
|
|
@ -49,16 +53,19 @@ class DefaultWalletAccountsFetcherTest {
|
|||
defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory,
|
||||
eTagsStore = eTagsStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
mainAccountTokensMigration = tokensMigration,
|
||||
)
|
||||
|
||||
private val userWalletId = UserWalletId("011")
|
||||
private val eTag = "etag"
|
||||
private val migratedAccountsResponse = createGetWalletAccountsResponse(userWalletId)
|
||||
|
||||
@BeforeAll
|
||||
fun setUp() {
|
||||
every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore
|
||||
every { accountsResponseStore.data } returns accountsResponseStoreFlow
|
||||
|
||||
coEvery { tokensMigration.migrate(userWalletId) } returns migratedAccountsResponse.right()
|
||||
coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +141,7 @@ class DefaultWalletAccountsFetcherTest {
|
|||
accountsResponseStore.updateData(any())
|
||||
accountsResponseStoreFactory.create(userWalletId = userWalletId)
|
||||
accountsResponseStore.updateData(any())
|
||||
tokensMigration.migrate(userWalletId)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
|
|
@ -181,6 +189,7 @@ class DefaultWalletAccountsFetcherTest {
|
|||
eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = newETag)
|
||||
accountsResponseStoreFactory.create(userWalletId = userWalletId)
|
||||
accountsResponseStore.updateData(any())
|
||||
tokensMigration.migrate(userWalletId)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
|
|
@ -236,6 +245,7 @@ class DefaultWalletAccountsFetcherTest {
|
|||
pushWalletAccounts = any(),
|
||||
storeWalletAccounts = any(),
|
||||
)
|
||||
tokensMigration.migrate(userWalletId)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
|
|
@ -244,6 +254,90 @@ class DefaultWalletAccountsFetcherTest {
|
|||
userTokensSaver.push(userWalletId = any(), response = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN response with empty accounts and push request is failed THEN eTag will be cleared`() = runTest {
|
||||
// Arrange
|
||||
val savedAccountsResponse = GetWalletAccountsResponse(
|
||||
wallet = GetWalletAccountsResponse.Wallet(
|
||||
group = null,
|
||||
sort = null,
|
||||
totalAccounts = 0,
|
||||
totalArchivedAccounts = 0,
|
||||
),
|
||||
accounts = emptyList(),
|
||||
unassignedTokens = emptyList(),
|
||||
)
|
||||
val getResponse = ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.NOT_MODIFIED,
|
||||
message = null,
|
||||
errorBody = null,
|
||||
),
|
||||
headers = mapOf(ETAG_HEADER to listOf(eTag)),
|
||||
)
|
||||
|
||||
accountsResponseStoreFlow.value = savedAccountsResponse
|
||||
|
||||
coEvery {
|
||||
tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag)
|
||||
} returns getResponse as ApiResponse<GetWalletAccountsResponse>
|
||||
|
||||
coEvery {
|
||||
fetchWalletAccountsErrorHandler.handle(
|
||||
error = getResponse.cause,
|
||||
userWalletId = userWalletId,
|
||||
savedAccountsResponse = savedAccountsResponse,
|
||||
pushWalletAccounts = any(),
|
||||
storeWalletAccounts = any(),
|
||||
)
|
||||
} returns FetchResult(savedAccountsResponse)
|
||||
|
||||
coEvery {
|
||||
defaultWalletAccountsResponseFactory.create(userWalletId = userWalletId, userTokensResponse = null)
|
||||
} returns savedAccountsResponse
|
||||
|
||||
coEvery { accountsResponseStore.updateData(any()) } returns savedAccountsResponse
|
||||
|
||||
val saveResponse = ApiResponse.Error(ApiResponseError.TimeoutException())
|
||||
coEvery {
|
||||
tangemTechApi.saveWalletAccounts(
|
||||
walletId = userWalletId.stringValue,
|
||||
eTag = eTag,
|
||||
body = SaveWalletAccountsResponse(savedAccountsResponse.accounts),
|
||||
)
|
||||
} returns saveResponse as ApiResponse<GetWalletAccountsResponse>
|
||||
|
||||
// Act
|
||||
fetcher.fetch(userWalletId)
|
||||
|
||||
// Assert
|
||||
coVerify {
|
||||
accountsResponseStoreFactory.create(userWalletId = userWalletId)
|
||||
accountsResponseStore.data
|
||||
eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts)
|
||||
tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag)
|
||||
eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = eTag)
|
||||
accountsResponseStore.updateData(any())
|
||||
fetchWalletAccountsErrorHandler.handle(
|
||||
error = getResponse.cause,
|
||||
userWalletId = userWalletId,
|
||||
savedAccountsResponse = savedAccountsResponse,
|
||||
pushWalletAccounts = any(),
|
||||
storeWalletAccounts = any(),
|
||||
)
|
||||
defaultWalletAccountsResponseFactory.create(userWalletId = userWalletId, userTokensResponse = null)
|
||||
eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts)
|
||||
tangemTechApi.saveWalletAccounts(
|
||||
walletId = userWalletId.stringValue,
|
||||
eTag = eTag,
|
||||
body = SaveWalletAccountsResponse(savedAccountsResponse.accounts),
|
||||
)
|
||||
eTagsStore.clear(userWalletId, ETagsStore.Key.WalletAccounts)
|
||||
userTokensSaver.push(userWalletId = userWalletId, response = savedAccountsResponse.toUserTokensResponse())
|
||||
tokensMigration.migrate(userWalletId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
package com.tangem.data.account.repository
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.domain.account.models.AccountExpandedState
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.account.DerivationIndex
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class DefaultAccountsExpandedRepositoryTest {
|
||||
|
||||
private val walletId = UserWalletId("011")
|
||||
private val mainAccountId = AccountId.forMainCryptoPortfolio(walletId)
|
||||
private val secondAccountId = AccountId.forCryptoPortfolio(walletId, DerivationIndex(1).getOrNull()!!)
|
||||
|
||||
@Test
|
||||
fun `expandedAccounts emits updated state when store changes`() = runTest {
|
||||
val dataStore = MockStateDataStore<Map<String, Set<AccountsExpandedDTO>>>(
|
||||
default = emptyMap()
|
||||
)
|
||||
|
||||
val repository = DefaultAccountsExpandedRepository(dataStore)
|
||||
|
||||
repository.expandedAccounts.test {
|
||||
// initial emission
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(initial.isEmpty()).isTrue()
|
||||
|
||||
// update store
|
||||
dataStore.updateData {
|
||||
mapOf(
|
||||
walletId.stringValue to setOf(
|
||||
AccountsExpandedDTO(
|
||||
accountId = mainAccountId.value,
|
||||
isExpanded = true
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// next emission
|
||||
val updated = awaitItem()
|
||||
val states = updated[walletId]!!
|
||||
|
||||
Truth.assertThat(states.size).isEqualTo(1)
|
||||
val state = states.first()
|
||||
|
||||
Truth.assertThat(state.accountId).isEqualTo(mainAccountId)
|
||||
Truth.assertThat(state.isExpanded).isTrue()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expandedAccounts emits when update is called`() = runTest {
|
||||
val dataStore = MockStateDataStore<Map<String, Set<AccountsExpandedDTO>>>(
|
||||
default = emptyMap()
|
||||
)
|
||||
|
||||
val repository = DefaultAccountsExpandedRepository(dataStore)
|
||||
|
||||
val state = AccountExpandedState(
|
||||
accountId = mainAccountId,
|
||||
isExpanded = true
|
||||
)
|
||||
|
||||
repository.expandedAccounts.test {
|
||||
// initial
|
||||
awaitItem()
|
||||
|
||||
// when
|
||||
repository.update(state)
|
||||
|
||||
// then
|
||||
val updated = awaitItem()
|
||||
val states = updated[walletId]!!
|
||||
|
||||
Truth.assertThat(states.size).isEqualTo(1)
|
||||
Truth.assertThat(states.first().isExpanded).isTrue()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expandedAccounts emits synced state after syncStore`() = runTest {
|
||||
val dataStore = MockStateDataStore(
|
||||
mapOf(
|
||||
walletId.stringValue to setOf(
|
||||
AccountsExpandedDTO(mainAccountId.value, true),
|
||||
AccountsExpandedDTO(secondAccountId.value, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val repository = DefaultAccountsExpandedRepository(dataStore)
|
||||
|
||||
repository.expandedAccounts.test {
|
||||
// initial
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(initial[walletId]!!.size).isEqualTo(2)
|
||||
|
||||
// when
|
||||
repository.syncStore(
|
||||
walletId = walletId,
|
||||
existAccounts = setOf(mainAccountId) // without secondAccountId
|
||||
)
|
||||
|
||||
// then
|
||||
val synced = awaitItem()
|
||||
val states = synced[walletId]!!
|
||||
|
||||
Truth.assertThat(states.size).isEqualTo(1)
|
||||
Truth.assertThat(states.first().accountId.value).isEqualTo(mainAccountId.value)
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.data.account.token
|
||||
|
||||
import arrow.core.right
|
||||
import com.tangem.data.account.converter.createGetWalletAccountsResponse
|
||||
import com.tangem.data.account.converter.createWalletAccountDTO
|
||||
import com.tangem.data.account.store.AccountsResponseStore
|
||||
|
|
@ -13,6 +14,7 @@ import com.tangem.datasource.local.accounts.AccountTokenMigrationStore
|
|||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.account.DerivationIndex
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.test.core.assertEither
|
||||
import com.tangem.test.core.assertEitherLeft
|
||||
import com.tangem.test.core.assertEitherRight
|
||||
import io.mockk.*
|
||||
|
|
@ -214,6 +216,212 @@ class DefaultMainAccountTokensMigrationTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrate updates tokens for all accounts`() = runTest {
|
||||
// Arrange
|
||||
val unassignedToken1 = createBitcoin(accountIndex = 1)
|
||||
val unassignedToken2 = createBitcoin(accountIndex = 2)
|
||||
|
||||
val derivationIndex1 = DerivationIndex(1).getOrNull()!!
|
||||
val derivationIndex2 = DerivationIndex(2).getOrNull()!!
|
||||
|
||||
val mainAccount = createWalletAccountDTO(
|
||||
userWalletId = userWalletId,
|
||||
accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value,
|
||||
derivationIndex = DerivationIndex.Main.value,
|
||||
tokens = listOf(
|
||||
createBitcoin(accountIndex = 0),
|
||||
unassignedToken1,
|
||||
unassignedToken2,
|
||||
),
|
||||
)
|
||||
|
||||
val account1 = createWalletAccountDTO(
|
||||
userWalletId = userWalletId,
|
||||
accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex1).value,
|
||||
derivationIndex = derivationIndex1.value,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
|
||||
val account2 = createWalletAccountDTO(
|
||||
userWalletId = userWalletId,
|
||||
accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex2).value,
|
||||
derivationIndex = derivationIndex2.value,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
|
||||
val response = GetWalletAccountsResponse(
|
||||
wallet = GetWalletAccountsResponse.Wallet(
|
||||
group = UserTokensResponse.GroupType.NONE,
|
||||
sort = UserTokensResponse.SortType.MANUAL,
|
||||
totalAccounts = 3,
|
||||
totalArchivedAccounts = 0,
|
||||
),
|
||||
accounts = listOf(mainAccount, account1, account2),
|
||||
unassignedTokens = emptyList(),
|
||||
)
|
||||
|
||||
accountsResponseStoreFlow.value = response
|
||||
|
||||
coEvery { accountsResponseStore.updateData(any()) } returns mockk()
|
||||
|
||||
// Act
|
||||
val actual = migration.migrate(userWalletId)
|
||||
|
||||
val migratedResponse = response.copy(
|
||||
accounts = listOf(
|
||||
mainAccount.copy(tokens = mainAccount.tokens!! - unassignedToken1 - unassignedToken2),
|
||||
account1.copy(tokens = listOf(unassignedToken1)),
|
||||
account2.copy(tokens = listOf(unassignedToken2)),
|
||||
),
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertEither(actual, migratedResponse.right())
|
||||
|
||||
coVerifySequence {
|
||||
accountsResponseStoreFactory.create(userWalletId)
|
||||
accountsResponseStore.data
|
||||
accountsResponseStore.updateData(any())
|
||||
userTokensSaver.pushWithRetryer(
|
||||
userWalletId = userWalletId,
|
||||
response = migratedResponse.toUserTokensResponse(),
|
||||
onFailSend = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrate updates tokens only for one account`() = runTest {
|
||||
// Arrange
|
||||
val unassignedToken1 = createBitcoin(accountIndex = 1)
|
||||
|
||||
val derivationIndex1 = DerivationIndex(1).getOrNull()!!
|
||||
val derivationIndex2 = DerivationIndex(2).getOrNull()!!
|
||||
|
||||
val mainAccount = createWalletAccountDTO(
|
||||
userWalletId = userWalletId,
|
||||
accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value,
|
||||
derivationIndex = DerivationIndex.Main.value,
|
||||
tokens = listOf(
|
||||
createBitcoin(accountIndex = 0),
|
||||
unassignedToken1,
|
||||
),
|
||||
)
|
||||
|
||||
val account1 = createWalletAccountDTO(
|
||||
userWalletId = userWalletId,
|
||||
accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex1).value,
|
||||
derivationIndex = derivationIndex1.value,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
|
||||
val account2 = createWalletAccountDTO(
|
||||
userWalletId = userWalletId,
|
||||
accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex2).value,
|
||||
derivationIndex = derivationIndex2.value,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
|
||||
val response = GetWalletAccountsResponse(
|
||||
wallet = GetWalletAccountsResponse.Wallet(
|
||||
group = UserTokensResponse.GroupType.NONE,
|
||||
sort = UserTokensResponse.SortType.MANUAL,
|
||||
totalAccounts = 3,
|
||||
totalArchivedAccounts = 0,
|
||||
),
|
||||
accounts = listOf(mainAccount, account1, account2),
|
||||
unassignedTokens = emptyList(),
|
||||
)
|
||||
|
||||
accountsResponseStoreFlow.value = response
|
||||
|
||||
coEvery { accountsResponseStore.updateData(any()) } returns mockk()
|
||||
|
||||
// Act
|
||||
val actual = migration.migrate(userWalletId)
|
||||
|
||||
val migratedResponse = response.copy(
|
||||
accounts = listOf(
|
||||
mainAccount.copy(tokens = mainAccount.tokens!! - unassignedToken1),
|
||||
account1.copy(tokens = listOf(unassignedToken1)),
|
||||
account2,
|
||||
),
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertEither(actual, migratedResponse.right())
|
||||
|
||||
coVerifySequence {
|
||||
accountsResponseStoreFactory.create(userWalletId)
|
||||
accountsResponseStore.data
|
||||
accountsResponseStore.updateData(any())
|
||||
userTokensSaver.pushWithRetryer(
|
||||
userWalletId = userWalletId,
|
||||
response = migratedResponse.toUserTokensResponse(),
|
||||
onFailSend = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrate all skips when no unassigned tokens`() = runTest {
|
||||
// Arrange
|
||||
val derivationIndex1 = DerivationIndex(1).getOrNull()!!
|
||||
val derivationIndex2 = DerivationIndex(2).getOrNull()!!
|
||||
|
||||
val mainAccount = createWalletAccountDTO(
|
||||
userWalletId = userWalletId,
|
||||
accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value,
|
||||
derivationIndex = DerivationIndex.Main.value,
|
||||
tokens = listOf(
|
||||
createBitcoin(accountIndex = 0),
|
||||
),
|
||||
)
|
||||
|
||||
val account1 = createWalletAccountDTO(
|
||||
userWalletId = userWalletId,
|
||||
accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex1).value,
|
||||
derivationIndex = derivationIndex1.value,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
|
||||
val account2 = createWalletAccountDTO(
|
||||
userWalletId = userWalletId,
|
||||
accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex2).value,
|
||||
derivationIndex = derivationIndex2.value,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
|
||||
val response = GetWalletAccountsResponse(
|
||||
wallet = GetWalletAccountsResponse.Wallet(
|
||||
group = UserTokensResponse.GroupType.NONE,
|
||||
sort = UserTokensResponse.SortType.MANUAL,
|
||||
totalAccounts = 3,
|
||||
totalArchivedAccounts = 0,
|
||||
),
|
||||
accounts = listOf(mainAccount, account1, account2),
|
||||
unassignedTokens = emptyList(),
|
||||
)
|
||||
|
||||
accountsResponseStoreFlow.value = response
|
||||
|
||||
// Act
|
||||
val actual = migration.migrate(userWalletId)
|
||||
|
||||
// Assert
|
||||
assertEither(actual, response.right())
|
||||
|
||||
coVerifySequence {
|
||||
accountsResponseStoreFactory.create(userWalletId)
|
||||
accountsResponseStore.data
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any())
|
||||
}
|
||||
}
|
||||
|
||||
private fun createBitcoin(accountIndex: Int): UserTokensResponse.Token {
|
||||
return UserTokensResponse.Token(
|
||||
id = "ne",
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ class ResponseCryptoCurrenciesFactory @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun createToken(blockchain: Blockchain, sdkToken: Token, network: Network): CryptoCurrency.Token {
|
||||
fun createToken(blockchain: Blockchain, sdkToken: Token, network: Network): CryptoCurrency.Token {
|
||||
val id = getTokenId(network, sdkToken)
|
||||
|
||||
return CryptoCurrency.Token(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ internal class ExpressProviderConverter : Converter<ExchangeProvider, ExpressPro
|
|||
privacyPolicy = value.privacyPolicy,
|
||||
isRecommended = value.isRecommended,
|
||||
slippage = value.slippage,
|
||||
isExchangeOnlyWithinSingleAddress = value.isExchangeOnlyWithinSingleAddress,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
<?xml version="1.0" ?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>BooleanPropertyNaming:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository.<no name provided>$val last = res.tokens.size < request.limit</ID>
|
||||
<ID>MultilineLambdaItParameter:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository${ val error = it as QuotesFetcher.Error.ApiOperationError val errorEvent = createDetailsErrorEvent( error = error.apiError, request = MarketsDataAnalyticsEvent.Details.Error.Request.Info, tokenSymbol = tokenSymbol, ) analyticsEventHandler.send(errorEvent.toEvent()) throw error.apiError }</ID>
|
||||
<ID>MultilineLambdaItParameter:MarketsBatchUpdateFetcher.kt$MarketsBatchUpdateFetcher${ it.copy( tokenCharts = TokenMarketChartsConverter.convert( chartsToCopy = it.tokenCharts, tokenId = it.id, interval = updateRequest.interval, value = update, ), ) }</ID>
|
||||
<ID>MultilineLambdaItParameter:MarketsBatchUpdateFetcher.kt$MarketsBatchUpdateFetcher${ val exception = if (it is QuotesFetcher.Error.ApiOperationError) { onApiResponseError(it.apiError) it.apiError } else { error("Cause: $it") } throw exception }</ID>
|
||||
<ID>MultilineLambdaItParameter:TokenMarketInfoConverter.kt$TokenMarketInfoConverter${ TokenMarketInfo.Link( title = it.title, id = it.id, link = it.link, ) }</ID>
|
||||
<ID>NullableBooleanCheck:TokenMarketListConverter.kt$TokenMarketListConverter$token.isUnderMarketCapLimit ?: false</ID>
|
||||
<ID>SuspendFunWithFlowReturnType:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository$suspend</ID>
|
||||
<ID>UnsafeCallOnNullableType:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository$network.contractAddress!!</ID>
|
||||
<ID>UseEmptyCounterpart:MarketsDataAnalyticsEvent.kt$MarketsDataAnalyticsEvent.Details$mapOf()</ID>
|
||||
<ID>UseEmptyCounterpart:MarketsDataAnalyticsEvent.kt$MarketsDataAnalyticsEvent.List$mapOf()</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
|
|
@ -90,14 +90,15 @@ internal class DefaultMarketsTokenRepository(
|
|||
requestTimeStamp.set(res.timestamp ?: 0)
|
||||
}
|
||||
|
||||
val last = res.tokens.size < request.limit
|
||||
val isLast = res.tokens.size < request.limit
|
||||
|
||||
val tokenMarketListWithMaxApy = TokenMarketListConverter.convert(res)
|
||||
|
||||
return BatchFetchResult.Success(
|
||||
data = tokenMarketListWithMaxApy.tokens,
|
||||
last = last,
|
||||
last = isLast,
|
||||
empty = res.tokens.isEmpty(),
|
||||
total = res.total,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -227,8 +228,8 @@ internal class DefaultMarketsTokenRepository(
|
|||
currencyId = tokenId.value,
|
||||
field = QuotesFetcher.Field.ALL_PRICES,
|
||||
)
|
||||
.getOrElse {
|
||||
val error = it as QuotesFetcher.Error.ApiOperationError
|
||||
.getOrElse { fetchError ->
|
||||
val error = fetchError as QuotesFetcher.Error.ApiOperationError
|
||||
|
||||
val errorEvent = createDetailsErrorEvent(
|
||||
error = error.apiError,
|
||||
|
|
@ -274,7 +275,7 @@ internal class DefaultMarketsTokenRepository(
|
|||
name = token.name,
|
||||
symbol = token.symbol,
|
||||
decimals = network.decimalCount ?: error("Unknown decimal"),
|
||||
contractAddress = network.contractAddress!!,
|
||||
contractAddress = requireNotNull(network.contractAddress) { "Contract address is required for token" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,13 +79,13 @@ internal class MarketsBatchUpdateFetcher(
|
|||
currenciesIds = currenciesIds,
|
||||
fields = setOf(QuotesFetcher.Field.ALL_PRICES),
|
||||
)
|
||||
.getOrElse {
|
||||
val exception = if (it is QuotesFetcher.Error.ApiOperationError) {
|
||||
onApiResponseError(it.apiError)
|
||||
.getOrElse { error ->
|
||||
val exception = if (error is QuotesFetcher.Error.ApiOperationError) {
|
||||
onApiResponseError(error.apiError)
|
||||
|
||||
it.apiError
|
||||
error.apiError
|
||||
} else {
|
||||
error("Cause: $it")
|
||||
error("Cause: $error")
|
||||
}
|
||||
|
||||
throw exception
|
||||
|
|
@ -116,11 +116,11 @@ internal class MarketsBatchUpdateFetcher(
|
|||
|
||||
Batch(
|
||||
key = batchToUpdate.key,
|
||||
data = batchToUpdate.data.map {
|
||||
it.copy(
|
||||
data = batchToUpdate.data.map { tokenMarket ->
|
||||
tokenMarket.copy(
|
||||
tokenCharts = TokenMarketChartsConverter.convert(
|
||||
chartsToCopy = it.tokenCharts,
|
||||
tokenId = it.id,
|
||||
chartsToCopy = tokenMarket.tokenCharts,
|
||||
tokenId = tokenMarket.id,
|
||||
interval = updateRequest.interval,
|
||||
value = update,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ package com.tangem.data.markets.analytics
|
|||
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR
|
||||
|
||||
sealed interface MarketsDataAnalyticsEvent {
|
||||
|
||||
sealed class List(
|
||||
event: String,
|
||||
params: Map<String, String> = mapOf(),
|
||||
params: Map<String, String> = emptyMap(),
|
||||
) : AnalyticsEvent(category = "Markets", event = event, params = params), MarketsDataAnalyticsEvent {
|
||||
|
||||
data class Error(
|
||||
|
|
@ -26,7 +27,7 @@ sealed interface MarketsDataAnalyticsEvent {
|
|||
|
||||
sealed class Details(
|
||||
event: String,
|
||||
params: Map<String, String> = mapOf(),
|
||||
params: Map<String, String> = emptyMap(),
|
||||
) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params), MarketsDataAnalyticsEvent {
|
||||
|
||||
data class Error(
|
||||
|
|
@ -82,8 +83,4 @@ sealed interface MarketsDataAnalyticsEvent {
|
|||
Custom("Custom"),
|
||||
Unknown("Unknown"),
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val IS_NOT_HTTP_ERROR = "Is not http error"
|
||||
}
|
||||
}
|
||||
|
|
@ -62,7 +62,7 @@ internal class TokenMarketInfoConverter(
|
|||
network.contractAddress.isNullOrEmpty() -> {
|
||||
TokenMarketInfo.Network(
|
||||
networkId = network.networkId,
|
||||
exchangeable = network.exchangeable,
|
||||
isExchangeable = network.exchangeable,
|
||||
contractAddress = network.contractAddress,
|
||||
decimalCount = network.decimalCount,
|
||||
)
|
||||
|
|
@ -71,7 +71,7 @@ internal class TokenMarketInfoConverter(
|
|||
blockchain.canHandleTokens() -> {
|
||||
TokenMarketInfo.Network(
|
||||
networkId = network.networkId,
|
||||
exchangeable = network.exchangeable,
|
||||
isExchangeable = network.exchangeable,
|
||||
contractAddress = blockchain.reformatContractAddress(network.contractAddress),
|
||||
decimalCount = network.decimalCount,
|
||||
)
|
||||
|
|
@ -147,11 +147,11 @@ internal class TokenMarketInfoConverter(
|
|||
|
||||
@JvmName("convertLink")
|
||||
private fun List<TokenMarketInfoResponse.Link>.convert(): List<TokenMarketInfo.Link> {
|
||||
return map {
|
||||
return map { link ->
|
||||
TokenMarketInfo.Link(
|
||||
title = it.title,
|
||||
id = it.id,
|
||||
link = it.link,
|
||||
title = link.title,
|
||||
id = link.id,
|
||||
link = link.link,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ internal object TokenMarketListConverter : Converter<TokenMarketListResponse, To
|
|||
symbol = token.symbol,
|
||||
marketRating = token.marketRating,
|
||||
marketCap = token.marketCap,
|
||||
isUnderMarketCapLimit = token.isUnderMarketCapLimit ?: false,
|
||||
isUnderMarketCapLimit = token.isUnderMarketCapLimit == true,
|
||||
imageHost = imageHost,
|
||||
tokenQuotesShort = TokenQuotesShort(
|
||||
currentPrice = token.currentPrice,
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import com.tangem.domain.models.wallet.UserWalletId
|
|||
import com.tangem.domain.networks.utils.NetworksCleaner
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Default implementation of [NetworksCleaner].
|
||||
|
|
@ -27,33 +29,51 @@ internal class DefaultNetworksCleaner(
|
|||
) : NetworksCleaner {
|
||||
|
||||
override suspend fun invoke(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
|
||||
if (currencies.isEmpty()) {
|
||||
Timber.d("No currencies to clear for wallet: $userWalletId")
|
||||
return
|
||||
}
|
||||
|
||||
withContext(dispatchers.default) {
|
||||
val (networks, tokens) = currencies.partitionByType()
|
||||
|
||||
coroutineScope {
|
||||
launch { cleanStore(userWalletId = userWalletId, networks = networks) }
|
||||
launch { cleanWalletManager(userWalletId = userWalletId, networks = networks, tokens = tokens) }
|
||||
}
|
||||
awaitAll(
|
||||
async { clearStatusesStore(userWalletId = userWalletId, networks = networks) },
|
||||
async { clearBlockchainSDK(userWalletId = userWalletId, networks = networks, tokens = tokens) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanStore(userWalletId: UserWalletId, networks: Set<Network>) {
|
||||
private suspend fun clearStatusesStore(userWalletId: UserWalletId, networks: Set<Network>) {
|
||||
if (networks.isNotEmpty()) {
|
||||
networksStatusesStore.clear(userWalletId = userWalletId, networks = networks)
|
||||
runSuspendCatching {
|
||||
networksStatusesStore.clear(userWalletId = userWalletId, networks = networks)
|
||||
}
|
||||
.onFailure { Timber.e(it, "Failed to clear network statuses for wallet: $userWalletId") }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanWalletManager(
|
||||
private suspend fun clearBlockchainSDK(
|
||||
userWalletId: UserWalletId,
|
||||
networks: Set<Network>,
|
||||
tokens: Set<CryptoCurrency.Token>,
|
||||
) {
|
||||
if (networks.isNotEmpty()) {
|
||||
walletManagersFacade.remove(userWalletId = userWalletId, networks = networks)
|
||||
runSuspendCatching {
|
||||
walletManagersFacade.remove(userWalletId = userWalletId, networks = networks)
|
||||
}
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to remove networks from Blockchain SDK for wallet: $userWalletId")
|
||||
}
|
||||
}
|
||||
|
||||
if (tokens.isNotEmpty()) {
|
||||
walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = tokens)
|
||||
runSuspendCatching {
|
||||
walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = tokens)
|
||||
}
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to remove tokens from Blockchain SDK for wallet: $userWalletId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.tangem.domain.models.wallet.UserWalletId
|
|||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -45,7 +46,7 @@ class DefaultNetworksCleanerTest {
|
|||
|
||||
// Assert
|
||||
coVerifyOrder {
|
||||
networksStatusesStore.clear(userWalletId, setOf(network))
|
||||
networksStatusesStore.clear(userWalletId = userWalletId, networks = setOf(network))
|
||||
walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network))
|
||||
walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token))
|
||||
}
|
||||
|
|
@ -66,12 +67,15 @@ class DefaultNetworksCleanerTest {
|
|||
|
||||
@Test
|
||||
fun `should clear only networks when there are no tokens`() = runTest {
|
||||
// Arrange
|
||||
val currencies = listOf(coin)
|
||||
|
||||
// Act
|
||||
cleaner(userWalletId = userWalletId, currencies = currencies)
|
||||
|
||||
// Assert
|
||||
coVerifyOrder {
|
||||
networksStatusesStore.clear(userWalletId, setOf(network))
|
||||
networksStatusesStore.clear(userWalletId = userWalletId, networks = setOf(network))
|
||||
walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network))
|
||||
}
|
||||
|
||||
|
|
@ -98,4 +102,27 @@ class DefaultNetworksCleanerTest {
|
|||
walletManagersFacade.remove(userWalletId = any(), networks = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle exception during cleaning`() = runTest {
|
||||
// Arrange
|
||||
val currencies = listOf(coin, token)
|
||||
|
||||
val exception = Exception("Test exception")
|
||||
coEvery { networksStatusesStore.clear(userWalletId, setOf(network)) } throws exception
|
||||
coEvery { walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network)) } throws exception
|
||||
coEvery {
|
||||
walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token))
|
||||
} throws exception
|
||||
|
||||
// Act
|
||||
cleaner(userWalletId = userWalletId, currencies = currencies)
|
||||
|
||||
// Assert
|
||||
coVerifyOrder {
|
||||
networksStatusesStore.clear(userWalletId = userWalletId, networks = setOf(network))
|
||||
walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network))
|
||||
walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.data.news
|
||||
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.domain.models.news.NewsError
|
||||
import com.tangem.domain.news.NewsErrorResolver
|
||||
|
||||
internal class DefaultNewsErrorResolver : NewsErrorResolver {
|
||||
|
||||
override fun resolve(throwable: Throwable?): NewsError {
|
||||
return when (throwable) {
|
||||
is ApiResponseError.HttpException -> {
|
||||
NewsError.HttpError(
|
||||
code = throwable.code.numericCode,
|
||||
message = throwable.message.orEmpty(),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
NewsError.NotHttpError
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
package com.tangem.data.news.di
|
||||
|
||||
import com.tangem.data.news.DefaultNewsErrorResolver
|
||||
import com.tangem.data.news.repository.DefaultNewsRepository
|
||||
import com.tangem.datasource.api.news.NewsApi
|
||||
import com.tangem.datasource.local.news.details.NewsDetailsStore
|
||||
import com.tangem.datasource.local.news.liked.NewsLikedStore
|
||||
import com.tangem.datasource.local.news.trending.TrendingNewsStore
|
||||
import com.tangem.datasource.local.news.viewed.NewsViewedStore
|
||||
import com.tangem.domain.news.NewsErrorResolver
|
||||
import com.tangem.domain.news.repository.NewsRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
|
|
@ -23,12 +27,21 @@ internal object NewsDataModule {
|
|||
dispatchers: CoroutineDispatcherProvider,
|
||||
newsDetailsStore: NewsDetailsStore,
|
||||
trendingNewsStore: TrendingNewsStore,
|
||||
newsViewedStore: NewsViewedStore,
|
||||
newsLikedStore: NewsLikedStore,
|
||||
newsErrorResolver: NewsErrorResolver,
|
||||
): NewsRepository {
|
||||
return DefaultNewsRepository(
|
||||
newsApi = newsApi,
|
||||
dispatchers = dispatchers,
|
||||
newsDetailsStore = newsDetailsStore,
|
||||
trendingNewsStore = trendingNewsStore,
|
||||
newsViewedStore = newsViewedStore,
|
||||
newsLikedStore = newsLikedStore,
|
||||
newsErrorResolver = newsErrorResolver,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideNewsErrorResolver(): NewsErrorResolver = DefaultNewsErrorResolver()
|
||||
}
|
||||
|
|
@ -1,115 +1,178 @@
|
|||
package com.tangem.data.news.repository
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.flatten
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.common.response.fold
|
||||
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.liked.NewsLikedStore
|
||||
import com.tangem.datasource.local.news.trending.TrendingNewsStore
|
||||
import com.tangem.domain.models.news.*
|
||||
import com.tangem.datasource.local.news.viewed.NewsViewedStore
|
||||
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.models.news.TrendingNews
|
||||
import com.tangem.domain.news.NewsErrorResolver
|
||||
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.news.repository.NewsRepository
|
||||
import com.tangem.pagination.BatchFetchResult
|
||||
import com.tangem.pagination.BatchListSource
|
||||
import com.tangem.pagination.*
|
||||
import com.tangem.pagination.exception.EndOfPaginationException
|
||||
import com.tangem.pagination.fetcher.BatchFetcher
|
||||
import com.tangem.pagination.toBatchFlow
|
||||
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.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Implementation of [NewsRepository].
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultNewsRepository(
|
||||
private val newsApi: NewsApi,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val newsDetailsStore: NewsDetailsStore,
|
||||
private val trendingNewsStore: TrendingNewsStore,
|
||||
private val newsViewedStore: NewsViewedStore,
|
||||
private val newsLikedStore: NewsLikedStore,
|
||||
private val newsErrorResolver: NewsErrorResolver,
|
||||
) : NewsRepository {
|
||||
|
||||
override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow {
|
||||
return BatchListSource(
|
||||
val newsBatchFlow = BatchListSource(
|
||||
fetchDispatcher = dispatchers.io,
|
||||
context = context,
|
||||
generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: INITIAL_BATCH_KEY },
|
||||
batchFetcher = createBatchFetcher(batchSize),
|
||||
).toBatchFlow()
|
||||
|
||||
return updateViewedStatusForNewsBatch(newsBatchFlow, context.coroutineScope)
|
||||
}
|
||||
|
||||
override suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle {
|
||||
val cached = newsDetailsStore.getSyncOrNull(newsId)
|
||||
if (cached != null) return cached
|
||||
override fun getNews(config: NewsListConfig, limit: Int): Flow<Either<Throwable, List<ShortArticle>>> {
|
||||
return flow {
|
||||
val items = newsApi.getNews(
|
||||
page = FIRST_PAGE,
|
||||
limit = limit,
|
||||
language = config.language,
|
||||
snapshot = config.snapshot,
|
||||
tokenIds = config.tokenIds.takeIf { it.isNotEmpty() },
|
||||
categoryIds = config.categoryIds.takeIf { it.isNotEmpty() },
|
||||
).fold(
|
||||
onSuccess = { response ->
|
||||
response.items
|
||||
},
|
||||
onError = { error ->
|
||||
Timber.e(error, "Failed to get list of news")
|
||||
throw error
|
||||
},
|
||||
)
|
||||
|
||||
fetchDetailedArticlesInternal(newsIds = listOf(newsId), language = language)
|
||||
|
||||
return requireNotNull(newsDetailsStore.getSyncOrNull(newsId)) {
|
||||
"Unable to load detailed article with id=$newsId"
|
||||
val shortArticles = items.map { it.toDomainShortArticle() }
|
||||
emit(shortArticles)
|
||||
}
|
||||
.flowOn(dispatchers.io)
|
||||
.combine(newsViewedStore.getAll()) { articlesToUpdate, viewedFlags ->
|
||||
articlesToUpdate.map { article ->
|
||||
val isViewed = viewedFlags[article.id] == true
|
||||
article.copy(viewed = isViewed)
|
||||
}.sortedBy { it.viewed }.right()
|
||||
}
|
||||
.catch { it.left() }
|
||||
}
|
||||
|
||||
override fun observeDetailedArticles(): Flow<Map<Int, DetailedArticle>> {
|
||||
return newsDetailsStore.getAll().map { articles ->
|
||||
articles.associateBy(DetailedArticle::id)
|
||||
}
|
||||
return newsDetailsStore.getAll()
|
||||
.combine(newsLikedStore.getAll()) { articles, likedFlags ->
|
||||
articles.map { article ->
|
||||
article.copy(
|
||||
isLiked = likedFlags[article.id] == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
.map { articles ->
|
||||
articles.associateBy(DetailedArticle::id)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchDetailedArticles(newsIds: Collection<Int>, language: String?) {
|
||||
fetchDetailedArticlesInternal(newsIds = newsIds, language = language)
|
||||
}
|
||||
override suspend fun fetchDetailedArticles(
|
||||
newsIds: Collection<Int>,
|
||||
language: String?,
|
||||
): Either<Map<Int, Throwable>, Unit> = fetchDetailedArticlesInternal(newsIds = newsIds, language = language)
|
||||
|
||||
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 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 = currentArticles.map { article ->
|
||||
if (article.id in ids) {
|
||||
article.copy(viewed = viewed)
|
||||
} else {
|
||||
article
|
||||
return combine(
|
||||
trendingNewsStore.get(TRENDING_NEWS_KEY),
|
||||
newsViewedStore.getAll(),
|
||||
) { trendingNews, viewedFlags ->
|
||||
when (trendingNews) {
|
||||
is TrendingNews.Data -> {
|
||||
val articlesWithViewedFlags = trendingNews.articles
|
||||
.map { article ->
|
||||
article.copy(viewed = viewedFlags[article.id] == true)
|
||||
}
|
||||
.sortedBy { it.viewed }
|
||||
TrendingNews.Data(articlesWithViewedFlags)
|
||||
}
|
||||
is TrendingNews.Error -> trendingNews
|
||||
}
|
||||
}
|
||||
|
||||
trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(updated))
|
||||
}
|
||||
|
||||
override suspend fun getCategories(): List<ArticleCategory> {
|
||||
return newsApi.getCategories().getOrThrow().items.map { dto ->
|
||||
ArticleCategory(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
)
|
||||
return withContext(dispatchers.io) {
|
||||
newsApi.getCategories().getOrThrow().items.map { dto ->
|
||||
ArticleCategory(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchDetailedArticlesInternal(newsIds: Collection<Int>, language: String?) =
|
||||
override suspend fun updateNewsViewed(articleIds: Collection<Int>, viewed: Boolean) {
|
||||
newsViewedStore.updateViewed(articleIds, viewed)
|
||||
}
|
||||
|
||||
private suspend fun isNewsLiked(articleId: Int): Boolean {
|
||||
return newsLikedStore.getSync()[articleId] == true
|
||||
}
|
||||
|
||||
override suspend fun toggleNewsLiked(articleId: Int) = withContext(dispatchers.io) {
|
||||
val isNewsLikedValue = !isNewsLiked(articleId)
|
||||
newsLikedStore.updateLiked(listOf(articleId), isNewsLikedValue)
|
||||
}
|
||||
|
||||
private fun updateViewedStatusForNewsBatch(
|
||||
newsBatchFlow: NewsListBatchFlow,
|
||||
scope: CoroutineScope,
|
||||
): NewsListBatchFlow {
|
||||
return NewsBatchFlowWithViewedStatus(
|
||||
upstream = newsBatchFlow,
|
||||
newsViewedStore = newsViewedStore,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchDetailedArticlesInternal(
|
||||
newsIds: Collection<Int>,
|
||||
language: String?,
|
||||
): Either<Map<Int, Throwable>, Unit> = Either.catch {
|
||||
withContext(dispatchers.io) {
|
||||
if (newsIds.isEmpty()) return@withContext
|
||||
if (newsIds.isEmpty()) return@withContext Unit.right()
|
||||
|
||||
val uniqueIds = newsIds.distinct()
|
||||
val idsToFetch = buildList {
|
||||
|
|
@ -119,34 +182,51 @@ internal class DefaultNewsRepository(
|
|||
}
|
||||
}
|
||||
|
||||
if (idsToFetch.isEmpty()) return@withContext
|
||||
if (idsToFetch.isEmpty()) return@withContext Unit.right()
|
||||
|
||||
val fetchedArticles = coroutineScope {
|
||||
val fetchedArticles = supervisorScope {
|
||||
idsToFetch.map { newsId ->
|
||||
async {
|
||||
newsApi.getNewsDetails(newsId = newsId, language = language)
|
||||
.getOrThrow()
|
||||
.toDomainDetailedArticle()
|
||||
Either.catch {
|
||||
newsApi.getNewsDetails(newsId = newsId, language = language)
|
||||
.getOrThrow()
|
||||
.toDomainDetailedArticle(
|
||||
isLiked = isNewsLiked(newsId),
|
||||
)
|
||||
}.mapLeft {
|
||||
newsId to it
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
if (fetchedArticles.isNotEmpty()) {
|
||||
newsDetailsStore.store(
|
||||
articles = fetchedArticles.associateBy(DetailedArticle::id),
|
||||
)
|
||||
}
|
||||
fetchedArticles
|
||||
.filterIsInstance<Either.Right<DetailedArticle>>()
|
||||
.map { it.value }
|
||||
.let { articles ->
|
||||
if (articles.isNotEmpty()) {
|
||||
newsDetailsStore.store(
|
||||
articles = articles.associateBy(DetailedArticle::id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val errors = fetchedArticles
|
||||
.filterIsInstance<Either.Left<Pair<Int, Throwable>>>()
|
||||
.associate { it.value.first to it.value.second }
|
||||
|
||||
if (errors.isEmpty()) Unit.right() else errors.left()
|
||||
}
|
||||
}.mapLeft { t -> mapOf(GLOBAL_ERROR_ID to t) }.flatten()
|
||||
|
||||
private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?) {
|
||||
return withContext(dispatchers.io) {
|
||||
val apiResponse = newsApi.getTrendingNews(limit = limit, language = language)
|
||||
when (val result = apiResponse) {
|
||||
when (val apiResponse = newsApi.getTrendingNews(limit = limit, language = language)) {
|
||||
is ApiResponse.Error -> {
|
||||
Timber.e(
|
||||
result.cause.cause,
|
||||
apiResponse.cause.cause,
|
||||
"Trending news fetch failed cause: ${
|
||||
when (val error = result.cause) {
|
||||
when (val error = apiResponse.cause) {
|
||||
is ApiResponseError.HttpException -> error.code
|
||||
is ApiResponseError.NetworkException -> "NetworkException"
|
||||
is ApiResponseError.TimeoutException -> "TimeoutException"
|
||||
|
|
@ -157,51 +237,31 @@ internal class DefaultNewsRepository(
|
|||
trendingNewsStore.clear()
|
||||
trendingNewsStore.store(
|
||||
key = TRENDING_NEWS_KEY,
|
||||
value = TrendingNews.Error(
|
||||
NewsError.Unknown(
|
||||
message = result.cause.message,
|
||||
code = null,
|
||||
),
|
||||
),
|
||||
value = TrendingNews.Error(error = newsErrorResolver.resolve(apiResponse.cause.cause)),
|
||||
)
|
||||
}
|
||||
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)
|
||||
val freshArticles = apiResponse.data.items.map { it.toDomainShortArticle() }
|
||||
val articles = freshArticles.take(limit)
|
||||
trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(articles))
|
||||
TrendingNews.Data(articles)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mergeTrendingArticles(current: List<ShortArticle>, fresh: List<ShortArticle>): List<ShortArticle> {
|
||||
if (current.isEmpty()) return fresh
|
||||
|
||||
val currentById = current.associateBy(ShortArticle::id)
|
||||
|
||||
return fresh.map { article ->
|
||||
val stored = currentById[article.id] ?: return@map article
|
||||
article.copy(viewed = stored.viewed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createBatchFetcher(batchSize: Int): BatchFetcher<NewsListConfig, List<ShortArticle>> {
|
||||
return NewsBatchFetcher(
|
||||
newsApi = newsApi,
|
||||
batchSize = batchSize,
|
||||
newsViewedStore = newsViewedStore,
|
||||
)
|
||||
}
|
||||
|
||||
private class NewsBatchFetcher(
|
||||
private val newsApi: NewsApi,
|
||||
private val batchSize: Int,
|
||||
private val newsViewedStore: NewsViewedStore,
|
||||
) : BatchFetcher<NewsListConfig, List<ShortArticle>> {
|
||||
|
||||
private var state: NewsPaginationState? = null
|
||||
|
|
@ -266,12 +326,18 @@ internal class DefaultNewsRepository(
|
|||
page = page,
|
||||
limit = limit,
|
||||
language = params.language,
|
||||
snapshot = snapshotOverride,
|
||||
snapshot = snapshotOverride?.takeIf { it.isNotEmpty() },
|
||||
tokenIds = params.tokenIds.takeIf { it.isNotEmpty() },
|
||||
categoryIds = params.categoryIds.takeIf { it.isNotEmpty() },
|
||||
).getOrThrow()
|
||||
|
||||
val items = response.items.map { it.toDomainShortArticle() }
|
||||
val articles = response.items.map { it.toDomainShortArticle() }
|
||||
val viewedFlags = newsViewedStore.getSync()
|
||||
|
||||
val items = articles.map { article ->
|
||||
val isViewed = viewedFlags[article.id] == true
|
||||
article.copy(viewed = isViewed)
|
||||
}
|
||||
|
||||
val batchResult = BatchFetchResult.Success(
|
||||
data = items,
|
||||
|
|
@ -301,9 +367,41 @@ internal class DefaultNewsRepository(
|
|||
val params: NewsListConfig,
|
||||
)
|
||||
|
||||
private class NewsBatchFlowWithViewedStatus(
|
||||
private val upstream: NewsListBatchFlow,
|
||||
newsViewedStore: NewsViewedStore,
|
||||
scope: CoroutineScope,
|
||||
) : NewsListBatchFlow {
|
||||
|
||||
override val state: StateFlow<BatchListState<Int, List<ShortArticle>>> = combine(
|
||||
upstream.state,
|
||||
newsViewedStore.getAll(),
|
||||
) { batchListState, viewedFlags ->
|
||||
val updatedBatches = batchListState.data.map { batch ->
|
||||
val updatedArticles = batch.data.map { article ->
|
||||
val isViewed = viewedFlags[article.id] == true
|
||||
article.copy(viewed = isViewed)
|
||||
}
|
||||
Batch(key = batch.key, data = updatedArticles)
|
||||
}
|
||||
BatchListState(
|
||||
data = updatedBatches,
|
||||
status = batchListState.status,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = scope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = BatchListState(emptyList(), upstream.state.value.status),
|
||||
)
|
||||
|
||||
override val updateResults: SharedFlow<Pair<Nothing, BatchUpdateResult<Int, List<ShortArticle>>>>
|
||||
get() = upstream.updateResults
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val INITIAL_BATCH_KEY = 0
|
||||
private const val FIRST_PAGE = 1
|
||||
private const val TRENDING_NEWS_KEY = "trending_news"
|
||||
private const val GLOBAL_ERROR_ID = -1
|
||||
}
|
||||
}
|
||||
|
|
@ -4,13 +4,9 @@ import com.tangem.datasource.api.news.models.response.NewsArticleDto
|
|||
import com.tangem.datasource.api.news.models.response.NewsDetailsResponse
|
||||
import com.tangem.datasource.api.news.models.response.NewsOriginalArticleDto
|
||||
import com.tangem.datasource.api.news.models.response.NewsRelatedTokenDto
|
||||
import com.tangem.domain.models.news.ArticleCategory
|
||||
import com.tangem.domain.models.news.DetailedArticle
|
||||
import com.tangem.domain.models.news.OriginalArticle
|
||||
import com.tangem.domain.models.news.RelatedToken
|
||||
import com.tangem.domain.models.news.ShortArticle
|
||||
import com.tangem.domain.models.news.*
|
||||
|
||||
internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle {
|
||||
internal fun NewsDetailsResponse.toDomainDetailedArticle(isLiked: Boolean): DetailedArticle {
|
||||
return DetailedArticle(
|
||||
id = id,
|
||||
createdAt = createdAt,
|
||||
|
|
@ -24,6 +20,7 @@ internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle {
|
|||
shortContent = shortContent,
|
||||
content = content,
|
||||
originalArticles = originalArticles.map { it.toDomainOriginalArticle() },
|
||||
isLiked = isLiked,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +51,10 @@ internal fun NewsOriginalArticleDto.toDomainOriginalArticle(): OriginalArticle {
|
|||
return OriginalArticle(
|
||||
id = id,
|
||||
title = title,
|
||||
sourceName = sourceName,
|
||||
source = Source(
|
||||
id = source.id,
|
||||
name = source.name,
|
||||
),
|
||||
locale = language,
|
||||
publishedAt = publishedAt,
|
||||
url = url,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import kotlinx.coroutines.coroutineScope
|
|||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
|
@ -63,6 +65,7 @@ internal class DefaultNFTRepository @Inject constructor(
|
|||
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
|
||||
|
||||
private val nftRuntimeStores = ConcurrentHashMap<String, NFTRuntimeStore>()
|
||||
private val nftRuntimeStoresMutex = Mutex()
|
||||
private val nftPersistenceStores = ConcurrentHashMap<String, NFTPersistenceStore>()
|
||||
|
||||
private val collectionIdConverter = NFTSdkCollectionIdentifierConverter
|
||||
|
|
@ -426,16 +429,17 @@ internal class DefaultNFTRepository @Inject constructor(
|
|||
|
||||
private suspend fun getNFTRuntimeStore(userWalletId: UserWalletId, network: Network): NFTRuntimeStore {
|
||||
val storeId = (userWalletId to network).formatted()
|
||||
return nftRuntimeStores.getOrPut(storeId) {
|
||||
nftRuntimeStoreFactory.provide(network).also {
|
||||
nftRuntimeStores[storeId] = it
|
||||
val storedCollections = getStoredCollections(userWalletId, network)
|
||||
val storedPrices = getStoredPrices(userWalletId, network)
|
||||
it.initialize(
|
||||
collections = storedCollections,
|
||||
prices = storedPrices,
|
||||
)
|
||||
}
|
||||
return nftRuntimeStoresMutex.withLock {
|
||||
nftRuntimeStores[storeId]?.let { return it }
|
||||
val store = nftRuntimeStoreFactory.provide(network)
|
||||
val storedCollections = getStoredCollections(userWalletId, network)
|
||||
val storedPrices = getStoredPrices(userWalletId, network)
|
||||
store.initialize(
|
||||
collections = storedCollections,
|
||||
prices = storedPrices,
|
||||
)
|
||||
nftRuntimeStores[storeId] = store
|
||||
store
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,21 +2,30 @@ package com.tangem.data.staking
|
|||
|
||||
import arrow.core.Either
|
||||
import arrow.core.getOrElse
|
||||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.either
|
||||
import arrow.core.raise.ensure
|
||||
import com.tangem.data.staking.converters.ethpool.*
|
||||
import com.tangem.data.staking.converters.ethpool.P2PEthPoolBroadcastResultConverter
|
||||
import com.tangem.data.staking.converters.ethpool.P2PEthPoolErrorConverter
|
||||
import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingAccountConverter
|
||||
import com.tangem.data.staking.converters.ethpool.P2PEthPoolUnsignedTxConverter
|
||||
import com.tangem.data.staking.converters.ethpool.P2PEthPoolVaultConverter
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
|
||||
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest
|
||||
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolDepositRequest
|
||||
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolUnstakeRequest
|
||||
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolWithdrawRequest
|
||||
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionRequest
|
||||
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse
|
||||
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolTransactionResponse
|
||||
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
|
||||
import com.tangem.domain.models.staking.P2PEthPoolStakingAccount
|
||||
import com.tangem.domain.staking.model.StakingAvailability
|
||||
import com.tangem.domain.staking.model.StakingOption
|
||||
import com.tangem.domain.staking.model.ethpool.*
|
||||
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
|
||||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult
|
||||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork
|
||||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx
|
||||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
|
||||
import com.tangem.domain.staking.model.stakekit.StakingError
|
||||
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
|
||||
import com.tangem.domain.staking.toggles.StakingFeatureToggles
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -26,25 +35,43 @@ import kotlinx.coroutines.withContext
|
|||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* P2P staking repository implementation
|
||||
* P2PEthPool staking repository implementation
|
||||
*/
|
||||
internal class DefaultP2PEthPoolRepository(
|
||||
private val p2pApi: P2PEthPoolApi,
|
||||
private val p2pEthPoolApi: P2PEthPoolApi,
|
||||
private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stakingFeatureToggles: StakingFeatureToggles,
|
||||
) : P2PEthPoolRepository {
|
||||
|
||||
private val vaultConverter = P2PEthPoolVaultConverter
|
||||
private val accountInfoConverter = P2PEthPoolAccountConverter
|
||||
private val rewardConverter = P2PEthPoolRewardConverter
|
||||
private val accountConverter = P2PEthPoolStakingAccountConverter
|
||||
private val broadcastResultConverter = P2PEthPoolBroadcastResultConverter
|
||||
private val errorConverter = P2PEthPoolErrorConverter
|
||||
|
||||
/**
|
||||
* Handles P2PEthPool API response with error checking and result extraction.
|
||||
* Reduces duplication across all API call methods.
|
||||
*/
|
||||
private inline fun <T, R> Raise<StakingError>.handleApiResponse(
|
||||
response: ApiResponse<P2PEthPoolResponse<T>>,
|
||||
transform: (T) -> R,
|
||||
): R = when (response) {
|
||||
is ApiResponse.Success -> {
|
||||
val data = response.data
|
||||
ensure(data.error == null) {
|
||||
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
|
||||
}
|
||||
val result = requireNotNull(data.result) { "Result is null in successful response" }
|
||||
transform(result)
|
||||
}
|
||||
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
|
||||
}
|
||||
|
||||
override suspend fun fetchVaults(network: P2PEthPoolNetwork) {
|
||||
val vaults = if (stakingFeatureToggles.isEthStakingEnabled) {
|
||||
getVaults(network).getOrElse { error ->
|
||||
Timber.e("Error fetching P2P vaults: $error")
|
||||
Timber.e("Error fetching P2PEthPool vaults: $error")
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
|
|
@ -56,17 +83,8 @@ internal class DefaultP2PEthPoolRepository(
|
|||
|
||||
override suspend fun getVaults(network: P2PEthPoolNetwork): Either<StakingError, List<P2PEthPoolVault>> = either {
|
||||
withContext(dispatchers.io) {
|
||||
val response = p2pApi.getVaults(network.value)
|
||||
when (response) {
|
||||
is ApiResponse.Success -> {
|
||||
val data = response.data
|
||||
ensure(data.error == null) {
|
||||
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
|
||||
}
|
||||
val result = requireNotNull(data.result) { "Result is null in successful response" }
|
||||
result.vaults.map { vaultConverter.convert(it) }
|
||||
}
|
||||
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
|
||||
handleApiResponse(p2pEthPoolApi.getVaults(network.value)) { result ->
|
||||
result.vaults.map { vaultConverter.convert(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -76,81 +94,60 @@ internal class DefaultP2PEthPoolRepository(
|
|||
delegatorAddress: String,
|
||||
vaultAddress: String,
|
||||
amount: String,
|
||||
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
|
||||
withContext(dispatchers.io) {
|
||||
val requestBody = P2PEthPoolDepositRequest(
|
||||
delegatorAddress = delegatorAddress,
|
||||
vaultAddress = vaultAddress,
|
||||
amount = amount.toDoubleOrNull() ?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")),
|
||||
)
|
||||
val response = p2pApi.createDepositTransaction(network.value, requestBody)
|
||||
when (response) {
|
||||
is ApiResponse.Success -> {
|
||||
val data = response.data
|
||||
ensure(data.error == null) {
|
||||
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
|
||||
}
|
||||
val result = requireNotNull(data.result) { "Result is null in successful response" }
|
||||
P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction)
|
||||
}
|
||||
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
|
||||
}
|
||||
}
|
||||
}
|
||||
): Either<StakingError, P2PEthPoolUnsignedTx> = createStakingTransaction(
|
||||
network = network,
|
||||
delegatorAddress = delegatorAddress,
|
||||
vaultAddress = vaultAddress,
|
||||
amount = amount,
|
||||
apiCall = p2pEthPoolApi::createDepositTransaction,
|
||||
)
|
||||
|
||||
override suspend fun createUnstakeTransaction(
|
||||
network: P2PEthPoolNetwork,
|
||||
stakerPublicKey: String,
|
||||
stakeTransactionHash: String,
|
||||
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
|
||||
withContext(dispatchers.io) {
|
||||
val requestBody = P2PEthPoolUnstakeRequest(
|
||||
stakerPublicKey = stakerPublicKey,
|
||||
stakeTransactionHash = stakeTransactionHash,
|
||||
)
|
||||
val response = p2pApi.createUnstakeTransaction(network.value, requestBody)
|
||||
when (response) {
|
||||
is ApiResponse.Success -> {
|
||||
val data = response.data
|
||||
ensure(data.error == null) {
|
||||
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
|
||||
}
|
||||
val result = requireNotNull(data.result) { "Result is null in successful response" }
|
||||
// Note: API returns only hex string for unstake, not full transaction structure
|
||||
P2PEthPoolUnsignedTx(
|
||||
serializeTx = result.unstakeTransactionHex,
|
||||
to = "", // Will be parsed from hex by wallet
|
||||
data = result.unstakeTransactionHex,
|
||||
value = java.math.BigDecimal.ZERO,
|
||||
nonce = 0,
|
||||
chainId = network.chainId,
|
||||
gasLimit = java.math.BigDecimal.ZERO,
|
||||
maxFeePerGas = java.math.BigDecimal.ZERO,
|
||||
maxPriorityFeePerGas = java.math.BigDecimal.ZERO,
|
||||
)
|
||||
}
|
||||
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
|
||||
}
|
||||
}
|
||||
}
|
||||
delegatorAddress: String,
|
||||
vaultAddress: String,
|
||||
amount: String,
|
||||
): Either<StakingError, P2PEthPoolUnsignedTx> = createStakingTransaction(
|
||||
network = network,
|
||||
delegatorAddress = delegatorAddress,
|
||||
vaultAddress = vaultAddress,
|
||||
amount = amount,
|
||||
apiCall = p2pEthPoolApi::createUnstakeTransaction,
|
||||
)
|
||||
|
||||
override suspend fun createWithdrawTransaction(
|
||||
network: P2PEthPoolNetwork,
|
||||
stakerAddress: String,
|
||||
delegatorAddress: String,
|
||||
vaultAddress: String,
|
||||
amount: String,
|
||||
): Either<StakingError, P2PEthPoolUnsignedTx> = createStakingTransaction(
|
||||
network = network,
|
||||
delegatorAddress = delegatorAddress,
|
||||
vaultAddress = vaultAddress,
|
||||
amount = amount,
|
||||
apiCall = p2pEthPoolApi::createWithdrawTransaction,
|
||||
)
|
||||
|
||||
private suspend fun createStakingTransaction(
|
||||
network: P2PEthPoolNetwork,
|
||||
delegatorAddress: String,
|
||||
vaultAddress: String,
|
||||
amount: String,
|
||||
apiCall:
|
||||
suspend (
|
||||
String,
|
||||
P2PEthPoolTransactionRequest,
|
||||
) -> ApiResponse<P2PEthPoolResponse<P2PEthPoolTransactionResponse>>,
|
||||
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
|
||||
withContext(dispatchers.io) {
|
||||
val requestBody = P2PEthPoolWithdrawRequest(stakerAddress = stakerAddress)
|
||||
val response = p2pApi.createWithdrawTransaction(network.value, requestBody)
|
||||
when (response) {
|
||||
is ApiResponse.Success -> {
|
||||
val data = response.data
|
||||
ensure(data.error == null) {
|
||||
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
|
||||
}
|
||||
val result = requireNotNull(data.result) { "Result is null in successful response" }
|
||||
P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction)
|
||||
}
|
||||
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
|
||||
val requestBody = P2PEthPoolTransactionRequest(
|
||||
delegatorAddress = delegatorAddress,
|
||||
vaultAddress = vaultAddress,
|
||||
amount = amount.toBigDecimalOrNull()
|
||||
?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")),
|
||||
)
|
||||
handleApiResponse(apiCall(network.value, requestBody)) { result ->
|
||||
P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -161,17 +158,8 @@ internal class DefaultP2PEthPoolRepository(
|
|||
): Either<StakingError, P2PEthPoolBroadcastResult> = either {
|
||||
withContext(dispatchers.io) {
|
||||
val requestBody = P2PEthPoolBroadcastRequest(signedTransaction = signedTransaction)
|
||||
val response = p2pApi.broadcastTransaction(network.value, requestBody)
|
||||
when (response) {
|
||||
is ApiResponse.Success -> {
|
||||
val data = response.data
|
||||
ensure(data.error == null) {
|
||||
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
|
||||
}
|
||||
val result = requireNotNull(data.result) { "Result is null in successful response" }
|
||||
broadcastResultConverter.convert(result)
|
||||
}
|
||||
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
|
||||
handleApiResponse(p2pEthPoolApi.broadcastTransaction(network.value, requestBody)) { result ->
|
||||
broadcastResultConverter.convert(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -180,48 +168,18 @@ internal class DefaultP2PEthPoolRepository(
|
|||
network: P2PEthPoolNetwork,
|
||||
delegatorAddress: String,
|
||||
vaultAddress: String,
|
||||
): Either<StakingError, P2PEthPoolAccount> = either {
|
||||
): Either<StakingError, P2PEthPoolStakingAccount> = either {
|
||||
withContext(dispatchers.io) {
|
||||
val response = p2pApi.getAccountInfo(network.value, delegatorAddress, vaultAddress)
|
||||
when (response) {
|
||||
is ApiResponse.Success -> {
|
||||
val data = response.data
|
||||
ensure(data.error == null) {
|
||||
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
|
||||
}
|
||||
val result = requireNotNull(data.result) { "Result is null in successful response" }
|
||||
accountInfoConverter.convert(result)
|
||||
}
|
||||
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
|
||||
handleApiResponse(
|
||||
p2pEthPoolApi.getAccountInfo(network.value, delegatorAddress, vaultAddress),
|
||||
) { result ->
|
||||
accountConverter.convert(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getRewards(
|
||||
network: P2PEthPoolNetwork,
|
||||
delegatorAddress: String,
|
||||
vaultAddress: String,
|
||||
period: Int?,
|
||||
): Either<StakingError, List<P2PEthPoolReward>> = either {
|
||||
withContext(dispatchers.io) {
|
||||
val response = p2pApi.getRewards(
|
||||
network = network.value,
|
||||
delegatorAddress = delegatorAddress,
|
||||
vaultAddress = vaultAddress,
|
||||
period = period,
|
||||
)
|
||||
when (response) {
|
||||
is ApiResponse.Success -> {
|
||||
val data = response.data
|
||||
ensure(data.error == null) {
|
||||
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
|
||||
}
|
||||
val result = requireNotNull(data.result) { "Result is null in successful response" }
|
||||
result.rewards.map { rewardConverter.convert(it) }
|
||||
}
|
||||
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
|
||||
}
|
||||
}
|
||||
override fun getVaultsFlow(): Flow<List<P2PEthPoolVault>> {
|
||||
return p2pEthPoolVaultsStore.get()
|
||||
}
|
||||
|
||||
override fun getStakingAvailability(): Flow<StakingAvailability> {
|
||||
|
|
@ -231,7 +189,7 @@ internal class DefaultP2PEthPoolRepository(
|
|||
if (vaults.isEmpty()) {
|
||||
return@map StakingAvailability.TemporaryUnavailable
|
||||
} else {
|
||||
StakingAvailability.Available(StakingOption.P2P(vaults))
|
||||
StakingAvailability.Available(StakingOption.P2PEthPool(vaults))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -241,15 +199,11 @@ internal class DefaultP2PEthPoolRepository(
|
|||
return if (vaults.isEmpty()) {
|
||||
StakingAvailability.TemporaryUnavailable
|
||||
} else {
|
||||
StakingAvailability.Available(StakingOption.P2P(vaults))
|
||||
StakingAvailability.Available(StakingOption.P2PEthPool(vaults))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getVaultsSync(): List<P2PEthPoolVault> {
|
||||
override suspend fun getVaultsSync(): List<P2PEthPoolVault> {
|
||||
return p2pEthPoolVaultsStore.getSync()
|
||||
}
|
||||
|
||||
private fun getVaultsFlow(): Flow<List<P2PEthPoolVault>> {
|
||||
return p2pEthPoolVaultsStore.get()
|
||||
}
|
||||
}
|
||||
|
|
@ -364,6 +364,7 @@ internal class DefaultStakeKitRepository(
|
|||
}
|
||||
|
||||
override fun getStakingAvailability(
|
||||
integrationId: StakingIntegrationID.StakeKit,
|
||||
rawCurrencyId: CryptoCurrency.RawID,
|
||||
symbol: String,
|
||||
): Flow<StakingAvailability> {
|
||||
|
|
@ -381,7 +382,7 @@ internal class DefaultStakeKitRepository(
|
|||
)
|
||||
|
||||
if (prefetchedYield != null) {
|
||||
StakingAvailability.Available(StakingOption.StakeKit(prefetchedYield))
|
||||
StakingAvailability.Available(StakingOption.StakeKit(integrationId, prefetchedYield))
|
||||
} else {
|
||||
StakingAvailability.TemporaryUnavailable
|
||||
}
|
||||
|
|
@ -389,6 +390,7 @@ internal class DefaultStakeKitRepository(
|
|||
}
|
||||
|
||||
override suspend fun getStakingAvailabilitySync(
|
||||
integrationId: StakingIntegrationID.StakeKit,
|
||||
rawCurrencyId: CryptoCurrency.RawID,
|
||||
symbol: String,
|
||||
): StakingAvailability {
|
||||
|
|
@ -404,7 +406,7 @@ internal class DefaultStakeKitRepository(
|
|||
)
|
||||
|
||||
return if (prefetchedYield != null) {
|
||||
StakingAvailability.Available(StakingOption.StakeKit(prefetchedYield))
|
||||
StakingAvailability.Available(StakingOption.StakeKit(integrationId, prefetchedYield))
|
||||
} else {
|
||||
StakingAvailability.TemporaryUnavailable
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,12 +29,12 @@ internal class DefaultStakingErrorResolver(
|
|||
is StakingError.DomainError -> {
|
||||
analyticsEventHandler.send(StakingAnalyticsEvent.DomainError(error))
|
||||
}
|
||||
// P2P errors
|
||||
// P2PEthPool errors
|
||||
is StakingError.InvalidAmount,
|
||||
is StakingError.DataError,
|
||||
is StakingError.UnknownError,
|
||||
-> {
|
||||
// P2P errors - no specific analytics event yet
|
||||
// P2PEthPool errors - no specific analytics event yet
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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.StakingBalancesStore
|
||||
import com.tangem.data.staking.store.StakeKitBalancesStore
|
||||
import com.tangem.domain.card.common.TapWorkarounds.isWallet2
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.staking.StakingBalance
|
||||
|
|
@ -30,7 +30,7 @@ import kotlinx.coroutines.withContext
|
|||
internal class DefaultStakingRepository(
|
||||
private val stakeKitRepository: StakeKitRepository,
|
||||
private val p2pEthPoolRepository: P2PEthPoolRepository,
|
||||
private val stakingBalanceStoreV2: StakingBalancesStore,
|
||||
private val stakingBalanceStoreV2: StakeKitBalancesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val getUserWalletUseCase: GetUserWalletUseCase,
|
||||
private val stakingFeatureToggles: StakingFeatureToggles,
|
||||
|
|
@ -61,8 +61,9 @@ internal class DefaultStakingRepository(
|
|||
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
|
||||
|
||||
val availabilityFlow = when (stakingIntegration) {
|
||||
is StakingIntegrationID.P2P -> p2pEthPoolRepository.getStakingAvailability()
|
||||
StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailability()
|
||||
is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability(
|
||||
stakingIntegration,
|
||||
rawCurrencyId,
|
||||
cryptoCurrency.symbol,
|
||||
)
|
||||
|
|
@ -94,8 +95,9 @@ internal class DefaultStakingRepository(
|
|||
?: return StakingAvailability.Unavailable
|
||||
|
||||
return when (stakingIntegration) {
|
||||
is StakingIntegrationID.P2P -> p2pEthPoolRepository.getStakingAvailabilitySync()
|
||||
StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailabilitySync()
|
||||
is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailabilitySync(
|
||||
stakingIntegration,
|
||||
rawCurrencyId,
|
||||
cryptoCurrency.symbol,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
|
|||
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardScheduleDTO
|
||||
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.ValidatorDTO.ValidatorStatusDTO
|
||||
import com.tangem.datasource.local.token.converter.YieldTokenConverter
|
||||
import com.tangem.domain.staking.model.common.RewardInfo
|
||||
import com.tangem.domain.staking.model.common.RewardType
|
||||
import com.tangem.domain.staking.model.stakekit.AddressArgument
|
||||
import com.tangem.domain.staking.model.stakekit.Yield
|
||||
import com.tangem.domain.staking.model.stakekit.Yield.Metadata.RewardSchedule
|
||||
|
|
@ -124,7 +126,7 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
|
|||
)
|
||||
}
|
||||
|
||||
private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO, rewardType: Yield.RewardType): Yield.Validator {
|
||||
private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO, rewardType: RewardType): Yield.Validator {
|
||||
val address = validatorDTO.address.asMandatory("address")
|
||||
|
||||
return Yield.Validator(
|
||||
|
|
@ -142,7 +144,7 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
|
|||
)
|
||||
}
|
||||
|
||||
private fun createRewardInfo(validatorDTO: YieldDTO.ValidatorDTO, rewardType: Yield.RewardType): Yield.RewardInfo? {
|
||||
private fun createRewardInfo(validatorDTO: YieldDTO.ValidatorDTO, rewardType: RewardType): RewardInfo? {
|
||||
val aprOrApy = validatorDTO.apr
|
||||
val commission = validatorDTO.commission
|
||||
// gross = net / (1 - commission)
|
||||
|
|
@ -162,17 +164,17 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
|
|||
} else {
|
||||
netApy
|
||||
}
|
||||
grossAprOrApy?.let { Yield.RewardInfo(rate = it, type = rewardType) }
|
||||
grossAprOrApy?.let { RewardInfo(rate = it, type = rewardType) }
|
||||
} catch (_: Exception) {
|
||||
aprOrApy?.let { Yield.RewardInfo(rate = it, type = rewardType) }
|
||||
aprOrApy?.let { RewardInfo(rate = it, type = rewardType) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertRewardType(rewardTypeDTO: YieldDTO.RewardTypeDTO): Yield.RewardType {
|
||||
private fun convertRewardType(rewardTypeDTO: YieldDTO.RewardTypeDTO): RewardType {
|
||||
return when (rewardTypeDTO) {
|
||||
YieldDTO.RewardTypeDTO.APY -> Yield.RewardType.APY
|
||||
YieldDTO.RewardTypeDTO.APR -> Yield.RewardType.APR
|
||||
else -> Yield.RewardType.UNKNOWN
|
||||
YieldDTO.RewardTypeDTO.APY -> RewardType.APY
|
||||
YieldDTO.RewardTypeDTO.APR -> RewardType.APR
|
||||
else -> RewardType.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import com.tangem.utils.converter.Converter
|
|||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Converter from P2P Broadcast Transaction Response to Domain model
|
||||
* Converter from P2PEthPool Broadcast Transaction Response to Domain model
|
||||
*/
|
||||
internal object P2PEthPoolBroadcastResultConverter : Converter<P2PEthPoolBroadcastResponse, P2PEthPoolBroadcastResult> {
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import com.tangem.domain.staking.model.stakekit.StakingError
|
|||
import com.tangem.utils.converter.Converter
|
||||
|
||||
/**
|
||||
* Converter from P2P Error Response to Domain StakingError
|
||||
* Converter from P2PEthPool Error Response to Domain StakingError
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
internal object P2PEthPoolErrorConverter : Converter<P2PEthPoolErrorResponse, StakingError> {
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
package com.tangem.data.staking.converters.ethpool
|
||||
|
||||
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolRewardDTO
|
||||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
/**
|
||||
* Converter from P2P Reward Entry DTO to Domain model
|
||||
*/
|
||||
internal object P2PEthPoolRewardConverter : Converter<P2PEthPoolRewardDTO, P2PEthPoolReward> {
|
||||
|
||||
override fun convert(value: P2PEthPoolRewardDTO): P2PEthPoolReward {
|
||||
return P2PEthPoolReward(
|
||||
date = value.date,
|
||||
apy = value.apy.toBigDecimal(),
|
||||
balance = value.balance,
|
||||
rewards = value.rewards,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,17 +4,20 @@ import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountRespon
|
|||
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.staking.model.ethpool.*
|
||||
import com.tangem.domain.models.staking.P2PEthPoolExitQueue
|
||||
import com.tangem.domain.models.staking.P2PEthPoolExitRequest
|
||||
import com.tangem.domain.models.staking.P2PEthPoolStake
|
||||
import com.tangem.domain.models.staking.P2PEthPoolStakingAccount
|
||||
import com.tangem.utils.converter.Converter
|
||||
import org.joda.time.Instant
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Converter from P2P Account Info Response to Domain model
|
||||
* Converts P2PEthPool Account API response to domain [P2PEthPoolStakingAccount].
|
||||
*/
|
||||
internal object P2PEthPoolAccountConverter : Converter<P2PEthPoolAccountResponse, P2PEthPoolAccount> {
|
||||
internal object P2PEthPoolStakingAccountConverter : Converter<P2PEthPoolAccountResponse, P2PEthPoolStakingAccount> {
|
||||
|
||||
override fun convert(value: P2PEthPoolAccountResponse): P2PEthPoolAccount {
|
||||
return P2PEthPoolAccount(
|
||||
override fun convert(value: P2PEthPoolAccountResponse): P2PEthPoolStakingAccount {
|
||||
return P2PEthPoolStakingAccount(
|
||||
delegatorAddress = value.delegatorAddress,
|
||||
vaultAddress = value.vaultAddress,
|
||||
stake = convertStake(value.stake),
|
||||
|
|
@ -24,26 +27,26 @@ internal object P2PEthPoolAccountConverter : Converter<P2PEthPoolAccountResponse
|
|||
)
|
||||
}
|
||||
|
||||
private fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake {
|
||||
fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake {
|
||||
return P2PEthPoolStake(
|
||||
assets = dto.assets,
|
||||
totalEarnedAssets = dto.totalEarnedAssets,
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PEthPoolExitQueue {
|
||||
fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PEthPoolExitQueue {
|
||||
return P2PEthPoolExitQueue(
|
||||
total = dto.total.toBigDecimal(),
|
||||
total = dto.total,
|
||||
requests = dto.requests.map(::convertExitRequest),
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PEthPoolExitRequest {
|
||||
fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PEthPoolExitRequest {
|
||||
return P2PEthPoolExitRequest(
|
||||
ticket = dto.ticket,
|
||||
totalAssets = dto.totalAssets.toBigDecimal(),
|
||||
timestamp = Instant.ofEpochSecond(dto.timestamp),
|
||||
withdrawalTimestamp = Instant.ofEpochSecond(dto.withdrawalTimestamp),
|
||||
totalAssets = dto.totalAssets,
|
||||
timestamp = Instant.fromEpochMilliseconds(dto.timestamp),
|
||||
withdrawalTimestamp = dto.withdrawalTimestamp?.let { Instant.fromEpochMilliseconds(it) },
|
||||
isClaimable = dto.isClaimable,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package com.tangem.data.staking.converters.ethpool
|
||||
|
||||
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.staking.StakingBalance
|
||||
import com.tangem.domain.models.staking.StakingID
|
||||
import com.tangem.domain.staking.model.StakingIntegrationID
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Converts P2PEthPool API responses to [StakingBalance].
|
||||
*
|
||||
* Uses [P2PEthPoolStakingAccountConverter] for account conversion to avoid duplication.
|
||||
*/
|
||||
internal object P2PEthPoolStakingBalanceConverter {
|
||||
|
||||
fun convertAll(responses: Set<P2PEthPoolAccountResponse>, source: StatusSource): Set<StakingBalance> {
|
||||
return responses
|
||||
.groupBy { it.delegatorAddress }
|
||||
.map { (delegatorAddress, accountResponses) ->
|
||||
convert(delegatorAddress, accountResponses, source)
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun convert(
|
||||
delegatorAddress: String,
|
||||
responses: List<P2PEthPoolAccountResponse>,
|
||||
source: StatusSource,
|
||||
): StakingBalance {
|
||||
val stakingId = StakingID(
|
||||
integrationId = StakingIntegrationID.P2PEthPool.value,
|
||||
address = delegatorAddress,
|
||||
)
|
||||
|
||||
val accounts = responses.map { P2PEthPoolStakingAccountConverter.convert(it) }
|
||||
|
||||
val hasActivePosition = accounts.any { account ->
|
||||
account.stake.assets > BigDecimal.ZERO ||
|
||||
account.exitQueue.total > BigDecimal.ZERO ||
|
||||
account.availableToWithdraw > BigDecimal.ZERO
|
||||
}
|
||||
|
||||
return if (hasActivePosition) {
|
||||
StakingBalance.Data.P2PEthPool(
|
||||
stakingId = stakingId,
|
||||
source = source,
|
||||
accounts = accounts,
|
||||
)
|
||||
} else {
|
||||
StakingBalance.Empty(
|
||||
stakingId = stakingId,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import com.tangem.utils.converter.Converter
|
|||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Converter from P2P Unsigned Transaction DTO to Domain model
|
||||
* Converter from P2PEthPool Unsigned Transaction DTO to Domain model
|
||||
*/
|
||||
internal object P2PEthPoolUnsignedTxConverter : Converter<P2PEthPoolUnsignedTxDTO, P2PEthPoolUnsignedTx> {
|
||||
|
||||
|
|
|
|||
|
|
@ -3,21 +3,26 @@ package com.tangem.data.staking.converters.ethpool
|
|||
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultDTO
|
||||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
|
||||
import com.tangem.utils.converter.Converter
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* Converter from P2P Vault DTO to Domain model
|
||||
* Converter from P2PEthPool Vault DTO to Domain model
|
||||
*/
|
||||
internal object P2PEthPoolVaultConverter : Converter<P2PEthPoolVaultDTO, P2PEthPoolVault> {
|
||||
|
||||
private val HUNDRED = BigDecimal(100)
|
||||
private const val DIVIDE_SCALE = 8
|
||||
|
||||
override fun convert(value: P2PEthPoolVaultDTO): P2PEthPoolVault {
|
||||
return P2PEthPoolVault(
|
||||
vaultAddress = value.vaultAddress,
|
||||
displayName = value.displayName,
|
||||
apy = value.apy.toBigDecimal(),
|
||||
baseApy = value.baseApy.toBigDecimal(),
|
||||
capacity = value.capacity.toBigDecimal(),
|
||||
totalAssets = value.totalAssets.toBigDecimal(),
|
||||
feePercent = value.feePercent.toBigDecimal(),
|
||||
apy = value.apy.divide(HUNDRED, DIVIDE_SCALE, RoundingMode.HALF_UP),
|
||||
baseApy = value.baseApy.divide(HUNDRED, DIVIDE_SCALE, RoundingMode.HALF_UP),
|
||||
capacity = value.capacity,
|
||||
totalAssets = value.totalAssets,
|
||||
feePercent = value.feePercent,
|
||||
isPrivate = value.isPrivate,
|
||||
isGenesis = value.isGenesis,
|
||||
isSmoothingPool = value.isSmoothingPool,
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
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.data.staking.store.DefaultP2PEthPoolBalancesStore
|
||||
import com.tangem.data.staking.store.DefaultStakeKitBalancesStore
|
||||
import com.tangem.data.staking.store.P2PEthPoolBalancesStore
|
||||
import com.tangem.data.staking.store.StakeKitBalancesStore
|
||||
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
|
||||
|
|
@ -28,8 +28,8 @@ internal object StakingBalanceSupplierModule {
|
|||
fun provideStakingBalancesStore(
|
||||
persistenceStore: DataStore<Map<String, Set<YieldBalanceWrapperDTO>>>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): StakingBalancesStore {
|
||||
return DefaultStakingBalancesStore(
|
||||
): StakeKitBalancesStore {
|
||||
return DefaultStakeKitBalancesStore(
|
||||
runtimeStore = RuntimeSharedStore(),
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = dispatchers,
|
||||
|
|
@ -38,11 +38,11 @@ internal object StakingBalanceSupplierModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideP2PBalancesStore(
|
||||
fun provideP2PEthPoolBalancesStore(
|
||||
persistenceStore: DataStore<Map<String, Set<P2PEthPoolAccountResponse>>>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): P2PBalancesStore {
|
||||
return DefaultP2PBalancesStore(
|
||||
): P2PEthPoolBalancesStore {
|
||||
return DefaultP2PEthPoolBalancesStore(
|
||||
runtimeStore = RuntimeSharedStore(),
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = dispatchers,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ 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.StakingBalancesStore
|
||||
import com.tangem.data.staking.store.P2PEthPoolBalancesStore
|
||||
import com.tangem.data.staking.store.StakeKitBalancesStore
|
||||
import com.tangem.data.staking.toggles.DefaultStakingFeatureToggles
|
||||
import com.tangem.data.staking.utils.DefaultStakingCleaner
|
||||
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
|
||||
|
|
@ -16,6 +17,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
|
|||
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
|
||||
import com.tangem.datasource.local.token.StakingActionsStore
|
||||
import com.tangem.datasource.local.token.StakingYieldsStore
|
||||
import com.tangem.domain.staking.StakingIdFactory
|
||||
import com.tangem.domain.staking.repositories.*
|
||||
import com.tangem.domain.staking.toggles.StakingFeatureToggles
|
||||
import com.tangem.domain.staking.utils.StakingCleaner
|
||||
|
|
@ -55,7 +57,7 @@ internal object StakingDataModule {
|
|||
fun provideStakingRepository(
|
||||
stakeKitRepository: StakeKitRepository,
|
||||
p2pEthPoolRepository: P2PEthPoolRepository,
|
||||
stakingBalancesStore: StakingBalancesStore,
|
||||
stakeKitBalancesStore: StakeKitBalancesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
getUserWalletUseCase: GetUserWalletUseCase,
|
||||
stakingFeatureToggles: StakingFeatureToggles,
|
||||
|
|
@ -64,7 +66,7 @@ internal object StakingDataModule {
|
|||
return DefaultStakingRepository(
|
||||
stakeKitRepository = stakeKitRepository,
|
||||
p2pEthPoolRepository = p2pEthPoolRepository,
|
||||
stakingBalanceStoreV2 = stakingBalancesStore,
|
||||
stakingBalanceStoreV2 = stakeKitBalancesStore,
|
||||
dispatchers = dispatchers,
|
||||
getUserWalletUseCase = getUserWalletUseCase,
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
|
|
@ -75,13 +77,13 @@ internal object StakingDataModule {
|
|||
@Provides
|
||||
@Singleton
|
||||
fun provideP2PEthPoolRepository(
|
||||
p2pApi: P2PEthPoolApi,
|
||||
p2pEthPoolApi: P2PEthPoolApi,
|
||||
p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
stakingFeatureToggles: StakingFeatureToggles,
|
||||
): P2PEthPoolRepository {
|
||||
return DefaultP2PEthPoolRepository(
|
||||
p2pApi = p2pApi,
|
||||
p2pEthPoolApi = p2pEthPoolApi,
|
||||
p2pEthPoolVaultsStore = p2pEthPoolVaultsStore,
|
||||
dispatchers = dispatchers,
|
||||
stakingFeatureToggles = stakingFeatureToggles,
|
||||
|
|
@ -136,11 +138,15 @@ internal object StakingDataModule {
|
|||
@Provides
|
||||
@Singleton
|
||||
fun provideStakingCleaner(
|
||||
stakingBalancesStore: StakingBalancesStore,
|
||||
stakingIdFactory: StakingIdFactory,
|
||||
stakeKitBalancesStore: StakeKitBalancesStore,
|
||||
p2pEthPoolBalancesStore: P2PEthPoolBalancesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): StakingCleaner {
|
||||
return DefaultStakingCleaner(
|
||||
stakingBalancesStore = stakingBalancesStore,
|
||||
stakingIdFactory = stakingIdFactory,
|
||||
stakeKitBalancesStore = stakeKitBalancesStore,
|
||||
p2pEthPoolBalancesStore = p2pEthPoolBalancesStore,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ 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.store.P2PEthPoolBalancesStore
|
||||
import com.tangem.data.staking.store.StakeKitBalancesStore
|
||||
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
|
||||
|
|
@ -24,7 +24,8 @@ 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.model.ethpool.P2PEthPoolStakingConfig
|
||||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
|
||||
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
|
|
@ -38,16 +39,16 @@ import javax.inject.Inject
|
|||
/**
|
||||
* Default implementation of [MultiStakingBalanceFetcher]
|
||||
*
|
||||
* Supports both StakeKit and P2P staking providers.
|
||||
* Supports both StakeKit and P2PEthPool 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
|
||||
* @property userWalletsStore user wallets store
|
||||
* @property stakingYieldsStore staking yields store
|
||||
* @property stakeKitBalancesStore staking balances store (StakeKit)
|
||||
* @property p2PEthPoolBalancesStore P2PEthPool balances store
|
||||
* @property stakeKitApi stake kit API
|
||||
* @property p2pEthPoolApi P2PEthPool API
|
||||
* @property p2pEthPoolVaultsStore P2PEthPool vaults store
|
||||
* @property dispatchers dispatchers
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
|
|
@ -55,11 +56,11 @@ import javax.inject.Inject
|
|||
internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
|
||||
private val userWalletsStore: UserWalletsStore,
|
||||
private val stakingYieldsStore: StakingYieldsStore,
|
||||
private val stakingBalancesStore: StakingBalancesStore,
|
||||
private val p2pBalancesStore: P2PBalancesStore,
|
||||
private val stakeKitBalancesStore: StakeKitBalancesStore,
|
||||
private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore,
|
||||
private val stakeKitApi: StakeKitApi,
|
||||
private val p2pApi: P2PEthPoolApi,
|
||||
private val p2pVaultsStore: P2PEthPoolVaultsStore,
|
||||
private val p2pEthPoolApi: P2PEthPoolApi,
|
||||
private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : MultiStakingBalanceFetcher {
|
||||
|
||||
|
|
@ -75,7 +76,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
|
|||
return it.left()
|
||||
}
|
||||
|
||||
val (stakeKitIds, p2pIds) = stakingIds.partition { stakingId ->
|
||||
val (stakeKitIds, p2pEthPoolIds) = stakingIds.partition { stakingId ->
|
||||
val stakingIntegrationID = StakingIntegrationID.entries.find {
|
||||
it.value == stakingId.integrationId
|
||||
}
|
||||
|
|
@ -86,7 +87,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
|
|||
"""
|
||||
Staking IDs to fetch:
|
||||
- StakeKit: ${stakeKitIds.joinToString()}
|
||||
- P2P: ${p2pIds.joinToString()}
|
||||
- P2PEthPool: ${p2pEthPoolIds.joinToString()}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
|
|
@ -96,8 +97,8 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
|
|||
launch { fetchStakeKitBalances(params.userWalletId, stakeKitIds.toSet()) }
|
||||
}
|
||||
|
||||
if (p2pIds.isNotEmpty()) {
|
||||
launch { fetchP2PBalances(params.userWalletId, p2pIds.toSet()) }
|
||||
if (p2pEthPoolIds.isNotEmpty()) {
|
||||
launch { fetchP2PBalances(params.userWalletId, p2pEthPoolIds.toSet()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -105,19 +106,22 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
|
|||
Timber.e(throwable, "Unable to fetch staking balances $params")
|
||||
|
||||
if (stakeKitIds.isNotEmpty()) {
|
||||
stakingBalancesStore.storeError(
|
||||
stakeKitBalancesStore.storeError(
|
||||
userWalletId = params.userWalletId,
|
||||
stakingIds = stakeKitIds.toSet(),
|
||||
)
|
||||
}
|
||||
if (p2pIds.isNotEmpty()) {
|
||||
p2pBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = p2pIds.toSet())
|
||||
if (p2pEthPoolIds.isNotEmpty()) {
|
||||
p2PEthPoolBalancesStore.storeError(
|
||||
userWalletId = params.userWalletId,
|
||||
stakingIds = p2pEthPoolIds.toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchStakeKitBalances(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
|
||||
stakingBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
stakeKitBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
|
||||
val availableStakingIds = getAvailableStakingIds(
|
||||
userWalletId = userWalletId,
|
||||
|
|
@ -128,12 +132,12 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
|
|||
}
|
||||
|
||||
private suspend fun fetchP2PBalances(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
|
||||
p2pBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
p2PEthPoolBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
|
||||
val vaults = runSuspendCatching { p2pVaultsStore.getSync() }.getOrNull().orEmpty()
|
||||
val vaults = runSuspendCatching { p2pEthPoolVaultsStore.getSync() }.getOrNull().orEmpty()
|
||||
if (vaults.isEmpty()) {
|
||||
Timber.w("No P2P vaults available for $userWalletId")
|
||||
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
Timber.w("No P2PEthPool vaults available for $userWalletId, storing empty balances")
|
||||
p2PEthPoolBalancesStore.storeEmpty(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -143,59 +147,17 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
|
|||
private suspend fun fetchFromP2P(
|
||||
userWalletId: UserWalletId,
|
||||
stakingIds: Set<StakingID>,
|
||||
vaults: List<com.tangem.domain.staking.model.ethpool.P2PEthPoolVault>,
|
||||
vaults: List<P2PEthPoolVault>,
|
||||
) {
|
||||
safeApiCall(
|
||||
call = {
|
||||
val addresses = stakingIds.map { it.address }.toSet()
|
||||
val responses = fetchP2PAccountResponses(vaults = vaults, addresses = addresses)
|
||||
|
||||
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")
|
||||
Timber.i("Successfully fetched ${responses.size} P2PEthPool balances for $userWalletId")
|
||||
|
||||
if (responses.isNotEmpty()) {
|
||||
p2pBalancesStore.storeActual(userWalletId = userWalletId, values = responses)
|
||||
p2PEthPoolBalancesStore.storeActual(userWalletId = userWalletId, values = responses)
|
||||
|
||||
val missingStakingIds = stakingIds.filter { stakingId ->
|
||||
responses.none { response ->
|
||||
|
|
@ -205,23 +167,76 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
|
|||
|
||||
if (missingStakingIds.isNotEmpty()) {
|
||||
Timber.i("Missing responses for ${missingStakingIds.size} staking IDs: $missingStakingIds")
|
||||
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = missingStakingIds.toSet())
|
||||
p2PEthPoolBalancesStore.storeError(
|
||||
userWalletId = userWalletId,
|
||||
stakingIds = missingStakingIds.toSet(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Timber.i("No P2P responses received for $userWalletId")
|
||||
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
Timber.i("No P2PEthPool responses received for $userWalletId")
|
||||
p2PEthPoolBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
}
|
||||
},
|
||||
onError = { throwable ->
|
||||
Timber.e(throwable, "Unable to fetch P2P balances $userWalletId")
|
||||
Timber.e(throwable, "Unable to fetch P2PEthPool balances $userWalletId")
|
||||
|
||||
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
p2PEthPoolBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
|
||||
throw throwable
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchP2PAccountResponses(
|
||||
vaults: List<P2PEthPoolVault>,
|
||||
addresses: Set<String>,
|
||||
): Set<P2PEthPoolAccountResponse> {
|
||||
val responses = mutableSetOf<P2PEthPoolAccountResponse>()
|
||||
|
||||
for (vault in vaults) {
|
||||
for (address in addresses) {
|
||||
runSuspendCatching {
|
||||
val response = p2pEthPoolApi.getAccountInfo(
|
||||
network = P2PEthPoolStakingConfig.activeNetwork.value,
|
||||
delegatorAddress = address,
|
||||
vaultAddress = vault.vaultAddress,
|
||||
)
|
||||
|
||||
when (response) {
|
||||
is ApiResponse.Success -> {
|
||||
val data = response.data
|
||||
if (data.error != null) {
|
||||
Timber.w(
|
||||
"P2PEthPool 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 P2PEthPool balance for vault ${vault.vaultAddress}, " +
|
||||
"address $address",
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Timber.w(
|
||||
error,
|
||||
"Failed to fetch P2PEthPool balance for vault ${vault.vaultAddress}, address $address",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return responses
|
||||
}
|
||||
|
||||
private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) {
|
||||
val maybeUserWallet = userWalletsStore.getSyncOrNull(key = userWalletId).toOption()
|
||||
|
||||
|
|
@ -255,7 +270,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
|
|||
)
|
||||
|
||||
if (unavailableStakingIds.isNotEmpty()) {
|
||||
stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = unavailableStakingIds.toSet())
|
||||
stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = unavailableStakingIds.toSet())
|
||||
}
|
||||
|
||||
return availableStakingIds.toSet().ifEmpty {
|
||||
|
|
@ -307,7 +322,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
|
|||
Timber.i(
|
||||
"Successfully fetched staking balances for $userWalletId:\n${yieldBalances.joinToString("\n")}",
|
||||
)
|
||||
stakingBalancesStore.storeActual(userWalletId = userWalletId, values = yieldBalances)
|
||||
stakeKitBalancesStore.storeActual(userWalletId = userWalletId, values = yieldBalances)
|
||||
|
||||
if (!allResponsesReceived(requests, yieldBalances)) {
|
||||
val values = stakingIds.filter { stakingId ->
|
||||
|
|
@ -317,13 +332,13 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = values.toSet())
|
||||
stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = values.toSet())
|
||||
}
|
||||
},
|
||||
onError = { throwable ->
|
||||
Timber.e(throwable, "Unable to fetch staking balances $userWalletId")
|
||||
|
||||
stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
|
||||
throw throwable
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ 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.data.staking.store.P2PEthPoolBalancesStore
|
||||
import com.tangem.data.staking.store.StakeKitBalancesStore
|
||||
import com.tangem.domain.models.staking.StakingBalance
|
||||
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
|
|
@ -19,30 +19,30 @@ import kotlinx.coroutines.flow.onEmpty
|
|||
/**
|
||||
* Default implementation of [MultiStakingBalanceProducer]
|
||||
*
|
||||
* Combines staking balances from both StakeKit and P2P providers.
|
||||
* Combines staking balances from both StakeKit and P2PEthPool providers.
|
||||
*
|
||||
* @property params params
|
||||
* @property stakingBalancesStore StakeKit staking balances store
|
||||
* @property p2pBalancesStore P2P balances store
|
||||
* @property stakeKitBalancesStore StakeKit staking balances store
|
||||
* @property p2PEthPoolBalancesStore P2PEthPool 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 stakeKitBalancesStore: StakeKitBalancesStore,
|
||||
private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore,
|
||||
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)
|
||||
val stakeKitFlow = stakeKitBalancesStore.get(userWalletId = params.userWalletId)
|
||||
val p2pEthPoolFlow = p2PEthPoolBalancesStore.get(userWalletId = params.userWalletId)
|
||||
|
||||
return combine(stakeKitFlow, p2pFlow) { stakeKitBalances, p2pBalances ->
|
||||
stakeKitBalances + p2pBalances
|
||||
return combine(stakeKitFlow, p2pEthPoolFlow) { stakeKitBalances, p2pEthPoolBalances ->
|
||||
stakeKitBalances + p2pEthPoolBalances
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEmpty { emit(value = hashSetOf()) }
|
||||
|
|
|
|||
|
|
@ -1,29 +1,35 @@
|
|||
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
|
||||
* Base interface for staking balances stores.
|
||||
*
|
||||
* Defines common read/query operations shared by all staking provider stores.
|
||||
*/
|
||||
interface P2PBalancesStore {
|
||||
interface BaseStakingBalancesStore {
|
||||
|
||||
/** Get flow of staking balances for a wallet */
|
||||
fun get(userWalletId: UserWalletId): Flow<Set<StakingBalance>>
|
||||
|
||||
/** Get a single staking balance synchronously */
|
||||
suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance?
|
||||
|
||||
/** Get all staking balances for a wallet synchronously */
|
||||
suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<StakingBalance>?
|
||||
|
||||
/** Refresh a single staking balance from cache */
|
||||
suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID)
|
||||
|
||||
/** Refresh multiple staking balances from cache */
|
||||
suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
|
||||
|
||||
suspend fun storeActual(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>)
|
||||
|
||||
/** Store error state for staking balances */
|
||||
suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
|
||||
|
||||
/** Clear staking balances */
|
||||
suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
|
||||
}
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
package com.tangem.data.staking.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.tangem.data.staking.converters.ethpool.P2PStakingBalanceConverter
|
||||
import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingBalanceConverter
|
||||
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
|
||||
|
|
@ -20,22 +19,22 @@ import kotlinx.coroutines.flow.map
|
|||
import kotlinx.coroutines.launch
|
||||
|
||||
internal typealias WalletIdWithP2PStakingBalances = Map<UserWalletId, Set<StakingBalance>>
|
||||
internal typealias WalletIdWithP2PResponses = Map<String, Set<P2PEthPoolAccountResponse>>
|
||||
internal typealias WalletIdWithP2PEthPoolResponses = Map<String, Set<P2PEthPoolAccountResponse>>
|
||||
|
||||
/**
|
||||
* Default implementation of [P2PBalancesStore]
|
||||
* Default implementation of [P2PEthPoolBalancesStore]
|
||||
*
|
||||
* Stores P2P ETH Pool staking balances.
|
||||
* Stores P2PEthPool staking balances.
|
||||
*
|
||||
* @property runtimeStore runtime store
|
||||
* @property persistenceStore persistence store
|
||||
* @param dispatchers coroutine dispatchers
|
||||
*/
|
||||
internal class DefaultP2PBalancesStore(
|
||||
internal class DefaultP2PEthPoolBalancesStore(
|
||||
private val runtimeStore: RuntimeSharedStore<WalletIdWithP2PStakingBalances>,
|
||||
private val persistenceStore: DataStore<WalletIdWithP2PResponses>,
|
||||
private val persistenceStore: DataStore<WalletIdWithP2PEthPoolResponses>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : P2PBalancesStore {
|
||||
) : P2PEthPoolBalancesStore {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
|
|
@ -46,12 +45,10 @@ internal class DefaultP2PBalancesStore(
|
|||
runtimeStore.store(
|
||||
value = cachedData.map { (stringWalletId, responses) ->
|
||||
val key = UserWalletId(stringWalletId)
|
||||
val value = responses.map { response ->
|
||||
P2PStakingBalanceConverter.convert(
|
||||
response = response,
|
||||
source = StatusSource.CACHE,
|
||||
)
|
||||
}.toSet()
|
||||
val value = P2PEthPoolStakingBalanceConverter.convertAll(
|
||||
responses = responses,
|
||||
source = StatusSource.CACHE,
|
||||
)
|
||||
|
||||
key to value
|
||||
}.toMap(),
|
||||
|
|
@ -90,6 +87,15 @@ internal class DefaultP2PBalancesStore(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun storeEmpty(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
|
||||
updateInRuntime(
|
||||
userWalletId = userWalletId,
|
||||
stakingIds = stakingIds,
|
||||
ifNotFound = ::createEmptyStakingBalance,
|
||||
update = { it.copySealed(source = StatusSource.ACTUAL) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
|
||||
updateInRuntime(
|
||||
userWalletId = userWalletId,
|
||||
|
|
@ -107,12 +113,10 @@ internal class DefaultP2PBalancesStore(
|
|||
}
|
||||
|
||||
private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>) {
|
||||
val newBalances = values.map { response ->
|
||||
P2PStakingBalanceConverter.convert(
|
||||
response = response,
|
||||
source = StatusSource.ACTUAL,
|
||||
)
|
||||
}.toSet()
|
||||
val newBalances = P2PEthPoolStakingBalanceConverter.convertAll(
|
||||
responses = values,
|
||||
source = StatusSource.ACTUAL,
|
||||
)
|
||||
|
||||
runtimeStore.update(default = emptyMap()) { saved ->
|
||||
saved.toMutableMap().apply {
|
||||
|
|
@ -146,13 +150,13 @@ internal class DefaultP2PBalancesStore(
|
|||
}
|
||||
|
||||
private suspend fun clearInPersistence(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
|
||||
val integrationIds = stakingIds.map { it.integrationId }.toSet()
|
||||
val addressesToClear = stakingIds.map { it.address }.toSet()
|
||||
|
||||
persistenceStore.updateData { current ->
|
||||
current.toMutableMap().apply {
|
||||
this[userWalletId.stringValue] = this[userWalletId.stringValue].orEmpty()
|
||||
.filterNot { response ->
|
||||
StakingIntegrationID.P2P.EthereumPooled.value in integrationIds
|
||||
response.delegatorAddress in addressesToClear
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
|
|
@ -187,5 +191,8 @@ internal class DefaultP2PBalancesStore(
|
|||
}
|
||||
}
|
||||
|
||||
private fun createEmptyStakingBalance(id: StakingID): StakingBalance =
|
||||
StakingBalance.Empty(stakingId = id, source = StatusSource.ACTUAL)
|
||||
|
||||
private fun createErrorStakingBalance(id: StakingID): StakingBalance = StakingBalance.Error(stakingId = id)
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ internal typealias WalletIdWithWrappers = Map<String, Set<YieldBalanceWrapperDTO
|
|||
internal typealias WalletIdWithStakingBalances = Map<UserWalletId, Set<StakingBalance>>
|
||||
|
||||
/**
|
||||
* Default implementation of [StakingBalancesStore]
|
||||
* Default implementation of [StakeKitBalancesStore]
|
||||
*
|
||||
* @property runtimeStore runtime store
|
||||
* @property persistenceStore persistence store
|
||||
|
|
@ -30,11 +30,11 @@ internal typealias WalletIdWithStakingBalances = Map<UserWalletId, Set<StakingBa
|
|||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultStakingBalancesStore(
|
||||
internal class DefaultStakeKitBalancesStore(
|
||||
private val runtimeStore: RuntimeSharedStore<WalletIdWithStakingBalances>,
|
||||
private val persistenceStore: DataStore<WalletIdWithWrappers>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : StakingBalancesStore {
|
||||
) : StakeKitBalancesStore {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
|
|
@ -97,6 +97,23 @@ internal class DefaultStakingBalancesStore(
|
|||
}
|
||||
|
||||
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 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>) {
|
||||
persistenceStore.updateData { current ->
|
||||
current.toMutableMap().apply {
|
||||
this[userWalletId.stringValue] = this[userWalletId.stringValue].orEmpty()
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.data.staking.store
|
||||
|
||||
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
|
||||
import com.tangem.domain.models.staking.StakingID
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Store for P2PEthPool staking balances.
|
||||
*
|
||||
* Extends [BaseStakingBalancesStore] with P2PEthPool-specific storage operations.
|
||||
*/
|
||||
interface P2PEthPoolBalancesStore : BaseStakingBalancesStore {
|
||||
|
||||
/** Store actual P2PEthPool account balances */
|
||||
suspend fun storeActual(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>)
|
||||
|
||||
/** Store empty state for accounts with no active positions */
|
||||
suspend fun storeEmpty(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.data.staking.store
|
||||
|
||||
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Store for StakeKit staking balances.
|
||||
*
|
||||
* Extends [BaseStakingBalancesStore] with StakeKit-specific storage operations.
|
||||
*/
|
||||
interface StakeKitBalancesStore : BaseStakingBalancesStore {
|
||||
|
||||
/** Store actual StakeKit yield balances */
|
||||
suspend fun storeActual(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>)
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** Store of StakeKit [StakingBalance] */
|
||||
interface StakingBalancesStore {
|
||||
|
||||
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<YieldBalanceWrapperDTO>)
|
||||
|
||||
suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
|
||||
|
||||
suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
|
||||
}
|
||||
|
|
@ -1,29 +1,78 @@
|
|||
package com.tangem.data.staking.utils
|
||||
|
||||
import com.tangem.data.staking.store.StakingBalancesStore
|
||||
import com.tangem.data.staking.store.P2PEthPoolBalancesStore
|
||||
import com.tangem.data.staking.store.StakeKitBalancesStore
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.staking.StakingID
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.staking.StakingIdFactory
|
||||
import com.tangem.domain.staking.utils.StakingCleaner
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Default implementation of [StakingCleaner].
|
||||
*
|
||||
* @property stakingBalancesStore Store to manage staking balances.
|
||||
* @property stakeKitBalancesStore Store to manage StakeKit staking balances.
|
||||
* @property p2pEthPoolBalancesStore Store to manage P2PEthPool staking balances.
|
||||
* @property dispatchers Coroutine dispatchers provider.
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultStakingCleaner(
|
||||
private val stakingBalancesStore: StakingBalancesStore,
|
||||
private val stakingIdFactory: StakingIdFactory,
|
||||
private val stakeKitBalancesStore: StakeKitBalancesStore,
|
||||
private val p2pEthPoolBalancesStore: P2PEthPoolBalancesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : StakingCleaner {
|
||||
|
||||
override suspend fun invoke(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
|
||||
if (stakingIds.isEmpty()) return
|
||||
override suspend fun invoke(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
|
||||
if (currencies.isEmpty()) {
|
||||
Timber.d("No currencies to clear for wallet: $userWalletId")
|
||||
return
|
||||
}
|
||||
|
||||
with(dispatchers.default) {
|
||||
stakingBalancesStore.clear(userWalletId, stakingIds)
|
||||
val stakingIds = currencies.mapNotNullTo(hashSetOf()) {
|
||||
stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = it).getOrNull()
|
||||
}
|
||||
|
||||
if (stakingIds.isEmpty()) {
|
||||
Timber.d("All currencies have no stakingIds to clear for wallet: $userWalletId")
|
||||
return
|
||||
}
|
||||
|
||||
invoke(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
}
|
||||
|
||||
override suspend fun invoke(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
|
||||
if (stakingIds.isEmpty()) {
|
||||
Timber.d("No stakingIds to clear for wallet: $userWalletId")
|
||||
return
|
||||
}
|
||||
|
||||
withContext(dispatchers.default) {
|
||||
awaitAll(
|
||||
async { clearStakeKitBalancesStore(userWalletId = userWalletId, stakingIds = stakingIds) },
|
||||
async { clearP2PEthPoolBalancesStore(userWalletId = userWalletId, stakingIds = stakingIds) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun clearStakeKitBalancesStore(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
|
||||
runSuspendCatching {
|
||||
stakeKitBalancesStore.clear(userWalletId, stakingIds)
|
||||
}
|
||||
.onFailure { Timber.e(it, "Failed to clear StakeKit balance statuses for wallet: $userWalletId") }
|
||||
}
|
||||
|
||||
private suspend fun clearP2PEthPoolBalancesStore(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
|
||||
runSuspendCatching {
|
||||
p2pEthPoolBalancesStore.clear(userWalletId, stakingIds)
|
||||
}
|
||||
.onFailure { Timber.e(it, "Failed to clear P2PEthPool balance statuses for wallet: $userWalletId") }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
package com.tangem.data.staking
|
||||
|
||||
import com.tangem.data.staking.converters.ethpool.P2PStakingBalanceConverter
|
||||
import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingBalanceConverter
|
||||
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
|
||||
|
|
@ -11,9 +11,11 @@ internal fun YieldBalanceWrapperDTO.toDomain(source: StatusSource = StatusSource
|
|||
return StakingBalanceConverter(isCached = source == StatusSource.CACHE).convert(this)!!
|
||||
}
|
||||
|
||||
internal fun P2PEthPoolAccountResponse.toDomain(source: StatusSource = StatusSource.CACHE): StakingBalance.Data.P2P {
|
||||
return P2PStakingBalanceConverter.convert(
|
||||
response = this,
|
||||
internal fun P2PEthPoolAccountResponse.toDomain(
|
||||
source: StatusSource = StatusSource.CACHE,
|
||||
): StakingBalance {
|
||||
return P2PEthPoolStakingBalanceConverter.convertAll(
|
||||
responses = setOf(this),
|
||||
source = source,
|
||||
)
|
||||
).first()
|
||||
}
|
||||
|
|
@ -4,8 +4,8 @@ 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.P2PBalancesStore
|
||||
import com.tangem.data.staking.store.StakingBalancesStore
|
||||
import com.tangem.data.staking.store.P2PEthPoolBalancesStore
|
||||
import com.tangem.data.staking.store.StakeKitBalancesStore
|
||||
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
|
|
@ -35,26 +35,26 @@ internal class DefaultMultiStakingBalanceFetcherTest {
|
|||
|
||||
private val userWalletsStore: UserWalletsStore = mockk()
|
||||
private val stakingYieldsStore: StakingYieldsStore = mockk()
|
||||
private val stakingBalancesStore: StakingBalancesStore = mockk(relaxUnitFun = true)
|
||||
private val p2pBalancesStore: P2PBalancesStore = mockk(relaxUnitFun = true)
|
||||
private val stakeKitBalancesStore: StakeKitBalancesStore = mockk(relaxUnitFun = true)
|
||||
private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore = mockk(relaxUnitFun = true)
|
||||
private val stakeKitApi: StakeKitApi = mockk()
|
||||
private val p2pApi: P2PEthPoolApi = mockk()
|
||||
private val p2pVaultsStore: P2PEthPoolVaultsStore = mockk()
|
||||
private val p2pEthPoolApi: P2PEthPoolApi = mockk()
|
||||
private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore = mockk()
|
||||
|
||||
private val fetcher = DefaultMultiStakingBalanceFetcher(
|
||||
userWalletsStore = userWalletsStore,
|
||||
stakingYieldsStore = stakingYieldsStore,
|
||||
stakingBalancesStore = stakingBalancesStore,
|
||||
p2pBalancesStore = p2pBalancesStore,
|
||||
stakeKitBalancesStore = stakeKitBalancesStore,
|
||||
p2PEthPoolBalancesStore = p2PEthPoolBalancesStore,
|
||||
stakeKitApi = stakeKitApi,
|
||||
p2pApi = p2pApi,
|
||||
p2pVaultsStore = p2pVaultsStore,
|
||||
p2pEthPoolApi = p2pEthPoolApi,
|
||||
p2pEthPoolVaultsStore = p2pEthPoolVaultsStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(userWalletsStore, stakingYieldsStore, stakingBalancesStore, stakeKitApi)
|
||||
clearMocks(userWalletsStore, stakingYieldsStore, stakeKitBalancesStore, stakeKitApi)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -81,13 +81,13 @@ internal class DefaultMultiStakingBalanceFetcherTest {
|
|||
// Assert
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncOrNull(params.userWalletId)
|
||||
stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
|
||||
stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
|
||||
stakingYieldsStore.getSyncWithTimeout()
|
||||
stakeKitApi.getMultipleYieldBalances(requests)
|
||||
stakingBalancesStore.storeActual(userWalletId = userWalletId, values = result)
|
||||
stakeKitBalancesStore.storeActual(userWalletId = userWalletId, values = result)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) { stakingBalancesStore.storeError(any(), any()) }
|
||||
coVerify(inverse = true) { stakeKitBalancesStore.storeError(any(), any()) }
|
||||
|
||||
assertEitherRight(actual)
|
||||
}
|
||||
|
|
@ -113,11 +113,11 @@ internal class DefaultMultiStakingBalanceFetcherTest {
|
|||
// Assert
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncOrNull(params.userWalletId)
|
||||
stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
|
||||
stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
|
||||
stakingYieldsStore.getSyncWithTimeout()
|
||||
stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(solanaId))
|
||||
stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(solanaId))
|
||||
stakeKitApi.getMultipleYieldBalances(requests)
|
||||
stakingBalancesStore.storeActual(userWalletId = userWalletId, values = result)
|
||||
stakeKitBalancesStore.storeActual(userWalletId = userWalletId, values = result)
|
||||
}
|
||||
|
||||
assertEitherRight(actual)
|
||||
|
|
@ -138,11 +138,11 @@ internal class DefaultMultiStakingBalanceFetcherTest {
|
|||
coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) }
|
||||
|
||||
coVerify(inverse = true) {
|
||||
stakingBalancesStore.refresh(userWalletId = any(), stakingIds = any())
|
||||
stakeKitBalancesStore.refresh(userWalletId = any(), stakingIds = any())
|
||||
stakingYieldsStore.getSyncWithTimeout()
|
||||
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
|
||||
stakingBalancesStore.storeActual(userWalletId = any(), values = any())
|
||||
stakingBalancesStore.storeError(userWalletId = any(), stakingIds = any())
|
||||
stakeKitBalancesStore.storeActual(userWalletId = any(), values = any())
|
||||
stakeKitBalancesStore.storeError(userWalletId = any(), stakingIds = any())
|
||||
}
|
||||
|
||||
val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${userWallet.toOption()}")
|
||||
|
|
@ -164,11 +164,11 @@ internal class DefaultMultiStakingBalanceFetcherTest {
|
|||
coVerifyOrder { userWalletsStore.getSyncOrNull(params.userWalletId) }
|
||||
|
||||
coVerify(inverse = true) {
|
||||
stakingBalancesStore.refresh(userWalletId = any(), stakingIds = any())
|
||||
stakeKitBalancesStore.refresh(userWalletId = any(), stakingIds = any())
|
||||
stakingYieldsStore.getSyncWithTimeout()
|
||||
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
|
||||
stakingBalancesStore.storeActual(userWalletId = any(), values = any())
|
||||
stakingBalancesStore.storeError(userWalletId = any(), stakingIds = any())
|
||||
stakeKitBalancesStore.storeActual(userWalletId = any(), values = any())
|
||||
stakeKitBalancesStore.storeError(userWalletId = any(), stakingIds = any())
|
||||
}
|
||||
|
||||
val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${null.toOption()}")
|
||||
|
|
@ -190,14 +190,14 @@ internal class DefaultMultiStakingBalanceFetcherTest {
|
|||
// Assert
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncOrNull(params.userWalletId)
|
||||
stakingBalancesStore.refresh(params.userWalletId, tonAndSolanaIds)
|
||||
stakeKitBalancesStore.refresh(params.userWalletId, tonAndSolanaIds)
|
||||
stakingYieldsStore.getSyncWithTimeout()
|
||||
stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds)
|
||||
stakeKitBalancesStore.storeError(userWalletId, tonAndSolanaIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
stakeKitApi.getMultipleYieldBalances(any())
|
||||
stakingBalancesStore.storeActual(userWalletId = any(), values = any())
|
||||
stakeKitBalancesStore.storeActual(userWalletId = any(), values = any())
|
||||
}
|
||||
|
||||
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
|
||||
|
|
@ -219,14 +219,14 @@ internal class DefaultMultiStakingBalanceFetcherTest {
|
|||
// Assert
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncOrNull(params.userWalletId)
|
||||
stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
|
||||
stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
|
||||
stakingYieldsStore.getSyncWithTimeout()
|
||||
stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds)
|
||||
stakeKitBalancesStore.storeError(userWalletId, tonAndSolanaIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
stakeKitApi.getMultipleYieldBalances(any())
|
||||
stakingBalancesStore.storeActual(userWalletId = any(), values = any())
|
||||
stakeKitBalancesStore.storeActual(userWalletId = any(), values = any())
|
||||
}
|
||||
|
||||
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
|
||||
|
|
@ -253,14 +253,14 @@ internal class DefaultMultiStakingBalanceFetcherTest {
|
|||
// Assert
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncOrNull(params.userWalletId)
|
||||
stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
|
||||
stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
|
||||
stakingYieldsStore.getSyncWithTimeout()
|
||||
stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds)
|
||||
stakeKitBalancesStore.storeError(userWalletId, tonAndSolanaIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
stakeKitApi.getMultipleYieldBalances(any())
|
||||
stakingBalancesStore.storeActual(userWalletId = any(), values = any())
|
||||
stakeKitBalancesStore.storeActual(userWalletId = any(), values = any())
|
||||
}
|
||||
|
||||
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
|
||||
|
|
@ -284,14 +284,14 @@ internal class DefaultMultiStakingBalanceFetcherTest {
|
|||
// Assert
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncOrNull(params.userWalletId)
|
||||
stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
|
||||
stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
|
||||
stakingYieldsStore.getSyncWithTimeout()
|
||||
stakingBalancesStore.storeError(userWalletId, tonAndSolanaIds)
|
||||
stakeKitBalancesStore.storeError(userWalletId, tonAndSolanaIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
stakeKitApi.getMultipleYieldBalances(any())
|
||||
stakingBalancesStore.storeActual(userWalletId = any(), values = any())
|
||||
stakeKitBalancesStore.storeActual(userWalletId = any(), values = any())
|
||||
}
|
||||
|
||||
val expected = IllegalStateException(
|
||||
|
|
@ -329,13 +329,13 @@ internal class DefaultMultiStakingBalanceFetcherTest {
|
|||
// Assert
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncOrNull(params.userWalletId)
|
||||
stakingBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
|
||||
stakeKitBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
|
||||
stakingYieldsStore.getSyncWithTimeout()
|
||||
stakeKitApi.getMultipleYieldBalances(requests)
|
||||
stakingBalancesStore.storeError(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
|
||||
stakeKitBalancesStore.storeError(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) { stakingBalancesStore.storeActual(userWalletId = any(), values = any()) }
|
||||
coVerify(inverse = true) { stakeKitBalancesStore.storeActual(userWalletId = any(), values = any()) }
|
||||
|
||||
val expected = ApiResponseError.NetworkException()
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ 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.store.P2PEthPoolBalancesStore
|
||||
import com.tangem.data.staking.store.StakeKitBalancesStore
|
||||
import com.tangem.data.staking.toDomain
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.staking.*
|
||||
|
|
@ -27,14 +27,14 @@ internal class DefaultMultiStakingBalanceProducerTest {
|
|||
|
||||
private val params = MultiStakingBalanceProducer.Params(userWalletId = UserWalletId("011"))
|
||||
|
||||
private val stakingBalancesStore = mockk<StakingBalancesStore>()
|
||||
private val p2pBalancesStore = mockk<P2PBalancesStore>()
|
||||
private val stakeKitBalancesStore = mockk<StakeKitBalancesStore>()
|
||||
private val p2PEthPoolBalancesStore = mockk<P2PEthPoolBalancesStore>()
|
||||
private val dispatchers = TestingCoroutineDispatcherProvider()
|
||||
|
||||
private val producer = DefaultMultiStakingBalanceProducer(
|
||||
params = params,
|
||||
stakingBalancesStore = stakingBalancesStore,
|
||||
p2pBalancesStore = p2pBalancesStore,
|
||||
stakeKitBalancesStore = stakeKitBalancesStore,
|
||||
p2PEthPoolBalancesStore = p2PEthPoolBalancesStore,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
|
||||
|
|
@ -47,14 +47,14 @@ internal class DefaultMultiStakingBalanceProducerTest {
|
|||
|
||||
val networksStatusesFlow = flowOf(balances)
|
||||
|
||||
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
|
||||
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
|
||||
every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
|
||||
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
// check after producer.produce()
|
||||
verify { stakingBalancesStore.get(params.userWalletId) }
|
||||
verify { p2pBalancesStore.get(params.userWalletId) }
|
||||
verify { stakeKitBalancesStore.get(params.userWalletId) }
|
||||
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
|
||||
|
||||
val values = getEmittedValues(flow = actual)
|
||||
|
||||
|
|
@ -66,14 +66,14 @@ internal class DefaultMultiStakingBalanceProducerTest {
|
|||
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())
|
||||
every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
|
||||
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
// check after producer.produce()
|
||||
verify { stakingBalancesStore.get(params.userWalletId) }
|
||||
verify { p2pBalancesStore.get(params.userWalletId) }
|
||||
verify { stakeKitBalancesStore.get(params.userWalletId) }
|
||||
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
|
||||
|
||||
// first emit
|
||||
val balances = setOf(
|
||||
|
|
@ -107,14 +107,14 @@ internal class DefaultMultiStakingBalanceProducerTest {
|
|||
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())
|
||||
every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
|
||||
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
// check after producer.produce()
|
||||
verify { stakingBalancesStore.get(params.userWalletId) }
|
||||
verify { p2pBalancesStore.get(params.userWalletId) }
|
||||
verify { stakeKitBalancesStore.get(params.userWalletId) }
|
||||
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
|
||||
|
||||
// first emit
|
||||
val wrappers = setOf(
|
||||
|
|
@ -156,14 +156,14 @@ internal class DefaultMultiStakingBalanceProducerTest {
|
|||
}
|
||||
.buffer(capacity = 5)
|
||||
|
||||
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
|
||||
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
|
||||
every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
|
||||
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
|
||||
|
||||
val actual = producer.produceWithFallback()
|
||||
|
||||
// check after producer.produce()
|
||||
verify { stakingBalancesStore.get(params.userWalletId) }
|
||||
verify { p2pBalancesStore.get(params.userWalletId) }
|
||||
verify { stakeKitBalancesStore.get(params.userWalletId) }
|
||||
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
|
||||
|
||||
val values1 = getEmittedValues(flow = actual)
|
||||
|
||||
|
|
@ -180,14 +180,14 @@ internal class DefaultMultiStakingBalanceProducerTest {
|
|||
|
||||
@Test
|
||||
fun `test that flow is empty`() = runTest {
|
||||
every { stakingBalancesStore.get(params.userWalletId) } returns emptyFlow()
|
||||
every { p2pBalancesStore.get(params.userWalletId) } returns emptyFlow()
|
||||
every { stakeKitBalancesStore.get(params.userWalletId) } returns emptyFlow()
|
||||
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns emptyFlow()
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
// check after producer.produce()
|
||||
verify { stakingBalancesStore.get(params.userWalletId) }
|
||||
verify { p2pBalancesStore.get(params.userWalletId) }
|
||||
verify { stakeKitBalancesStore.get(params.userWalletId) }
|
||||
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
|
||||
|
||||
val values = getEmittedValues(flow = actual)
|
||||
|
||||
|
|
@ -198,53 +198,53 @@ internal class DefaultMultiStakingBalanceProducerTest {
|
|||
@Test
|
||||
fun `test that StakeKit and P2P balances are combined`() = runTest {
|
||||
val stakeKitBalances = createStakeKitBalances()
|
||||
val p2pBalances = createP2PBalances()
|
||||
val p2pEthPoolBalances = createP2PEthPoolBalances()
|
||||
|
||||
every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances)
|
||||
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(p2pBalances)
|
||||
every { stakeKitBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances)
|
||||
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(p2pEthPoolBalances)
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
// check after producer.produce()
|
||||
verify { stakingBalancesStore.get(params.userWalletId) }
|
||||
verify { p2pBalancesStore.get(params.userWalletId) }
|
||||
verify { stakeKitBalancesStore.get(params.userWalletId) }
|
||||
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
|
||||
|
||||
val values = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values.size).isEqualTo(1)
|
||||
Truth.assertThat(values.first()).isEqualTo(stakeKitBalances + p2pBalances)
|
||||
Truth.assertThat(values.first()).isEqualTo(stakeKitBalances + p2pEthPoolBalances)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test that P2P balances are updated independently from StakeKit`() = runTest {
|
||||
val stakeKitBalances = createStakeKitBalancesWithTonOnly()
|
||||
val p2pFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2)
|
||||
val p2pEthPoolFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2)
|
||||
|
||||
every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances)
|
||||
every { p2pBalancesStore.get(params.userWalletId) } returns p2pFlow
|
||||
every { stakeKitBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances)
|
||||
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns p2pEthPoolFlow
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
// check after producer.produce()
|
||||
verify { stakingBalancesStore.get(params.userWalletId) }
|
||||
verify { p2pBalancesStore.get(params.userWalletId) }
|
||||
verify { stakeKitBalancesStore.get(params.userWalletId) }
|
||||
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
|
||||
|
||||
// first emit - empty P2P
|
||||
p2pFlow.emit(emptySet())
|
||||
// first emit - empty P2PEthPool
|
||||
p2pEthPoolFlow.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)
|
||||
// second emit - with P2PEthPool balance
|
||||
val p2pEthPoolBalances = createP2PEthPoolBalances()
|
||||
p2pEthPoolFlow.emit(p2pEthPoolBalances)
|
||||
|
||||
val values2 = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values2.size).isEqualTo(2)
|
||||
Truth.assertThat(values2.last()).isEqualTo(stakeKitBalances + p2pBalances)
|
||||
Truth.assertThat(values2.last()).isEqualTo(stakeKitBalances + p2pEthPoolBalances)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
|
@ -255,7 +255,7 @@ internal class DefaultMultiStakingBalanceProducerTest {
|
|||
address = "0x1",
|
||||
)
|
||||
val p2pEthereumId = StakingID(
|
||||
integrationId = StakingIntegrationID.P2P.EthereumPooled.value,
|
||||
integrationId = StakingIntegrationID.P2PEthPool.value,
|
||||
address = "0x5aa711F440Eb6d4361148bBD89d03464628ace84",
|
||||
)
|
||||
|
||||
|
|
@ -272,7 +272,7 @@ internal class DefaultMultiStakingBalanceProducerTest {
|
|||
)
|
||||
}
|
||||
|
||||
fun createP2PBalances(): Set<StakingBalance> {
|
||||
fun createP2PEthPoolBalances(): Set<StakingBalance> {
|
||||
return setOf(
|
||||
MockP2PEthPoolAccountResponseFactory.createWithBalance(stakingId = p2pEthereumId).toDomain(
|
||||
source = StatusSource.ACTUAL,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ internal class StakingBalancesStoreGetMethodTest {
|
|||
private val runtimeStore = RuntimeSharedStore<WalletIdWithStakingBalances>()
|
||||
private val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
|
||||
|
||||
private val store = DefaultStakingBalancesStore(
|
||||
private val store = DefaultStakeKitBalancesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ internal class StakingBalancesStoreInitializationTest {
|
|||
|
||||
every { persistenceStore.data } returns emptyFlow()
|
||||
|
||||
DefaultStakingBalancesStore(
|
||||
DefaultStakeKitBalancesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
|
|
@ -41,7 +41,7 @@ internal class StakingBalancesStoreInitializationTest {
|
|||
val runtimeStore = RuntimeSharedStore<WalletIdWithStakingBalances>()
|
||||
val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
|
||||
|
||||
DefaultStakingBalancesStore(
|
||||
DefaultStakeKitBalancesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
|
|
@ -63,7 +63,7 @@ internal class StakingBalancesStoreInitializationTest {
|
|||
}
|
||||
}
|
||||
|
||||
DefaultStakingBalancesStore(
|
||||
DefaultStakeKitBalancesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ internal class StakingBalancesStoreUpdateMethodsTest {
|
|||
private val runtimeStore = RuntimeSharedStore<WalletIdWithStakingBalances>()
|
||||
private val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
|
||||
|
||||
private val store = DefaultStakingBalancesStore(
|
||||
private val store = DefaultStakeKitBalancesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
|
|
|
|||
|
|
@ -1,55 +1,170 @@
|
|||
package com.tangem.data.staking.utils
|
||||
|
||||
import com.tangem.data.staking.store.StakingBalancesStore
|
||||
import arrow.core.right
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.data.staking.store.P2PEthPoolBalancesStore
|
||||
import com.tangem.data.staking.store.StakeKitBalancesStore
|
||||
import com.tangem.domain.models.staking.StakingID
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.staking.StakingIdFactory
|
||||
import com.tangem.domain.staking.model.StakingIntegrationID
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class DefaultStakingCleanerTest {
|
||||
|
||||
private val stakingBalancesStore = mockk<StakingBalancesStore>(relaxed = true)
|
||||
private val stakingIdFactory = mockk<StakingIdFactory>(relaxed = true)
|
||||
private val stakeKitBalancesStore = mockk<StakeKitBalancesStore>(relaxed = true)
|
||||
private val p2pEthPoolBalancesStore = mockk<P2PEthPoolBalancesStore>(relaxed = true)
|
||||
private val cleaner = DefaultStakingCleaner(
|
||||
stakingBalancesStore = stakingBalancesStore,
|
||||
stakingIdFactory = stakingIdFactory,
|
||||
stakeKitBalancesStore = stakeKitBalancesStore,
|
||||
p2pEthPoolBalancesStore = p2pEthPoolBalancesStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
private val userWalletId = UserWalletId("011")
|
||||
private val stakingIds = setOf(
|
||||
StakingID(integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, address = "0x1"),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(stakingBalancesStore)
|
||||
clearMocks(stakingIdFactory, stakeKitBalancesStore, p2pEthPoolBalancesStore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should clear yields balances when called`() = runTest {
|
||||
// Act
|
||||
cleaner(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class ClearByStakingIds {
|
||||
|
||||
// Assert
|
||||
coVerifyOrder {
|
||||
stakingBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
private val stakingIds = setOf(
|
||||
StakingID(integrationId = StakingIntegrationID.StakeKit.Coin.Cardano.value, address = "0x1"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `should clear yields balances when called`() = runTest {
|
||||
// Act
|
||||
cleaner(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
|
||||
// Assert
|
||||
coVerify {
|
||||
stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle empty stakingIds`() = runTest {
|
||||
// Act
|
||||
cleaner(userWalletId = userWalletId, stakingIds = emptySet())
|
||||
|
||||
// Assert
|
||||
coVerify(inverse = true) {
|
||||
stakeKitBalancesStore.clear(userWalletId = any(), stakingIds = any())
|
||||
p2pEthPoolBalancesStore.clear(userWalletId = any(), stakingIds = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should catch exception from stakingBalancesStore and not throw`() = runTest {
|
||||
// Arrange
|
||||
coEvery { stakeKitBalancesStore.clear(userWalletId, stakingIds) } throws Exception()
|
||||
|
||||
// Act
|
||||
cleaner(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
|
||||
// Assert
|
||||
coVerify {
|
||||
stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should catch exception from p2pEthPoolBalancesStore and not throw`() = runTest {
|
||||
// Arrange
|
||||
coEvery { p2pEthPoolBalancesStore.clear(userWalletId, stakingIds) } throws Exception()
|
||||
|
||||
// Act
|
||||
cleaner(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
|
||||
// Assert
|
||||
coVerify {
|
||||
stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = stakingIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle empty stakingIds`() = runTest {
|
||||
// Act
|
||||
cleaner(userWalletId = userWalletId, stakingIds = emptySet())
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class ClearByCurrencies {
|
||||
|
||||
// Assert
|
||||
coVerifyOrder(inverse = true) {
|
||||
stakingBalancesStore.clear(userWalletId = any(), stakingIds = any())
|
||||
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
|
||||
private val coin = cryptoCurrencyFactory.ethereum
|
||||
|
||||
@Test
|
||||
fun `should clear yields balances when called with single currency`() = runTest {
|
||||
// Arrange
|
||||
val stakingId = StakingID(
|
||||
integrationId = "stake_kit_coin_eth",
|
||||
address = "0xabc",
|
||||
)
|
||||
|
||||
coEvery { stakingIdFactory.create(userWalletId, coin) } returns stakingId.right()
|
||||
|
||||
// Act
|
||||
cleaner(userWalletId = userWalletId, currency = coin)
|
||||
|
||||
// Assert
|
||||
coVerify {
|
||||
stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = coin)
|
||||
stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId))
|
||||
p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle empty list of currencies`() = runTest {
|
||||
// Act
|
||||
cleaner(userWalletId = userWalletId, currencies = emptyList())
|
||||
|
||||
// Assert
|
||||
coVerify(inverse = true) {
|
||||
stakingIdFactory.create(userWalletId = any(), cryptoCurrency = any())
|
||||
stakeKitBalancesStore.clear(userWalletId = any(), stakingIds = any())
|
||||
p2pEthPoolBalancesStore.clear(userWalletId = any(), stakingIds = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should catch exception from stakingBalancesStore and not throw`() = runTest {
|
||||
// Arrange
|
||||
val stakingId = StakingID(
|
||||
integrationId = "stake_kit_coin_eth",
|
||||
address = "0xabc",
|
||||
)
|
||||
|
||||
coEvery { stakingIdFactory.create(userWalletId, coin) } returns stakingId.right()
|
||||
coEvery {
|
||||
stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId))
|
||||
} throws Exception()
|
||||
|
||||
// Act
|
||||
cleaner(userWalletId = userWalletId, currency = coin)
|
||||
|
||||
// Assert
|
||||
coVerify {
|
||||
stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = coin)
|
||||
stakeKitBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId))
|
||||
p2pEthPoolBalancesStore.clear(userWalletId = userWalletId, stakingIds = setOf(stakingId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -75,11 +75,11 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
|
|||
swapTxType = swapTxType,
|
||||
)
|
||||
|
||||
val providers = expressRepository.getProviders(
|
||||
val expressProviders = expressRepository.getFilteredProviders(
|
||||
userWallet = userWallet,
|
||||
filterProviderTypes = filterProviderTypes,
|
||||
)
|
||||
val expressProviders = providers.associateBy(ExpressProvider::providerId)
|
||||
swapTxType = swapTxType,
|
||||
).associateBy(ExpressProvider::providerId)
|
||||
|
||||
allPairs.map { pair ->
|
||||
async {
|
||||
|
|
@ -125,11 +125,11 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
|
|||
swapTxType = swapTxType,
|
||||
)
|
||||
|
||||
val providers = expressRepository.getProviders(
|
||||
val mappedProviders = expressRepository.getFilteredProviders(
|
||||
userWallet = userWallet,
|
||||
filterProviderTypes = filterProviderTypes,
|
||||
)
|
||||
val mappedProviders = providers.associateBy(ExpressProvider::providerId)
|
||||
swapTxType = swapTxType,
|
||||
).associateBy(ExpressProvider::providerId)
|
||||
|
||||
allPairs.map { pair ->
|
||||
async {
|
||||
|
|
@ -434,4 +434,20 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
|
|||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ExpressRepository.getFilteredProviders(
|
||||
userWallet: UserWallet,
|
||||
filterProviderTypes: List<ExpressProviderType>,
|
||||
swapTxType: SwapTxType,
|
||||
): List<ExpressProvider> {
|
||||
return getProviders(
|
||||
userWallet = userWallet,
|
||||
filterProviderTypes = filterProviderTypes,
|
||||
).let { allProviders ->
|
||||
when (swapTxType) {
|
||||
SwapTxType.SendWithSwap -> allProviders.filterNot { it.isExchangeOnlyWithinSingleAddress }
|
||||
SwapTxType.Swap -> allProviders
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import com.tangem.domain.models.network.Network
|
|||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.swap.models.SwapTransactionListModel
|
||||
import com.tangem.lib.crypto.derivation.AccountNodeRecognizer
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class SavedSwapTransactionListConverter(
|
||||
|
|
@ -132,7 +133,7 @@ internal class SavedSwapTransactionListConverter(
|
|||
blockchain = blockchain,
|
||||
extraDerivationPath = token.derivationPath,
|
||||
userWallet = userWallet,
|
||||
accountIndex = token.accountId?.toIntOrNull()?.let { DerivationIndex(it).getOrNull() },
|
||||
accountIndex = token.getDerivationIndex(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -150,6 +151,10 @@ internal class SavedSwapTransactionListConverter(
|
|||
}
|
||||
|
||||
private fun UserTokensResponse.Token.getDerivationIndex(): DerivationIndex? {
|
||||
return accountId?.toIntOrNull()?.let { DerivationIndex(it).getOrNull() }
|
||||
val blockchain = Blockchain.fromNetworkId(networkId) ?: return null
|
||||
val accountNodeRecognizer = AccountNodeRecognizer(blockchain)
|
||||
return derivationPath
|
||||
?.let { accountNodeRecognizer.recognize(it) }
|
||||
?.let { DerivationIndex(it.toInt()).getOrNull() }
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,10 @@ dependencies {
|
|||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region Project - Features API
|
||||
implementation(projects.features.sendV2.api)
|
||||
// endregion
|
||||
|
||||
// region Tangem SDKs
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
|||
import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository
|
||||
import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.features.send.v2.api.SendFeatureToggles
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
|
@ -72,10 +73,12 @@ internal object TokensDataModule {
|
|||
fun provideCurrencyChecksRepository(
|
||||
walletManagersFacade: WalletManagersFacade,
|
||||
coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
||||
sendFeatureToggles: SendFeatureToggles,
|
||||
): CurrencyChecksRepository {
|
||||
return DefaultCurrencyChecksRepository(
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
coroutineDispatchers = coroutineDispatcherProvider,
|
||||
sendFeatureToggles = sendFeatureToggles,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.tangem.data.tokens.repository
|
||||
|
||||
import com.tangem.blockchain.blockchains.ethereum.eip1559.isGaslessTxSupported
|
||||
import com.tangem.blockchain.blockchains.polkadot.ExistentialDepositProvider
|
||||
import com.tangem.blockchain.common.FeeResourceAmountProvider
|
||||
import com.tangem.blockchain.common.MinimumSendAmountProvider
|
||||
import com.tangem.blockchain.common.ReserveAmountProvider
|
||||
import com.tangem.blockchain.common.UtxoAmountLimitProvider
|
||||
import com.tangem.blockchain.common.*
|
||||
import com.tangem.data.tokens.converters.UtxoConverter
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
|
|
@ -17,6 +15,7 @@ import com.tangem.domain.tokens.model.blockchains.UtxoAmountLimit
|
|||
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
|
||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.features.send.v2.api.SendFeatureToggles
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.extensions.isZero
|
||||
import com.tangem.utils.extensions.orZero
|
||||
|
|
@ -26,6 +25,7 @@ import java.math.BigDecimal
|
|||
internal class DefaultCurrencyChecksRepository(
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val coroutineDispatchers: CoroutineDispatcherProvider,
|
||||
private val sendFeatureToggles: SendFeatureToggles,
|
||||
) : CurrencyChecksRepository {
|
||||
|
||||
override suspend fun getExistentialDeposit(userWalletId: UserWalletId, network: Network): BigDecimal? {
|
||||
|
|
@ -66,6 +66,12 @@ internal class DefaultCurrencyChecksRepository(
|
|||
}
|
||||
}
|
||||
|
||||
override fun isNetworkSupportedForGaslessTx(network: Network): Boolean {
|
||||
if (!sendFeatureToggles.isGaslessTransactionsEnabled) return false
|
||||
val blockchain = Blockchain.fromId(network.rawId)
|
||||
return blockchain.isGaslessTxSupported
|
||||
}
|
||||
|
||||
override suspend fun getFeeResourceAmount(userWalletId: UserWalletId, network: Network): CurrencyAmount? {
|
||||
val manager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWalletId,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ dependencies {
|
|||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Common */
|
||||
implementation(projects.data.common)
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.domain.legacy)
|
||||
|
|
@ -34,6 +37,9 @@ dependencies {
|
|||
implementation(projects.domain.transaction)
|
||||
implementation(projects.domain.demo)
|
||||
|
||||
/** Api */
|
||||
implementation(projects.features.sendV2.api)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
package com.tangem.data.transaction
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter
|
||||
import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import com.tangem.features.send.v2.api.SendFeatureToggles
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigInteger
|
||||
|
||||
class DefaultGaslessTransactionRepository(
|
||||
private val gaslessTxServiceApi: GaslessTxServiceApi,
|
||||
private val coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
private val sendFeatureToggles: SendFeatureToggles,
|
||||
) : GaslessTransactionRepository {
|
||||
|
||||
private val supportedTokensState = MutableStateFlow<Map<Network.ID, Set<CryptoCurrency>>>(hashMapOf())
|
||||
private val allFeeRecipientAddress = mutableSetOf<String>()
|
||||
private val addressesMutex = Mutex()
|
||||
|
||||
private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder()
|
||||
private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter()
|
||||
|
||||
override suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency> {
|
||||
return withContext(coroutineDispatcherProvider.io) {
|
||||
val storedTokens = supportedTokensState.value[network.id]
|
||||
if (storedTokens != null && storedTokens.isNotEmpty()) {
|
||||
return@withContext storedTokens
|
||||
}
|
||||
|
||||
val supportedTokensData = gaslessTxServiceApi.getSupportedTokens().getOrThrow()
|
||||
if (supportedTokensData.isSuccess) {
|
||||
val networkBlockchain = Blockchain.fromId(network.rawId)
|
||||
val supportedTokens = supportedTokensData.result.tokens
|
||||
.filter {
|
||||
it.chainId == networkBlockchain.getChainId()
|
||||
}
|
||||
.map { token ->
|
||||
responseCryptoCurrenciesFactory.createToken(
|
||||
blockchain = networkBlockchain,
|
||||
sdkToken = Token(
|
||||
contractAddress = token.tokenAddress,
|
||||
name = token.tokenName,
|
||||
symbol = token.tokenSymbol,
|
||||
decimals = token.decimals,
|
||||
),
|
||||
network = network,
|
||||
)
|
||||
}.toSet()
|
||||
// update local cache
|
||||
supportedTokensState.update { current ->
|
||||
val newMap = current.toMutableMap()
|
||||
newMap[network.id] = supportedTokens
|
||||
newMap
|
||||
}
|
||||
return@withContext supportedTokens
|
||||
} else {
|
||||
error("Gasless service returned unsuccessful response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getTokenFeeReceiverAddress(): String {
|
||||
return withContext(coroutineDispatcherProvider.io) {
|
||||
val response = gaslessTxServiceApi.getFeeRecipient().getOrThrow()
|
||||
if (response.isSuccess) {
|
||||
response.result.address
|
||||
} else {
|
||||
error("Gasless service returned unsuccessful response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun signGaslessTransaction(
|
||||
gaslessTransactionData: GaslessTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
network: Network,
|
||||
eip7702Auth: Eip7702Authorization?,
|
||||
): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) {
|
||||
val blockchain = Blockchain.fromId(network.rawId)
|
||||
val transactionRequest = gaslessTransactionRequestBuilder.build(
|
||||
gaslessTransaction = gaslessTransactionData,
|
||||
signature = signature,
|
||||
userAddress = userAddress,
|
||||
chainId = blockchain.getChainId() ?: error("ChainId is null for blockchain: $blockchain"),
|
||||
eip7702Auth = eip7702Auth,
|
||||
)
|
||||
|
||||
val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow()
|
||||
|
||||
if (!response.isSuccess) {
|
||||
error("Gasless service returned unsuccessful response")
|
||||
}
|
||||
|
||||
// Convert DTO to domain model
|
||||
signedTransactionResultConverter.convert(response.result)
|
||||
}
|
||||
|
||||
override fun getBaseGasForTransaction(): BigInteger {
|
||||
return BASE_GAS_FOR_TRANSACTION
|
||||
}
|
||||
|
||||
override fun getChainIdForNetwork(network: Network): Int {
|
||||
val networkBlockchain = Blockchain.fromId(network.rawId)
|
||||
return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}")
|
||||
}
|
||||
|
||||
override suspend fun getGaslessFeeAddresses(): Set<String> {
|
||||
if (!sendFeatureToggles.isGaslessTransactionsEnabled) {
|
||||
return EMPTY_ADDRESSES
|
||||
}
|
||||
return addressesMutex.withLock {
|
||||
allFeeRecipientAddress.ifEmpty {
|
||||
val allFeeAddresses = getAllFeeRecipientAddresses()
|
||||
allFeeRecipientAddress.addAll(allFeeAddresses)
|
||||
allFeeRecipientAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getAllFeeRecipientAddresses(): Set<String> {
|
||||
// TODO Replace with other backend call to get all fee recipient addresses when available
|
||||
return setOf(getTokenFeeReceiverAddress())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000")
|
||||
val EMPTY_ADDRESSES = emptySet<String>()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package com.tangem.data.transaction
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import java.math.BigInteger
|
||||
|
||||
class MockedGaslessTransactionRepository(
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
) : GaslessTransactionRepository {
|
||||
|
||||
override suspend fun getSupportedTokens(network: Network): Set<CryptoCurrency> {
|
||||
val usdcPolygon = responseCryptoCurrenciesFactory.createToken(
|
||||
blockchain = Blockchain.Polygon,
|
||||
sdkToken = Token(
|
||||
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
||||
name = "Tether",
|
||||
symbol = "USDT",
|
||||
decimals = 6,
|
||||
),
|
||||
network = network,
|
||||
)
|
||||
return setOf(usdcPolygon)
|
||||
}
|
||||
|
||||
override fun getChainIdForNetwork(network: Network): Int {
|
||||
val networkBlockchain = Blockchain.fromId(network.rawId)
|
||||
return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}")
|
||||
}
|
||||
|
||||
override suspend fun getGaslessFeeAddresses(): Set<String> {
|
||||
return setOf("0x2bD8AA05a92CcDaeDE5d42EB7D196ed07F8F8EF3")
|
||||
}
|
||||
|
||||
override suspend fun getTokenFeeReceiverAddress(): String {
|
||||
return TOKEN_RECEIVER_ADDRESS
|
||||
}
|
||||
|
||||
override suspend fun signGaslessTransaction(
|
||||
gaslessTransactionData: GaslessTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
network: Network,
|
||||
eip7702Auth: Eip7702Authorization?,
|
||||
): GaslessSignedTransactionResult = GaslessSignedTransactionResult(
|
||||
txHash = "0x000",
|
||||
)
|
||||
|
||||
override fun getBaseGasForTransaction(): BigInteger {
|
||||
return BASE_GAS_FOR_TRANSACTION
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val TOKEN_RECEIVER_ADDRESS = "0x"
|
||||
|
||||
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000")
|
||||
val SUPPORTED_BLOCKCHAINS = arrayOf(
|
||||
Blockchain.Ethereum,
|
||||
Blockchain.BSC,
|
||||
Blockchain.Base,
|
||||
Blockchain.Polygon,
|
||||
Blockchain.Arbitrum,
|
||||
Blockchain.XDC,
|
||||
Blockchain.Optimism,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO
|
||||
import com.tangem.domain.transaction.models.GaslessSignedTransactionResult
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
/**
|
||||
* Converts DTO GaslessSignedTransactionResult from API to domain model.
|
||||
* Transforms string representations of gas parameters to BigInteger for type safety.
|
||||
*/
|
||||
class GaslessSignedTransactionResultConverter :
|
||||
Converter<GaslessSignedTransactionResultDTO, GaslessSignedTransactionResult> {
|
||||
|
||||
override fun convert(value: GaslessSignedTransactionResultDTO): GaslessSignedTransactionResult {
|
||||
return GaslessSignedTransactionResult(
|
||||
txHash = value.txHash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest
|
||||
import com.tangem.domain.transaction.models.Eip7702Authorization
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO
|
||||
|
||||
/**
|
||||
* Builder for creating complete GaslessTransactionRequest from domain model.
|
||||
* Combines transaction data with signature and user information.
|
||||
*/
|
||||
class GaslessTransactionRequestBuilder(
|
||||
private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(),
|
||||
) {
|
||||
|
||||
/**
|
||||
* Creates complete gasless transaction request.
|
||||
*
|
||||
* @param gaslessTransaction domain model of transaction
|
||||
* @param signature transaction signature in hex format (with 0x prefix)
|
||||
* @param userAddress user's Ethereum address
|
||||
* @param chainId blockchain network chain ID
|
||||
* @param eip7702Auth optional EIP-7702 authorization for account abstraction
|
||||
* @return complete request ready for API submission
|
||||
*/
|
||||
fun build(
|
||||
gaslessTransaction: GaslessTransactionData,
|
||||
signature: String,
|
||||
userAddress: String,
|
||||
chainId: Int,
|
||||
eip7702Auth: Eip7702Authorization? = null,
|
||||
): GaslessTransactionRequest {
|
||||
return GaslessTransactionRequest(
|
||||
gaslessTransaction = converter.convert(gaslessTransaction),
|
||||
signature = signature,
|
||||
userAddress = userAddress,
|
||||
chainId = chainId,
|
||||
eip7702Auth = eip7702Auth?.toDTO(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts domain Eip7702Authorization to DTO.
|
||||
*/
|
||||
private fun Eip7702Authorization.toDTO(): Eip7702AuthorizationDTO {
|
||||
return Eip7702AuthorizationDTO(
|
||||
chainId = chainId,
|
||||
address = address,
|
||||
nonce = nonce.toString(),
|
||||
yParity = yParity,
|
||||
r = r,
|
||||
s = s,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.tangem.data.transaction.convertes
|
||||
|
||||
import com.tangem.blockchain.extensions.formatHex
|
||||
import com.tangem.datasource.api.gasless.models.FeeData
|
||||
import com.tangem.datasource.api.gasless.models.TransactionData
|
||||
import com.tangem.domain.transaction.models.GaslessTransactionData
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.toHexString
|
||||
import com.tangem.datasource.api.gasless.models.GaslessTransactionData as GaslessTransactionDataDTO
|
||||
|
||||
/**
|
||||
* Converts domain GaslessTransactionData to DTO for API requests.
|
||||
* Note: This converter only handles the transaction data conversion.
|
||||
* Additional fields (signature, userAddress, chainId) must be added separately
|
||||
* to create complete GaslessTransactionRequest.
|
||||
*/
|
||||
class GaslessTxDataToGaslessRequestConverter : Converter<GaslessTransactionData, GaslessTransactionDataDTO> {
|
||||
|
||||
override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO {
|
||||
return GaslessTransactionDataDTO(
|
||||
transaction = convertTransaction(value.transaction),
|
||||
fee = convertFee(value.fee),
|
||||
nonce = value.nonce.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertTransaction(transaction: GaslessTransactionData.Transaction): TransactionData {
|
||||
return TransactionData(
|
||||
to = transaction.to,
|
||||
value = transaction.value.toString(),
|
||||
data = transaction.data.toHexString().formatHex(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertFee(fee: GaslessTransactionData.Fee): FeeData {
|
||||
return FeeData(
|
||||
feeToken = fee.feeToken,
|
||||
maxTokenFee = fee.maxTokenFee.toString(),
|
||||
coinPriceInToken = fee.coinPriceInToken.toString(),
|
||||
feeTransferGasLimit = fee.feeTransferGasLimit.toString(),
|
||||
baseGas = fee.baseGas.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,22 @@
|
|||
package com.tangem.data.transaction.di
|
||||
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.data.transaction.DefaultFeeRepository
|
||||
import com.tangem.data.transaction.DefaultGaslessTransactionRepository
|
||||
import com.tangem.data.transaction.DefaultTransactionRepository
|
||||
import com.tangem.data.transaction.DefaultWalletAddressServiceRepository
|
||||
import com.tangem.data.transaction.error.DefaultFeeErrorResolver
|
||||
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.walletmanager.WalletManagersStore
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
import com.tangem.domain.transaction.FeeRepository
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.TransactionRepository
|
||||
import com.tangem.domain.transaction.WalletAddressServiceRepository
|
||||
import com.tangem.domain.transaction.error.FeeErrorResolver
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.features.send.v2.api.SendFeatureToggles
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
|
@ -65,4 +70,20 @@ internal object TransactionDataModule {
|
|||
fun providerFeeErrorResolver(): FeeErrorResolver {
|
||||
return DefaultFeeErrorResolver()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGaslessTransactionRepository(
|
||||
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
gaslessTxServiceApi: GaslessTxServiceApi,
|
||||
coroutineDispatcherProvider: CoroutineDispatcherProvider,
|
||||
sendFeatureToggles: SendFeatureToggles,
|
||||
): GaslessTransactionRepository {
|
||||
return DefaultGaslessTransactionRepository(
|
||||
gaslessTxServiceApi = gaslessTxServiceApi,
|
||||
coroutineDispatcherProvider = coroutineDispatcherProvider,
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
sendFeatureToggles = sendFeatureToggles,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,9 @@ import java.util.concurrent.ConcurrentHashMap
|
|||
import javax.inject.Inject
|
||||
|
||||
private const val VALID_STATUS = "valid"
|
||||
private const val APPROVED_KYC_STATUS = "APPROVED"
|
||||
private const val APPROVED_KYC_STATUS = "approved"
|
||||
private const val IN_PROGRESS_KYC_STATUS = "in_progress"
|
||||
private const val DECLINED_KYC_STATUS = "declined"
|
||||
private const val TAG = "TangemPay: OnboardingRepository"
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
|
|
@ -168,8 +170,9 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
ProductInstance(id = instance.id, cardId = instance.cardId, cardFrozenState = cardFrozenState)
|
||||
}
|
||||
return CustomerInfo(
|
||||
customerId = response?.id,
|
||||
productInstance = productInstance,
|
||||
isKycApproved = response?.kyc?.status == APPROVED_KYC_STATUS,
|
||||
kycStatus = getKycStatus(status = response?.kyc?.status),
|
||||
cardInfo = cardInfo,
|
||||
).also {
|
||||
lastFetchedCustomerInfoMap[userWalletId] = it
|
||||
|
|
@ -182,11 +185,8 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
return Either.Right(hasTangemPay)
|
||||
}
|
||||
|
||||
return requestHelper.performWithStaticToken { staticToken ->
|
||||
tangemPayApi.checkCustomerWalletId(
|
||||
authHeader = staticToken,
|
||||
customerWalletId = userWalletId.stringValue,
|
||||
)
|
||||
return requestHelper.performWithStaticToken {
|
||||
tangemPayApi.checkCustomerWalletId(customerWalletId = userWalletId.stringValue)
|
||||
}.map { response ->
|
||||
val id = response.result?.id
|
||||
val isTangemPayEnabled = response.result?.isTangemPayEnabled == true
|
||||
|
|
@ -202,7 +202,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
}
|
||||
|
||||
override suspend fun checkCustomerEligibility(): Boolean {
|
||||
val response = requestHelper.performWithoutToken {
|
||||
val response = requestHelper.performWithStaticToken {
|
||||
tangemPayApi.checkCustomerEligibility()
|
||||
}.getOrNull()
|
||||
|
||||
|
|
@ -236,4 +236,13 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
setHideMainOnboardingBanner(userWalletId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getKycStatus(status: String?): CustomerInfo.KycStatus {
|
||||
return when (status?.lowercase()) {
|
||||
IN_PROGRESS_KYC_STATUS -> CustomerInfo.KycStatus.PENDING
|
||||
DECLINED_KYC_STATUS -> CustomerInfo.KycStatus.REJECTED
|
||||
APPROVED_KYC_STATUS -> CustomerInfo.KycStatus.APPROVED
|
||||
else -> CustomerInfo.KycStatus.INIT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,6 @@ import com.tangem.datasource.api.common.response.ApiResponse
|
|||
import com.tangem.datasource.api.pay.TangemPayAuthApi
|
||||
import com.tangem.datasource.api.pay.models.request.RefreshCustomerWalletAccessTokenRequest
|
||||
import com.tangem.datasource.api.pay.models.response.TangemPayGetTokensResponse
|
||||
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
|
||||
import com.tangem.datasource.local.visa.TangemPayStorage
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
|
|
@ -32,7 +31,6 @@ private const val TAG = "TangemPayRequestPerformer"
|
|||
@Singleton
|
||||
internal class TangemPayRequestPerformer @Inject constructor(
|
||||
private val errorConverter: TangemPayErrorConverter,
|
||||
private val environmentConfigStorage: EnvironmentConfigStorage,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val tangemPayAuthApi: TangemPayAuthApi,
|
||||
private val tangemPayStorage: TangemPayStorage,
|
||||
|
|
@ -41,23 +39,10 @@ internal class TangemPayRequestPerformer @Inject constructor(
|
|||
private val customerWalletAddresses = ConcurrentHashMap<UserWalletId, String>()
|
||||
private val tokensMutex = Mutex()
|
||||
|
||||
suspend fun <T : Any> performWithStaticToken(
|
||||
requestBlock: suspend (header: String) -> ApiResponse<T>,
|
||||
): Either<VisaApiError, T> = withContext(dispatchers.io) {
|
||||
catch(
|
||||
block = {
|
||||
val staticToken =
|
||||
environmentConfigStorage.getConfigSync().bffStaticToken ?: error("BFF static token is null")
|
||||
when (val apiResponse = requestBlock(staticToken)) {
|
||||
is ApiResponse.Error -> errorConverter.convert(apiResponse.cause).left()
|
||||
is ApiResponse.Success<T> -> apiResponse.data.right()
|
||||
}
|
||||
},
|
||||
catch = { errorConverter.convert(it).left() },
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun <T : Any> performWithoutToken(requestBlock: suspend () -> ApiResponse<T>): Either<VisaApiError, T> =
|
||||
/**
|
||||
* Static token added in headers [com.tangem.datasource.api.common.config.TangemPay]
|
||||
*/
|
||||
suspend fun <T : Any> performWithStaticToken(requestBlock: suspend () -> ApiResponse<T>): Either<VisaApiError, T> =
|
||||
withContext(dispatchers.io) {
|
||||
catch(
|
||||
block = {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@
|
|||
<ID>MultilineLambdaItParameter:WcEthMessageSignUseCase.kt$LegacySdkHelper${ val char = it.toInt().toChar() if (char.isAscii()) char else return null }</ID>
|
||||
<ID>MultilineLambdaItParameter:WcEthNetwork.kt$WcEthNetwork${ if (this == WcEthMethodName.AddEthereumChain) { WcEthMethod.AddEthereumChain(rawChain = it).right() } else { WcEthMethod.SwitchEthereumChain(rawChain = it).right() } }</ID>
|
||||
<ID>MultilineLambdaItParameter:WcEthNetwork.kt$WcEthNetwork${ if (this == WcEthMethodName.SignTransaction) { WcEthMethod.SignTransaction(transaction = it).right() } else { WcEthMethod.SendTransaction(transaction = it).right() } }</ID>
|
||||
<ID>MultilineLambdaItParameter:WcNetworksConverter.kt$WcNetworksConverter${ val walletAddress = walletManagersFacade.getDefaultAddress(wallet.walletId, it) walletAddress?.lowercase() == caip10.accountAddress.lowercase() }</ID>
|
||||
<ID>MultilineLambdaItParameter:WcPairSdkDelegate.kt$WcPairSdkDelegate${ proposalCallback.cancel() return@coroutineScope it.left() }</ID>
|
||||
<ID>MultilineLambdaItParameter:WcPairSdkDelegate.kt$WcPairSdkDelegate${ store.removePendingApproval(forSave) it.left() }</ID>
|
||||
<ID>MultilineLambdaItParameter:WcSdkSessionConverter.kt$WcSdkSessionConverter${ WcAppMetaDataConverter.convert( value = WcAppMetaDataConverter.Input( originUrl = value.originUrl, peerMetaData = it, ), ) }</ID>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ dependencies {
|
|||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.demo)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.transaction)
|
||||
api(projects.domain.models)
|
||||
|
||||
/** Domain models */
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import com.tangem.domain.models.network.Network
|
|||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.GaslessTransactionRepository
|
||||
import com.tangem.domain.transaction.models.AssetRequirementsCondition
|
||||
import com.tangem.domain.txhistory.models.PaginationWrapper
|
||||
import com.tangem.domain.txhistory.models.TxHistoryState
|
||||
|
|
@ -61,6 +62,7 @@ internal class DefaultWalletManagersFacade @Inject constructor(
|
|||
private val userWalletsStore: UserWalletsStore,
|
||||
private val assetLoader: AssetLoader,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val gaslessTransactionRepository: GaslessTransactionRepository,
|
||||
blockchainSDKFactory: BlockchainSDKFactory,
|
||||
) : WalletManagersFacade {
|
||||
|
||||
|
|
@ -284,6 +286,7 @@ internal class DefaultWalletManagersFacade @Inject constructor(
|
|||
items = SdkTransactionHistoryItemConverter(
|
||||
smartContractMethods = readSmartContractMethods(),
|
||||
yieldSupplyAddresses = YIELD_SUPPLY_ADDRESSES,
|
||||
gaslessFeeAddresses = gaslessTransactionRepository.getGaslessFeeAddresses(),
|
||||
).convertList(itemsResult.data.items),
|
||||
)
|
||||
is Result.Failure -> error(itemsResult.error.message ?: itemsResult.error.customMessage)
|
||||
|
|
@ -313,13 +316,13 @@ internal class DefaultWalletManagersFacade @Inject constructor(
|
|||
return UpdateWalletManagerResult.Unreachable()
|
||||
}
|
||||
|
||||
updateWalletManagerTokensIfNeeded(walletManager, extraTokens)
|
||||
val isUpdated = updateWalletManagerTokensIfNeeded(walletManager, extraTokens)
|
||||
|
||||
return try {
|
||||
if (userWallet is UserWallet.Cold && demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)) {
|
||||
updateDemoWalletManager(walletManager)
|
||||
} else {
|
||||
updateWalletManager(walletManager)
|
||||
updateWalletManager(walletManager = walletManager, forceUpdate = isUpdated)
|
||||
}
|
||||
} finally {
|
||||
walletManagersStore.store(userWallet.walletId, walletManager)
|
||||
|
|
@ -333,9 +336,12 @@ internal class DefaultWalletManagersFacade @Inject constructor(
|
|||
return resultFactory.getDemoResult(walletManager, amount)
|
||||
}
|
||||
|
||||
private suspend fun updateWalletManager(walletManager: WalletManager): UpdateWalletManagerResult {
|
||||
private suspend fun updateWalletManager(
|
||||
walletManager: WalletManager,
|
||||
forceUpdate: Boolean,
|
||||
): UpdateWalletManagerResult {
|
||||
return try {
|
||||
walletManager.update()
|
||||
walletManager.update(forceUpdate)
|
||||
|
||||
resultFactory.getResult(walletManager)
|
||||
} catch (e: BlockchainSdkError.AccountNotFound) {
|
||||
|
|
@ -765,14 +771,19 @@ internal class DefaultWalletManagersFacade @Inject constructor(
|
|||
.joinToString(separator = "|")
|
||||
}
|
||||
|
||||
private fun updateWalletManagerTokensIfNeeded(walletManager: WalletManager, tokens: Set<CryptoCurrency.Token>) {
|
||||
if (tokens.isEmpty()) return
|
||||
private fun updateWalletManagerTokensIfNeeded(
|
||||
walletManager: WalletManager,
|
||||
tokens: Set<CryptoCurrency.Token>,
|
||||
): Boolean {
|
||||
if (tokens.isEmpty()) return false
|
||||
|
||||
val tokensToAdd = sdkTokenConverter
|
||||
.convertList(tokens)
|
||||
.filter { it !in walletManager.cardTokens }
|
||||
|
||||
walletManager.addTokens(tokensToAdd)
|
||||
|
||||
return tokensToAdd.isNotEmpty()
|
||||
}
|
||||
|
||||
private suspend fun readSmartContractMethods(): Map<String, SmartContractMethod> {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ import com.tangem.blockchain.transactionhistory.models.TransactionHistoryItem as
|
|||
internal class SdkTransactionHistoryItemConverter(
|
||||
smartContractMethods: Map<String, SmartContractMethod>,
|
||||
yieldSupplyAddresses: Set<String>,
|
||||
gaslessFeeAddresses: Set<String>,
|
||||
) : Converter<SdkTransactionHistoryItem, TxInfo> {
|
||||
|
||||
private val typeConverter by lazy { SdkTransactionTypeConverter(
|
||||
smartContractMethods = smartContractMethods,
|
||||
yieldSupplyAddresses = yieldSupplyAddresses,
|
||||
gaslessFeeAddresses = gaslessFeeAddresses,
|
||||
) }
|
||||
|
||||
override fun convert(value: SdkTransactionHistoryItem): TxInfo = TxInfo(
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@ import com.tangem.utils.converter.Converter
|
|||
internal class SdkTransactionTypeConverter(
|
||||
private val smartContractMethods: Map<String, SmartContractMethod>,
|
||||
private val yieldSupplyAddresses: Set<String>,
|
||||
gaslessFeeAddresses: Set<String>,
|
||||
) : Converter<TransactionHistoryItem, TxInfo.TransactionType> {
|
||||
|
||||
private val gaslessFeeAddressesLowercase: Set<String> = gaslessFeeAddresses.map { it.lowercase() }.toSet()
|
||||
|
||||
override fun convert(value: TransactionHistoryItem): TxInfo.TransactionType {
|
||||
val (type, destination) = value.type to value.destinationType
|
||||
val source = value.sourceType
|
||||
|
|
@ -71,10 +74,12 @@ internal class SdkTransactionTypeConverter(
|
|||
"buyVoucher",
|
||||
"buyVoucherPOL",
|
||||
"delegate",
|
||||
"pooledStake",
|
||||
-> TxInfo.TransactionType.Staking.Stake
|
||||
"sellVoucher_new",
|
||||
"sellVoucher_newPOL",
|
||||
"undelegate",
|
||||
"pooledUnstake",
|
||||
-> TxInfo.TransactionType.Staking.Unstake
|
||||
"unstakeClaimTokens_new",
|
||||
"unstakeClaimTokens_newPOL",
|
||||
|
|
@ -123,8 +128,23 @@ internal class SdkTransactionTypeConverter(
|
|||
)
|
||||
}
|
||||
"supplyTopUp" -> TxInfo.TransactionType.YieldSupply.Topup
|
||||
"gaslessTransaction" -> getTypeForGaslessMethod(destination)
|
||||
null -> TxInfo.TransactionType.UnknownOperation
|
||||
else -> TxInfo.TransactionType.Operation(name = methodName.replaceFirstChar { it.titlecase() })
|
||||
} ?: TxInfo.TransactionType.Operation(name = methodName?.replaceFirstChar { it.titlecase() }.orEmpty())
|
||||
}
|
||||
|
||||
private fun getTypeForGaslessMethod(destination: TransactionHistoryItem.DestinationType): TxInfo.TransactionType {
|
||||
return when (destination) {
|
||||
is TransactionHistoryItem.DestinationType.Multiple -> TxInfo.TransactionType.UnknownOperation
|
||||
is TransactionHistoryItem.DestinationType.Single -> {
|
||||
val address = destination.addressType.address.lowercase()
|
||||
if (gaslessFeeAddressesLowercase.contains(address)) {
|
||||
TxInfo.TransactionType.GaslessFee
|
||||
} else {
|
||||
TxInfo.TransactionType.UnknownOperation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.yield.supply
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.TransactionStatus
|
||||
import com.tangem.blockchain.yieldsupply.YieldSupplyProvider
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
|
|
@ -18,20 +19,16 @@ import com.tangem.datasource.local.preferences.utils.get
|
|||
import com.tangem.datasource.local.preferences.utils.store
|
||||
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.YieldSupplyRepository
|
||||
import com.tangem.domain.yield.supply.models.YieldMarketToken
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyEnterStatus
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
internal class DefaultYieldSupplyRepository(
|
||||
|
|
@ -43,7 +40,7 @@ internal class DefaultYieldSupplyRepository(
|
|||
private val appPreferencesStore: AppPreferencesStore,
|
||||
) : YieldSupplyRepository {
|
||||
|
||||
private val statusMap: MutableMap<String, YieldSupplyEnterStatus> = ConcurrentHashMap()
|
||||
private val statusMap: MutableMap<String, YieldSupplyPendingStatus> = ConcurrentHashMap()
|
||||
|
||||
override suspend fun getCachedMarkets(): List<YieldMarketToken>? = withContext(dispatchers.io) {
|
||||
val cache = store.getSyncOrNull().orEmpty()
|
||||
|
|
@ -126,23 +123,36 @@ internal class DefaultYieldSupplyRepository(
|
|||
).getOrThrow().isActive
|
||||
}
|
||||
|
||||
override suspend fun saveTokenProtocolStatus(
|
||||
override suspend fun saveTokenProtocolPendingStatus(
|
||||
userWalletId: UserWalletId,
|
||||
cryptoCurrency: CryptoCurrency,
|
||||
yieldSupplyEnterStatus: YieldSupplyEnterStatus?,
|
||||
yieldSupplyPendingStatus: YieldSupplyPendingStatus?,
|
||||
) {
|
||||
val key = getTokenProtocolStatusKey(userWalletId, cryptoCurrency)
|
||||
if (yieldSupplyEnterStatus != null) {
|
||||
statusMap[key] = yieldSupplyEnterStatus
|
||||
if (yieldSupplyPendingStatus != null) {
|
||||
statusMap[key] = yieldSupplyPendingStatus
|
||||
} else {
|
||||
statusMap.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getTokenProtocolStatus(
|
||||
override suspend fun getPendingTxHashes(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): List<String> {
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWalletId,
|
||||
network = cryptoCurrency.network,
|
||||
) ?: return emptyList()
|
||||
|
||||
return walletManager.wallet.recentTransactions
|
||||
.filter { it.status == TransactionStatus.Unconfirmed }
|
||||
.map {
|
||||
it.hash.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getTokenProtocolPendingStatus(
|
||||
userWalletId: UserWalletId,
|
||||
cryptoCurrency: CryptoCurrency,
|
||||
): YieldSupplyEnterStatus? {
|
||||
): YieldSupplyPendingStatus? {
|
||||
return statusMap[getTokenProtocolStatusKey(userWalletId, cryptoCurrency)]
|
||||
}
|
||||
|
||||
|
|
@ -153,32 +163,6 @@ internal class DefaultYieldSupplyRepository(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun getTokenPendingStatus(
|
||||
userWalletId: UserWalletId,
|
||||
cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
): YieldSupplyEnterStatus? = runSuspendCatching {
|
||||
val cryptoCurrency = cryptoCurrencyStatus.currency
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWalletId,
|
||||
blockchain = cryptoCurrency.network.toBlockchain(),
|
||||
derivationPath = cryptoCurrency.network.derivationPath.value,
|
||||
) ?: error("Wallet manager not found")
|
||||
|
||||
val pendingTxs = cryptoCurrencyStatus.value.pendingTransactions
|
||||
|
||||
val yieldAddress = walletManager.calculateYieldModuleAddress()
|
||||
val hasRecentYieldEnterTxs = pendingTxs.hasYieldEnterTransactions(yieldAddress)
|
||||
val hasRecentYieldExitTxs = pendingTxs.hasYieldExitTransactions()
|
||||
|
||||
when {
|
||||
hasRecentYieldEnterTxs -> YieldSupplyEnterStatus.Enter
|
||||
hasRecentYieldExitTxs -> YieldSupplyEnterStatus.Exit
|
||||
else -> null
|
||||
}
|
||||
}.onFailure { exception ->
|
||||
Timber.w(exception, "Failed to get pending yield supply status")
|
||||
}.getOrNull()
|
||||
|
||||
override fun getShouldShowYieldPromoBanner(): Flow<Boolean> {
|
||||
return appPreferencesStore.get(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, true)
|
||||
}
|
||||
|
|
@ -187,16 +171,6 @@ internal class DefaultYieldSupplyRepository(
|
|||
appPreferencesStore.store(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, shouldShow)
|
||||
}
|
||||
|
||||
private fun Set<TxInfo>.hasYieldEnterTransactions(yieldAddress: String) = any {
|
||||
it.type == TxInfo.TransactionType.YieldSupply.Enter ||
|
||||
it.type == TxInfo.TransactionType.Approve &&
|
||||
(it.interactionAddressType as? TxInfo.InteractionAddressType.Contract)?.address == yieldAddress
|
||||
}
|
||||
|
||||
private fun Set<TxInfo>.hasYieldExitTransactions() = any {
|
||||
it.type == TxInfo.TransactionType.YieldSupply.Exit
|
||||
}
|
||||
|
||||
private fun getTokenProtocolStatusKey(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String =
|
||||
"${userWalletId}_${cryptoCurrency.id.value}"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue