Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-30 11:18:48 +04:00
parent 79eab436df
commit 78720c5cab
20 changed files with 267 additions and 215 deletions

View file

@ -52,6 +52,7 @@ dependencies {
/** Coroutines */
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.coroutines.rx2)
implementation(deps.kotlin.datetime)
/** Logging */
implementation(deps.timber)

View file

@ -1,53 +1,61 @@
package com.tangem.datasource.local.token.converter
import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.domain.models.StatusSource
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.model.stakekit.BalanceItem
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.model.stakekit.YieldBalanceItem
import com.tangem.utils.converter.Converter
import kotlinx.datetime.Instant
class YieldBalanceConverter(
private val source: StatusSource,
) : Converter<YieldBalanceWrapperDTO, YieldBalance> {
) : Converter<YieldBalanceWrapperDTO, YieldBalance?> {
constructor(isCached: Boolean) : this(source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL)
override fun convert(value: YieldBalanceWrapperDTO): YieldBalance {
override fun convert(value: YieldBalanceWrapperDTO): YieldBalance? {
val stakingId = StakingID(
integrationId = value.integrationId ?: return null,
address = value.addresses.address,
)
return if (value.balances.isEmpty()) {
YieldBalance.Empty(
integrationId = value.integrationId,
address = value.addresses.address,
source = source,
)
YieldBalance.Empty(stakingId = stakingId, source = source)
} else {
YieldBalance.Data(
integrationId = value.integrationId,
address = value.addresses.address,
stakingId = stakingId,
balance = YieldBalanceItem(
items = value.balances.map { item ->
BalanceItem(
groupId = item.groupId,
token = TokenConverter.convert(item.tokenDTO),
type = BalanceTypeConverter.convert(item.type),
amount = item.amount,
rawCurrencyId = item.tokenDTO.coinGeckoId,
// tron-specific. operates validatorAddresses instead of validatorAddress
validatorAddress = item.validatorAddress ?: item.validatorAddresses?.get(0),
date = item.date?.toDateTime(),
pendingActions = PendingActionConverter
.convertList(item.pendingActions)
.sortedBy { it.passthrough },
pendingActionsConstraints = PendingActionConstraintsConverter
.convertList(item.pendingActionConstraints.orEmpty()),
isPending = false,
)
}
.sortedWith(compareBy({ it.type }, { it.amount })),
items = value.balances
.map { item -> item.toBalanceItem() }
.sortedWith(comparator = compareBy({ it.type }, { it.amount })),
integrationId = value.integrationId,
),
source = source,
)
}
}
private fun BalanceDTO.toBalanceItem(): BalanceItem {
val item = this
return BalanceItem(
groupId = item.groupId,
token = TokenConverter.convert(item.tokenDTO),
type = BalanceTypeConverter.convert(item.type),
amount = item.amount,
rawCurrencyId = item.tokenDTO.coinGeckoId,
// tron-specific. operates validatorAddresses instead of validatorAddress
validatorAddress = item.validatorAddress ?: item.validatorAddresses?.get(0),
date = item.date?.toString()?.let { Instant.parse(it) },
pendingActions = PendingActionConverter
.convertList(item.pendingActions)
.sortedBy { it.passthrough },
pendingActionsConstraints = PendingActionConstraintsConverter
.convertList(item.pendingActionConstraints.orEmpty()),
isPending = false,
)
}
}

View file

@ -35,10 +35,7 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
) : SingleYieldBalanceProducer {
override val fallback: YieldBalance by lazy {
YieldBalance.Error(
integrationId = params.stakingId.integrationId,
address = params.stakingId.address,
)
YieldBalance.Error(stakingId = params.stakingId)
}
override fun produce(): Flow<YieldBalance> {
@ -50,7 +47,7 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
.mapNotNull { balances ->
val currentStakingId = params.stakingId
val currentBalances = balances.filter { it.getStakingId() == currentStakingId }
val currentBalances = balances.filter { it.stakingId == currentStakingId }
if (currentBalances.size > 1) {
analyticsExceptionHandler.sendException(

View file

@ -46,6 +46,8 @@ internal class DefaultYieldsBalancesStore(
value = cachedStatuses.map { (stringWalletId, wrappers) ->
val key = UserWalletId(stringWalletId)
val value = YieldBalanceConverter(isCached = true).convertSet(input = wrappers)
.filterNotNull()
.toSet()
key to value
}
@ -61,7 +63,7 @@ internal class DefaultYieldsBalancesStore(
override suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): YieldBalance? {
return runtimeStore.getSyncOrNull()
?.get(userWalletId)
?.firstOrNull { stakingId == it.getStakingId() }
?.firstOrNull { it.stakingId == stakingId }
}
override suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<YieldBalance>? {
@ -96,11 +98,13 @@ internal class DefaultYieldsBalancesStore(
private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>) {
val newBalances = YieldBalanceConverter(isCached = false).convertSet(input = values)
.filterNotNull()
.toSet()
runtimeStore.update(default = emptyMap()) { saved ->
saved.toMutableMap().apply {
this[userWalletId] = saved[userWalletId]
?.addOrReplace(newBalances) { old, new -> old.getStakingId() == new.getStakingId() }
?.addOrReplace(newBalances) { old, new -> old.stakingId == new.stakingId }
?: newBalances
}
}
@ -135,7 +139,7 @@ internal class DefaultYieldsBalancesStore(
val balances = stakingIds.mapNotNullTo(hashSetOf()) { stakingId ->
val balance = portfolioBalances
.firstOrNull { stakingId == it.getStakingId() }
.firstOrNull { it.stakingId == stakingId }
?: ifNotFound(stakingId)
?: return@mapNotNullTo null
@ -143,7 +147,7 @@ internal class DefaultYieldsBalancesStore(
}
val updatedBalances = portfolioBalances.addOrReplace(items = balances) { old, new ->
old.getStakingId() == new.getStakingId()
old.stakingId == new.stakingId
}
put(key = userWalletId, value = updatedBalances)
@ -151,9 +155,7 @@ internal class DefaultYieldsBalancesStore(
}
}
private fun createErrorYieldBalance(id: StakingID): YieldBalance {
return YieldBalance.Error(integrationId = id.integrationId, address = id.address)
}
private fun createErrorYieldBalance(id: StakingID): YieldBalance = YieldBalance.Error(stakingId = id)
private fun YieldBalanceWrapperDTO.getStakingId(): StakingID? {
val integrationId = integrationId

View file

@ -6,5 +6,5 @@ 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)
return YieldBalanceConverter(source = source).convert(this)!!
}

View file

@ -84,7 +84,7 @@ internal class DefaultSingleYieldBalanceProducerTest {
val producerFlow = producer.produceWithFallback()
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
val updatedBalance = YieldBalance.Error(integrationId = tonId.integrationId, address = tonId.address)
val updatedBalance = YieldBalance.Error(stakingId = tonId)
// Act (first emit)
multiFlow.emit(value = setOf(balance))
@ -162,7 +162,7 @@ internal class DefaultSingleYieldBalanceProducerTest {
val actual1 = getEmittedValues(flow = producerFlow)
// Assert (first emit)
val fallbackStatus = YieldBalance.Error(integrationId = tonId.integrationId, address = "0x1")
val fallbackStatus = YieldBalance.Error(stakingId = tonId.copy(address = "0x1"))
Truth.assertThat(actual1).hasSize(1)
Truth.assertThat(actual1).containsExactly(fallbackStatus)

View file

@ -129,12 +129,7 @@ internal class YieldsBalancesStoreUpdateMethodsTest {
store.storeError(userWalletId = userWalletId, stakingIds = setOf(stakingId))
val runtimeExpected = mapOf(
userWalletId to setOf(
YieldBalance.Error(
integrationId = stakingId.integrationId,
address = stakingId.address,
),
),
userWalletId to setOf(YieldBalance.Error(stakingId)),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)

View file

@ -19,6 +19,7 @@ dependencies {
api(projects.core.analytics)
api(projects.core.utils)
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.serialization)
implementation(deps.jodatime)

View file

@ -11,6 +11,7 @@ dependencies {
implementation(projects.domain.core)
implementation(projects.domain.models)
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.serialization)
implementation(deps.jodatime)

View file

@ -1,4 +1,6 @@
package com.tangem.domain.staking.model
// TODO: make part of YieldBalance in the future
import kotlinx.serialization.Serializable
@Serializable
data class StakingID(val integrationId: String, val address: String)

View file

@ -2,166 +2,67 @@ package com.tangem.domain.staking.model.stakekit
import com.tangem.domain.models.StatusSource
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import org.joda.time.DateTime
import java.math.BigDecimal
import kotlinx.serialization.Serializable
sealed class YieldBalance {
/**
* Represents a yield balance in the staking system
*/
@Serializable
sealed interface YieldBalance {
abstract val integrationId: String?
abstract val address: String?
abstract val source: StatusSource
/** The unique identifier of the staking operation */
val stakingId: StakingID
/** The source of the status information */
val source: StatusSource
/**
* Represents a yield balance with actual data
*
* @property stakingId the unique identifier of the staking operation
* @property source the source of the status information
* @property balance the balance details of the yield
*/
@Serializable
data class Data(
override val stakingId: StakingID,
override val source: StatusSource,
val balance: YieldBalanceItem,
) : YieldBalance
/**
* Represents an empty yield balance
*
* @property stakingId the unique identifier of the staking operation
* @property source the source of the status information
*/
@Serializable
data class Empty(
override val stakingId: StakingID,
override val source: StatusSource,
) : YieldBalance
/**
* Represents an error state for the yield balance
*
* @property stakingId the unique identifier of the staking operation
*/
@Serializable
data class Error(override val stakingId: StakingID) : YieldBalance {
override val source: StatusSource = StatusSource.ACTUAL
}
/**
* Creates a copy of the current yield balance with a new status source
*
* @param source the new source of the status information
*/
fun copySealed(source: StatusSource): YieldBalance {
return when (this) {
is Data -> copy(source = source)
is Empty -> copy(source = source)
is Error,
is Unsupported,
-> this
}
}
fun getStakingId(): StakingID? {
val integrationId = integrationId
val address = address
if (integrationId == null || address == null) return null
return StakingID(integrationId = integrationId, address = address)
}
data class Data(
override val integrationId: String?,
override val address: String,
override val source: StatusSource,
val balance: YieldBalanceItem,
) : YieldBalance()
data class Empty(
override val integrationId: String?,
override val address: String,
override val source: StatusSource,
) : YieldBalance()
data object Unsupported : YieldBalance() {
override val integrationId: String? = null
override val address: String? = null
override val source: StatusSource = StatusSource.ACTUAL
}
data class Error(override val integrationId: String?, override val address: String?) : YieldBalance() {
override val source: StatusSource = StatusSource.ACTUAL
}
}
data class YieldBalanceItem(
val items: List<BalanceItem>,
val integrationId: String?,
)
data class BalanceItem(
val groupId: String,
val token: Token,
val type: BalanceType,
val amount: BigDecimal,
val rawCurrencyId: String?,
val validatorAddress: String?,
val date: DateTime?,
val pendingActions: List<PendingAction>,
val pendingActionsConstraints: List<PendingActionConstraints>,
val isPending: Boolean,
)
data class PendingActionConstraints(
val type: StakingActionType,
val amountArg: PendingAction.PendingActionArgs.Amount?,
)
data class PendingAction(
val type: StakingActionType,
val passthrough: String,
val args: PendingActionArgs?,
) {
data class PendingActionArgs(
val amount: Amount?,
val duration: Duration?,
val validatorAddress: Boolean?,
val validatorAddresses: Boolean?,
val tronResource: TronResource?,
val signatureVerification: Boolean?,
) {
data class Amount(
val required: Boolean,
val minimum: BigDecimal?,
val maximum: BigDecimal?,
)
data class Duration(
val required: Boolean,
val minimum: Int?,
val maximum: Int?,
)
data class TronResource(
val required: Boolean,
val options: List<String>,
)
}
}
/**
* IMPORTANT!!!
* Order is used to sort balances.
*/
@Suppress("MagicNumber")
enum class BalanceType(val order: Int) {
AVAILABLE(1),
STAKED(2),
PREPARING(3),
LOCKED(4),
UNSTAKING(5),
UNLOCKING(6),
UNSTAKED(7),
REWARDS(8),
UNKNOWN(9),
;
companion object {
fun BalanceType.isClickable() = when (this) {
STAKED,
UNSTAKED,
LOCKED,
-> true
AVAILABLE,
UNSTAKING,
PREPARING,
REWARDS,
UNLOCKING,
UNKNOWN,
-> false
}
}
}
enum class RewardBlockType {
NoRewards,
Rewards,
RewardsRequirementsError,
RewardUnavailable,
;
/**
* Indicated whether action can be performed on available reward
* For example, pending action CLAIM_REWARDS could be called
*/
val isActionable: Boolean
get() = when (this) {
NoRewards,
RewardUnavailable,
-> false
RewardsRequirementsError,
Rewards,
-> true
}
}

View file

@ -0,0 +1,130 @@
package com.tangem.domain.staking.model.stakekit
import com.tangem.domain.core.serialization.SerializedBigDecimal
import com.tangem.domain.staking.model.stakekit.action.StakingActionType
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@Serializable
data class YieldBalanceItem(
val items: List<BalanceItem>,
val integrationId: String,
)
@Serializable
data class BalanceItem(
val groupId: String,
val token: Token,
val type: BalanceType,
val amount: SerializedBigDecimal,
val rawCurrencyId: String?,
val validatorAddress: String?,
val date: Instant?,
val pendingActions: List<PendingAction>,
val pendingActionsConstraints: List<PendingActionConstraints>,
val isPending: Boolean,
)
@Serializable
data class PendingActionConstraints(
val type: StakingActionType,
val amountArg: PendingAction.PendingActionArgs.Amount?,
)
@Serializable
data class PendingAction(
val type: StakingActionType,
val passthrough: String,
val args: PendingActionArgs?,
) {
@Serializable
data class PendingActionArgs(
val amount: Amount?,
val duration: Duration?,
val validatorAddress: Boolean?,
val validatorAddresses: Boolean?,
val tronResource: TronResource?,
val signatureVerification: Boolean?,
) {
@Serializable
data class Amount(
val required: Boolean,
val minimum: SerializedBigDecimal?,
val maximum: SerializedBigDecimal?,
)
@Serializable
data class Duration(
val required: Boolean,
val minimum: Int?,
val maximum: Int?,
)
@Serializable
data class TronResource(
val required: Boolean,
val options: List<String>,
)
}
}
/**
* IMPORTANT!!!
* Order is used to sort balances.
*/
@Serializable
@Suppress("MagicNumber")
enum class BalanceType(val order: Int) {
AVAILABLE(1),
STAKED(2),
PREPARING(3),
LOCKED(4),
UNSTAKING(5),
UNLOCKING(6),
UNSTAKED(7),
REWARDS(8),
UNKNOWN(9),
;
companion object {
fun BalanceType.isClickable() = when (this) {
STAKED,
UNSTAKED,
LOCKED,
-> true
AVAILABLE,
UNSTAKING,
PREPARING,
REWARDS,
UNLOCKING,
UNKNOWN,
-> false
}
}
}
@Serializable
enum class RewardBlockType {
NoRewards,
Rewards,
RewardsRequirementsError,
RewardUnavailable,
;
/**
* Indicated whether action can be performed on available reward
* For example, pending action CLAIM_REWARDS could be called
*/
val isActionable: Boolean
get() = when (this) {
NoRewards,
RewardUnavailable,
-> false
RewardsRequirementsError,
Rewards,
-> true
}
}

View file

@ -420,7 +420,7 @@ abstract class BaseCurrencyStatusOperations(
params = MultiYieldBalanceProducer.Params(userWalletId = userWalletId),
)
.orEmpty()
.filter { it.getStakingId() in stakingIds }
.filter { it.stakingId in stakingIds }
ensure(balances.isNotEmpty()) { Error.EmptyYieldBalances }

View file

@ -25,6 +25,7 @@ import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
@ -277,12 +278,17 @@ class CachedCurrenciesStatusesOperations(
): YieldBalance? {
if (yieldBalances.isNullOrEmpty()) return null
val supportedIntegration = StakingIntegrationID.create(currencyId = currency.id)?.value ?: return null
val supportedIntegration = StakingIntegrationID.create(currencyId = currency.id)?.value
val address = extractAddress(networkStatus)
return yieldBalances.firstOrNull { it.integrationId == supportedIntegration && it.address == address }
?: YieldBalance.Error(integrationId = supportedIntegration, address = address)
return if (supportedIntegration != null && address != null) {
val stakingId = StakingID(integrationId = supportedIntegration, address = address)
yieldBalances.firstOrNull { it.stakingId == stakingId }
?: YieldBalance.Error(stakingId = stakingId)
} else {
null
}
}
private fun getCurrencies(userWalletId: UserWalletId): EitherFlow<TokenListError, List<CryptoCurrency>> {
@ -390,7 +396,7 @@ class CachedCurrenciesStatusesOperations(
)
.onEach { balance ->
state.update { loadedBalances ->
loadedBalances.addOrReplace(balance) { balance.getStakingId() == it }
loadedBalances.addOrReplace(balance) { balance.stakingId == it }
}
}
.launchIn(scope = this)

View file

@ -80,7 +80,8 @@ internal class CurrencyStatusOperations(
val hasCurrentNetworkTransactions = networkStatusValue.pendingTransactions.isNotEmpty()
val currentTransactions = networkStatusValue.pendingTransactions.getOrElse(currency.id, ::emptySet)
val yieldBalanceData = yieldBalance as? YieldBalance.Data
val isCurrentAddressStaking = yieldBalanceData?.address == networkStatusValue.address.defaultAddress.value
val isCurrentAddressStaking =
yieldBalanceData?.stakingId?.address == networkStatusValue.address.defaultAddress.value
val filteredTokenBalances = yieldBalanceData?.balance?.items?.filter {
it.token.coinGeckoId == currency.id.rawCurrencyId?.value
}

View file

@ -7,6 +7,7 @@ import arrow.core.toNonEmptySetOrNull
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.stakekit.YieldBalance
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
@ -74,9 +75,12 @@ class CurrencyStatusProxyCreator {
val address = extractAddress(networkStatus)
val supportedIntegration = StakingIntegrationID.create(currencyId = currency.id)?.value
val yieldBalance = if (supportedIntegration != null) {
yieldBalances?.firstOrNull { it.integrationId == supportedIntegration && it.address == address }
?: YieldBalance.Error(integrationId = supportedIntegration, address = address)
val yieldBalance = if (supportedIntegration != null && address != null) {
val stakingId = StakingID(integrationId = supportedIntegration, address = address)
yieldBalances?.firstOrNull { it.stakingId == stakingId }
?: YieldBalance.Error(stakingId = stakingId)
} else {
null
}

View file

@ -17,6 +17,7 @@ dependencies {
implementation(deps.androidx.paging.runtime)
/** Other dependencies */
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.immutable.collections)
implementation(deps.material)
implementation(deps.arrow.core)

View file

@ -22,7 +22,7 @@ import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toPersistentList
import org.joda.time.DateTime
import kotlinx.datetime.Instant
import java.math.BigDecimal
import java.util.Calendar
@ -122,7 +122,7 @@ internal class BalanceItemConverter(
-> null
}
private fun getUnbondingDate(date: DateTime?): TextReference? {
private fun getUnbondingDate(date: Instant?): TextReference? {
val unbondingPeriod = yield.metadata.cooldownPeriod?.days ?: return null
if (date == null) {
return combinedReference(
@ -136,7 +136,7 @@ internal class BalanceItemConverter(
nowCalendar.resetHours()
val endDate = Calendar.getInstance()
endDate.timeInMillis = date.millis
endDate.timeInMillis = date.toEpochMilliseconds()
endDate.resetHours()
val days = ((endDate.timeInMillis - nowCalendar.timeInMillis) / DAY_IN_MILLIS).toInt()

View file

@ -50,7 +50,7 @@ internal class YieldBalancesConverter(
?.firstOrNull { it.type == StakingActionType.CLAIM_REWARDS }
InnerYieldBalanceState.Data(
integrationId = yieldBalance?.integrationId,
integrationId = yieldBalance?.stakingId?.integrationId,
reward = YieldReward(
rewardsCrypto = cryptoRewardsValue.format { crypto(cryptoCurrency) },
rewardsFiat = fiatRewardsValue.format {

View file

@ -73,6 +73,7 @@ viewBindingDelegate = "1.5.9"
xmlShimmer = "1.1.3"
zxingQrCode = "3.5.1"
kotlinSerialization = "1.8.0"
kotlinDatetime = "0.6.2"
arrow = "1.2.4" # 2.0.1 breaks the build
reownCore = "1.1.2"
reownWeb3 = "1.1.2"
@ -258,6 +259,7 @@ viewBindingDelegate = { module = "com.github.kirich1409:viewbindingpropertydeleg
xmlShimmer = { module = "com.github.skydoves:androidveil", version.ref = "xmlShimmer" }
zxing-qrCore = { module = "com.google.zxing:core", version.ref = "zxingQrCode" }
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerialization" }
kotlin-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinDatetime" }
arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" }
arrow-fx = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" }
reownCore = { module = "com.reown:android-core", version.ref = "reownCore" }