Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-16 15:44:17 +04:00
parent 242483fd4f
commit 887ea85b3b
11 changed files with 625 additions and 4 deletions

View file

@ -13,6 +13,7 @@ dependencies {
implementation(projects.core.utils)
implementation(projects.data.common)
implementation(projects.data.staking)
implementation(projects.domain.legacy)
implementation(projects.domain.models)
@ -23,6 +24,7 @@ dependencies {
implementation(projects.libs.blockchainSdk)
implementation(deps.androidx.datastore)
implementation(deps.jodatime)
implementation(deps.test.coroutine)
implementation(tangemDeps.blockchain)

View file

@ -0,0 +1,51 @@
package com.tangem.common.test.data.staking
import com.tangem.data.staking.store.YieldsBalancesStore.StakingID
import com.tangem.datasource.api.stakekit.models.request.Address
import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO
import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO
import com.tangem.datasource.api.stakekit.models.response.model.TokenDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
object MockYieldBalanceWrapperDTOFactory {
val defaultStakingId = StakingID(
integrationId = "ton-ton-chorus-one-pools-staking",
address = "0x1",
)
fun createWithBalance(stakingId: StakingID = defaultStakingId): YieldBalanceWrapperDTO {
return YieldBalanceWrapperDTO(
addresses = Address(address = stakingId.address),
balances = listOf(
BalanceDTO(
groupId = "groupId",
type = BalanceDTO.BalanceTypeDTO.UNKNOWN,
amount = BigDecimal.ONE,
date = null,
pricePerShare = BigDecimal.ZERO,
pendingActions = listOf(),
pendingActionConstraints = null,
tokenDTO = TokenDTO(
name = "The-Open-Network",
network = NetworkTypeDTO.TON,
symbol = "TON",
decimals = 8,
address = null,
coinGeckoId = null,
logoURI = null,
isPoints = null,
),
validatorAddress = null,
validatorAddresses = null,
providerId = null,
),
),
integrationId = stakingId.integrationId,
)
}
}

View file

@ -7,7 +7,7 @@ import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.model.stakekit.YieldBalanceItem
import com.tangem.utils.converter.Converter
internal class YieldBalanceConverter(
class YieldBalanceConverter(
private val source: StatusSource,
) : Converter<YieldBalanceWrapperDTO, YieldBalance> {

View file

@ -62,4 +62,11 @@ dependencies {
}
// endregion
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(tangemDeps.card.core)
testImplementation(projects.common.test)
}

View file

@ -0,0 +1,158 @@
package com.tangem.data.staking.store
import androidx.datastore.core.DataStore
import com.tangem.data.staking.store.YieldsBalancesStore.StakingID
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.token.converter.YieldBalanceConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
internal typealias WalletIdWithWrappers = Map<String, Set<YieldBalanceWrapperDTO>>
internal typealias WalletIdWithBalances = Map<UserWalletId, Set<YieldBalance>>
/**
* Default implementation of [YieldsBalancesStore]
*
* @property runtimeStore runtime store
* @property persistenceStore persistence store
* @param dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultYieldsBalancesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithBalances>,
private val persistenceStore: DataStore<WalletIdWithWrappers>,
dispatchers: CoroutineDispatcherProvider,
) : YieldsBalancesStore {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
val cachedStatuses = persistenceStore.data.firstOrNull() ?: return@launch
runtimeStore.store(
value = cachedStatuses.map { (stringWalletId, wrappers) ->
val key = UserWalletId(stringWalletId)
val value = YieldBalanceConverter(isCached = true).convertSet(input = wrappers)
key to value
}
.toMap(),
)
}
}
override fun get(userWalletId: UserWalletId): Flow<Set<YieldBalance>> {
return runtimeStore.get().mapNotNull { it[userWalletId] }
}
override suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) {
updateBalanceInRuntime(userWalletId, stakingId) {
it.copySealed(source = StatusSource.CACHE)
}
}
override suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
val storedBalances = this[userWalletId].orEmpty()
val balances = stakingIds.mapTo(hashSetOf()) { stakingId ->
val balance = storedBalances.firstOrNull {
it.integrationId == stakingId.integrationId &&
it.address == stakingId.address
}
?: createDefaultBalance(id = stakingId)
balance.copySealed(source = StatusSource.CACHE)
}
val updatedBalances = storedBalances.addOrReplace(balances) { old, new ->
old.integrationId == new.integrationId && old.address == new.address
}
put(key = userWalletId, value = updatedBalances)
}
}
}
override suspend fun storeActual(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>) {
coroutineScope {
launch { storeInRuntime(userWalletId = userWalletId, values = values) }
launch { storeInPersistence(userWalletId = userWalletId, values = values) }
}
}
override suspend fun storeError(userWalletId: UserWalletId, stakingId: StakingID) {
updateBalanceInRuntime(userWalletId, stakingId) {
it.copySealed(source = StatusSource.ONLY_CACHE)
}
}
private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>) {
val newBalances = YieldBalanceConverter(isCached = false).convertSet(input = values)
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = saved[userWalletId]
?.addOrReplace(newBalances) { old, new ->
old.integrationId == new.integrationId && old.address == new.address
}
?: newBalances
}
}
}
private suspend fun storeInPersistence(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>) {
persistenceStore.updateData { current ->
current.toMutableMap().apply {
this[userWalletId.stringValue] = current[userWalletId.stringValue]
?.addOrReplace(items = values) { old, new ->
old.integrationId == new.integrationId && old.addresses.address == new.addresses.address
}
?: values
}
}
}
private suspend fun updateBalanceInRuntime(
userWalletId: UserWalletId,
stakingID: StakingID,
update: (YieldBalance) -> YieldBalance,
) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
val balance = this[userWalletId].orEmpty()
.firstOrNull {
it.integrationId == stakingID.integrationId &&
it.address == stakingID.address
}
?: createDefaultBalance(id = stakingID)
val updatedBalances = this[userWalletId].orEmpty()
.addOrReplace(item = update(balance)) {
it.integrationId == balance.integrationId &&
it.address == balance.address
}
put(key = userWalletId, value = updatedBalances)
}
}
}
private fun createDefaultBalance(id: StakingID): YieldBalance {
return YieldBalance.Error(integrationId = id.integrationId, address = id.address)
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.data.staking.store
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* Store of [YieldBalance]'s set
*
[REDACTED_AUTHOR]
*/
interface YieldsBalancesStore {
/** Get flow of [YieldBalance]'s set by [userWalletId] */
fun get(userWalletId: UserWalletId): Flow<Set<YieldBalance>>
/** Refresh balance of [stakingId] by [userWalletId] */
suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID)
/** Refresh balances of [stakingIds] by [userWalletId] */
suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
/** Store actual [values] by [userWalletId] */
suspend fun storeActual(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>)
/** Store error by [userWalletId] and [stakingId] */
suspend fun storeError(userWalletId: UserWalletId, stakingId: StakingID)
data class StakingID(val integrationId: String, val address: String)
}

View file

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

View file

@ -0,0 +1,83 @@
package com.tangem.data.staking.store
import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.staking.toDomain
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class YieldsBalancesStoreGetMethodTest {
private val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
private val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
private val store = DefaultYieldsBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `test get if runtime store is empty`() = runTest {
val actual = store.get(userWalletId = userWalletId)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values).isEqualTo(emptyList<Set<YieldBalance>>())
}
@Test
fun `test get if runtime store contains empty map`() = runTest {
runtimeStore.store(value = emptyMap())
val actual = store.get(userWalletId = userWalletId)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values).isEqualTo(emptyList<Set<YieldBalance>>())
}
@Test
fun `test get if runtime store contains portfolio with empty balances`() = runTest {
runtimeStore.store(
value = mapOf(userWalletId to emptySet()),
)
val actual = store.get(userWalletId = userWalletId)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(emptySet<YieldBalance>()))
}
@Test
fun `test get if runtime store is not empty`() = runTest {
val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance()
runtimeStore.store(
value = mapOf(userWalletId to setOf(wrapper.toDomain())),
)
val actual = store.get(userWalletId = userWalletId)
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(setOf(wrapper.toDomain())))
}
private companion object {
val userWalletId = UserWalletId(stringValue = "011")
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.data.staking.store
import androidx.datastore.core.DataStore
import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.data.staking.toDomain
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class YieldsBalancesStoreInitializationTest {
@Test
fun `test initialization if cache store is empty`() = runTest {
val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
val persistenceStore: DataStore<WalletIdWithWrappers> = mockk()
every { persistenceStore.data } returns emptyFlow()
DefaultYieldsBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
}
@Test
fun `test initialization if cache store contains empty map`() = runTest {
val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
DefaultYieldsBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptyMap<String, Set<YieldBalance>>())
}
@Test
fun `test initialization if cache store is not empty`() = runTest {
val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance()
persistenceStore.updateData {
it.toMutableMap().apply {
put(userWalletId.stringValue, setOf(wrapper))
}
}
DefaultYieldsBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
val expected = mapOf(userWalletId to setOf(wrapper.toDomain()))
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected)
}
private companion object {
val userWalletId = UserWalletId(stringValue = "011")
}
}

