Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-26 09:51:50 +03:00
commit 7e48e67354
942 changed files with 40912 additions and 7544 deletions

View file

@ -70,5 +70,6 @@ dependencies {
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.turbine)
// endregion
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
}
}
}

View file

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

View file

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

View file

@ -19,6 +19,7 @@ internal class ExpressProviderConverter : Converter<ExchangeProvider, ExpressPro
privacyPolicy = value.privacyPolicy,
isRecommended = value.isRecommended,
slippage = value.slippage,
isExchangeOnlyWithinSingleAddress = value.isExchangeOnlyWithinSingleAddress,
)
}

View file

@ -1,16 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository.&lt;no name provided&gt;$val last = res.tokens.size &lt; 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>

View file

@ -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" },
)
}
}

View file

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

View file

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

View file

@ -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,
)
}
}

View file

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

View file

@ -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")
}
}
}

View file

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

View file

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

View file

@ -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()
}

View file

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

View file

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

View file

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

View file

@ -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()
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
)
}
}

View file

@ -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,
)
}

View file

@ -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,
)
}
}
}

View file

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

View file

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

View file

@ -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,
)
}
}

View file

@ -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,
)
}
}

View file

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

View file

@ -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,
)
}

View file

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

View file

@ -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()) }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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") }
}
}

View file

@ -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()
}

View file

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

View file

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

View file

@ -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(),

View file

@ -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(),

View file

@ -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(),

View file

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

View file

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

View file

@ -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() }
}
}

View file

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

View file

@ -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,
)
}

View file

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

View file

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

View file

@ -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>()
}
}

View file

@ -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,
)
}
}

View file

@ -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,
)
}
}

View file

@ -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,
)
}
}

View file

@ -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(),
)
}
}

View file

@ -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,
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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