View file

@ -0,0 +1,188 @@
package com.tangem.data.staking.store
import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.data.staking.toDomain
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
[REDACTED_AUTHOR]
*/
internal class YieldsBalancesStoreUpdateMethodsTest {
private val runtimeStore = RuntimeSharedStore<WalletIdWithBalances>()
private val persistenceStore = MockStateDataStore<WalletIdWithWrappers>(default = emptyMap())
private val store = DefaultYieldsBalancesStore(
runtimeStore = runtimeStore,
persistenceStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `refresh the single id if runtime store is empty`() = runTest {
store.refresh(userWalletId = userWalletId, stakingId = stakingId)
val runtimeExpected = mapOf(
userWalletId to setOf(
YieldBalance.Error(
integrationId = stakingId.integrationId,
address = stakingId.address,
),
),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
}
@Test
fun `refresh the single id if runtime store contains balance with this id`() = runTest {
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance().toDomain(source = StatusSource.ACTUAL)
runtimeStore.store(
value = mapOf(userWalletId to setOf(balance)),
)
store.refresh(userWalletId = userWalletId, stakingId = stakingId)
val runtimeExpected = mapOf(
userWalletId to setOf(
balance.copySealed(source = StatusSource.CACHE),
),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
}
@Test
fun `refresh the multi ids if runtime store is empty`() = runTest {
store.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
val runtimeExpected = mapOf(
userWalletId to stakingIds.mapTo(hashSetOf()) {
YieldBalance.Error(integrationId = it.integrationId, address = it.address)
},
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
}
@Test
fun `refresh the multi ids if runtime store contains balance with this id`() = runTest {
val firstBalance = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId = stakingIds.first())
.toDomain(source = StatusSource.ACTUAL)
val secondBalance = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId = stakingIds.last())
.toDomain(source = StatusSource.ACTUAL)
runtimeStore.store(
value = mapOf(userWalletId to setOf(firstBalance, secondBalance)),
)
store.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
val runtimeExpected = mapOf(
userWalletId to setOf(
firstBalance.copySealed(source = StatusSource.CACHE),
secondBalance.copySealed(source = StatusSource.CACHE),
),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
}
@Test
fun `store actual if runtime and cache stores contain balance with this id`() = runTest {
val prevWrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance()
runtimeStore.store(
value = mapOf(userWalletId to setOf(prevWrapper.toDomain(source = StatusSource.CACHE))),
)
persistenceStore.updateData {
it.toMutableMap().apply {
put(userWalletId.stringValue, setOf(prevWrapper))
}
}
val newWrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance()
store.storeActual(userWalletId, setOf(newWrapper))
val runtimeExpected = mapOf(
userWalletId to setOf(newWrapper.toDomain(source = StatusSource.ACTUAL)),
)
val persistenceExpected = mapOf(
userWalletId.stringValue to setOf(newWrapper),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected)
}
@Test
fun `store error if runtime store is empty`() = runTest {
store.storeError(userWalletId = userWalletId, stakingId = stakingId)
val runtimeExpected = mapOf(
userWalletId to setOf(
YieldBalance.Error(
integrationId = stakingId.integrationId,
address = stakingId.address,
),
),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
}
@Test
fun `store error if runtime store contains balance with this id`() = runTest {
val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId)
runtimeStore.store(
value = mapOf(
userWalletId to setOf(wrapper.toDomain(source = StatusSource.CACHE)),
),
)
store.storeError(userWalletId = userWalletId, stakingId = stakingId)
val runtimeExpected = mapOf(
userWalletId to setOf(wrapper.toDomain(source = StatusSource.ONLY_CACHE)),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
}
private companion object {
val userWalletId = UserWalletId(stringValue = "011")
val stakingId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
val stakingIds = setOf(
stakingId,
YieldsBalancesStore.StakingID(
integrationId = "solana-sol-native-multivalidator-staking",
address = "0x1",
),
)
}
}

View file

@ -9,21 +9,32 @@ sealed class YieldBalance {
abstract val integrationId: String?
abstract val address: String?
abstract val source: StatusSource
fun copySealed(source: StatusSource): YieldBalance {
return when (this) {
is Data -> copy(source = source)
is Empty -> copy(source = source)
is Error -> this
}
}
data class Data(
override val integrationId: String?,
override val address: String,
override val source: StatusSource,
val balance: YieldBalanceItem,
val source: StatusSource,
) : YieldBalance()
data class Empty(
override val integrationId: String?,
override val address: String,
val source: StatusSource,
override val source: StatusSource,
) : YieldBalance()
data class Error(override val integrationId: String?, override val address: String?) : YieldBalance()
data class Error(override val integrationId: String?, override val address: String?) : YieldBalance() {
override val source: StatusSource = StatusSource.ACTUAL
}
}
data class YieldBalanceItem(