Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-06 18:48:35 +03:00
parent 4765a7be43
commit d5fcf732e5
15 changed files with 142 additions and 53 deletions

View file

@ -164,7 +164,11 @@ internal class DefaultStakingRepository(
val yield = getYield(cryptoCurrencyId, symbol)
StakingEntryInfo(
apr = requireNotNull(yield.preferredValidators.maxByOrNull { it.apr.orZero() }?.apr),
rewardInfo = requireNotNull(
yield
.preferredValidators
.maxByOrNull { it.rewardInfo?.rate.orZero() }?.rewardInfo,
),
rewardSchedule = yield.metadata.rewardSchedule,
tokenSymbol = yield.token.symbol,
)

View file

@ -10,7 +10,9 @@ import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.Yield.Metadata.RewardSchedule
import com.tangem.domain.staking.model.stakekit.Yield.Validator.ValidatorStatus
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toImmutableList
import java.math.RoundingMode
internal object YieldConverter : Converter<YieldDTO, Yield> {
@ -21,7 +23,10 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
private val PARTNERS_NAMES = listOf("Meria")
private const val DIVIDE_SCALE = 8
override fun convert(value: YieldDTO): Yield {
val rewardType = convertRewardType(value.rewardType.asMandatory("rewardType"))
return Yield(
id = value.id.asMandatory("id"),
token = YieldTokenConverter.convert(value.token.asMandatory("token")),
@ -30,14 +35,14 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
status = convertStatus(value.status.asMandatory("status")),
apy = value.apy.asMandatory("apy"),
rewardRate = value.rewardRate.asMandatory("rewardRate"),
rewardType = convertRewardType(value.rewardType.asMandatory("rewardType")),
rewardType = rewardType,
metadata = convertMetadata(value.metadata.asMandatory("metadata")),
validators = value.validators.asMandatory("validators")
.asSequence()
.distinctBy { it.address }
.filter { it.status == ValidatorStatusDTO.ACTIVE }
.map { convertValidator(it) }
.sortedByDescending { it.apr }
.map { convertValidator(validatorDTO = it, rewardType = rewardType) }
.sortedByDescending { it.rewardInfo?.rate?.orZero() }
.sortedByDescending { it.isStrategicPartner }
.toImmutableList(),
isAvailable = value.isAvailable.asMandatory("isAvailable"),
@ -119,15 +124,16 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
)
}
private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO): Yield.Validator {
private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO, rewardType: Yield.RewardType): Yield.Validator {
val address = validatorDTO.address.asMandatory("address")
return Yield.Validator(
address = address,
status = convertValidatorStatus(validatorDTO.status.asMandatory("status")),
name = validatorDTO.name.asMandatory("name"),
image = validatorDTO.image,
website = validatorDTO.website,
apr = validatorDTO.apr,
rewardInfo = createRewardInfo(validatorDTO, rewardType),
commission = validatorDTO.commission,
stakedBalance = validatorDTO.stakedBalance,
votingPower = validatorDTO.votingPower,
@ -136,6 +142,32 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
)
}
private fun createRewardInfo(validatorDTO: YieldDTO.ValidatorDTO, rewardType: Yield.RewardType): Yield.RewardInfo? {
val aprOrApy = validatorDTO.apr
val commission = validatorDTO.commission
// gross = net / (1 - commission)
return try {
val netApy = aprOrApy
val grossAprOrApy = if (netApy != null && commission != null) {
val commissionFraction = commission.toBigDecimal()
if (commissionFraction < 1.toBigDecimal()) {
netApy.divide(
1.toBigDecimal() - commissionFraction,
DIVIDE_SCALE,
RoundingMode.HALF_UP,
)
} else {
netApy
}
} else {
netApy
}
grossAprOrApy?.let { Yield.RewardInfo(rate = it, type = rewardType) }
} catch (_: Exception) {
aprOrApy?.let { Yield.RewardInfo(rate = it, type = rewardType) }
}
}
private fun convertRewardType(rewardTypeDTO: YieldDTO.RewardTypeDTO): Yield.RewardType {
return when (rewardTypeDTO) {
YieldDTO.RewardTypeDTO.APY -> Yield.RewardType.APY

View file

@ -1,10 +1,9 @@
package com.tangem.domain.staking.model
import com.tangem.domain.staking.model.stakekit.Yield
import java.math.BigDecimal
data class StakingEntryInfo(
val apr: BigDecimal,
val rewardInfo: Yield.RewardInfo,
val rewardSchedule: Yield.Metadata.RewardSchedule,
val tokenSymbol: String,
)

View file

@ -70,7 +70,7 @@ data class Yield(
val name: String,
val image: String? = null,
val website: String? = null,
val apr: SerializedBigDecimal? = null,
val rewardInfo: RewardInfo? = null,
val commission: Double? = null,
val stakedBalance: String? = null,
val votingPower: Double? = null,
@ -144,6 +144,12 @@ data class Yield(
APR, // simple rate
UNKNOWN,
}
@Serializable
data class RewardInfo(
val rate: SerializedBigDecimal,
val type: RewardType,
)
}
@Serializable

View file

@ -68,7 +68,6 @@ internal sealed class StakingStates {
override val isPrimaryButtonEnabled: Boolean,
val showBanner: Boolean,
val infoItems: ImmutableList<RoundedListWithDividersItemData>,
val aprRange: TextReference,
val onInfoClick: (InfoType) -> Unit,
val yieldBalance: InnerYieldBalanceState,
val pullToRefreshConfig: PullToRefreshConfig,

View file

@ -2,6 +2,7 @@ package com.tangem.features.staking.impl.presentation.state.bottomsheet
internal enum class InfoType {
ANNUAL_PERCENTAGE_RATE,
ANNUAL_PERCENTAGE_YIELD,
UNBONDING_PERIOD,
REWARD_CLAIMING,
WARMUP_PERIOD,

View file

@ -18,7 +18,6 @@ internal object InitialStakingStatePreview {
val defaultState = StakingStates.InitialInfoState.Data(
isPrimaryButtonEnabled = true,
showBanner = true,
aprRange = stringReference("2.54-5.12%"),
infoItems = persistentListOf(
RoundedListWithDividersItemData(
id = R.string.staking_details_available,
@ -88,7 +87,6 @@ internal object InitialStakingStatePreview {
name = "Binance",
image = null,
website = null,
apr = "5".toBigDecimal(),
commission = null,
stakedBalance = null,
votingPower = null,

View file

@ -13,7 +13,10 @@ internal object ValidatorStatePreviewData {
status = ValidatorStatus.ACTIVE,
name = "Luganodes",
image = "https://assets.stakek.it/validators/luganodes.png",
apr = BigDecimal("0.054823398040640445"),
rewardInfo = Yield.RewardInfo(
rate = BigDecimal("0.054823398040640445"),
type = Yield.RewardType.APR,
),
commission = 0.1,
stakedBalance = "355544384.45009977",
website = "https://luganodes.com/",
@ -26,7 +29,10 @@ internal object ValidatorStatePreviewData {
status = ValidatorStatus.ACTIVE,
name = "InfStones",
image = "https://assets.stakek.it/validators/infstones.png",
apr = BigDecimal("0.057786472172836965"),
rewardInfo = Yield.RewardInfo(
rate = BigDecimal("0.057786472172836965"),
type = Yield.RewardType.APR,
),
commission = 0.05,
stakedBalance = "12495684.05643019",
website = "https://infstones.com/",
@ -39,7 +45,10 @@ internal object ValidatorStatePreviewData {
status = ValidatorStatus.ACTIVE,
name = "Kiln",
image = "https://assets.stakek.it/validators/kiln.png",
apr = BigDecimal("0.057786472172836965"),
rewardInfo = Yield.RewardInfo(
rate = BigDecimal("0.057786472172836965"),
type = Yield.RewardType.APR,
),
commission = 0.05,
stakedBalance = "85400369.96393165",
website = "https://infstones.com/",

View file

@ -88,7 +88,6 @@ internal class SetInitialDataStateTransformer(
!amount.isNullOrZero() && sources.yieldBalanceSource.isActual() && sources.networkSource.isActual()
},
showBanner = !isAnyTokenStaked && yieldBalance == InnerYieldBalanceState.Empty,
aprRange = getAprRange(yield.preferredValidators),
infoItems = getInfoItems(),
onInfoClick = clickIntents::onInfoClick,
yieldBalance = yieldBalance,
@ -101,7 +100,7 @@ internal class SetInitialDataStateTransformer(
private fun getInfoItems(): PersistentList<RoundedListWithDividersItemData> {
return listOfNotNull(
createAnnualPercentageRateItem(),
createAnnualPercentageItem(),
createAvailableItem(cryptoCurrencyStatus),
createUnbondingPeriodItem(),
createMinimumRequirementItem(cryptoCurrencyStatus),
@ -111,17 +110,32 @@ internal class SetInitialDataStateTransformer(
).toPersistentList()
}
private fun createAnnualPercentageRateItem(): RoundedListWithDividersItemData {
private fun createAnnualPercentageItem(): RoundedListWithDividersItemData {
val validators = yield.preferredValidators
val rateRangeInfo = getPercentageRange(validators)
return RoundedListWithDividersItemData(
id = R.string.staking_details_annual_percentage_rate,
startText = TextReference.Res(R.string.staking_details_annual_percentage_rate),
endText = getAprRange(validators),
iconClick = { clickIntents.onInfoClick(InfoType.ANNUAL_PERCENTAGE_RATE) },
startText = getRateStartText(rateRangeInfo.first),
endText = rateRangeInfo.second,
iconClick = {
when (rateRangeInfo.first) {
Yield.RewardType.APR -> clickIntents.onInfoClick(InfoType.ANNUAL_PERCENTAGE_RATE)
Yield.RewardType.APY -> clickIntents.onInfoClick(InfoType.ANNUAL_PERCENTAGE_YIELD)
Yield.RewardType.UNKNOWN -> {}
}
},
isEndTextHighlighted = false,
)
}
private fun getRateStartText(rewardType: Yield.RewardType): TextReference {
return when (rewardType) {
Yield.RewardType.APR -> TextReference.Res(R.string.staking_details_annual_percentage_rate)
Yield.RewardType.APY -> TextReference.Res(R.string.staking_details_annual_percentage_yield)
else -> TextReference.EMPTY
}
}
private fun createAvailableItem(cryptoCurrencyStatus: CryptoCurrencyStatus): RoundedListWithDividersItemData {
return RoundedListWithDividersItemData(
id = R.string.staking_details_available,
@ -227,26 +241,30 @@ internal class SetInitialDataStateTransformer(
)
}
private fun getAprRange(validators: List<Yield.Validator>): TextReference {
private fun getPercentageRange(validators: List<Yield.Validator>): Pair<Yield.RewardType, TextReference> {
if (validators.isEmpty()) {
return stringReference(DASH_SIGN)
return Yield.RewardType.APR to stringReference(DASH_SIGN)
}
val aprValues = validators
val rewardInfos = validators
.filter { it.preferred }
.takeIf { it.isNotEmpty() }
?.mapNotNull { it.apr }
?: validators.mapNotNull { it.apr }
?.mapNotNull { it.rewardInfo }
?: validators.mapNotNull { it.rewardInfo }
val minApr = aprValues.min()
val maxApr = aprValues.max()
val infoWithMinRate = rewardInfos.minBy { it.rate }
val infoWithMaxRate = rewardInfos.maxBy { it.rate }
val formattedMinApr = minApr.format { percent() }.remove("%")
val formattedMaxApr = maxApr.format { percent() }
val formattedMinRate = infoWithMinRate.rate.format { percent() }.remove("%")
val formattedMaxRate = infoWithMaxRate.rate.format { percent() }
if (maxApr - minApr < EQUALITY_THRESHOLD) {
return stringReference("$formattedMinApr%")
if (infoWithMaxRate.rate - infoWithMinRate.rate < EQUALITY_THRESHOLD) {
return infoWithMaxRate.type to stringReference("$formattedMinRate%")
}
return resourceReference(R.string.common_range, wrappedList(formattedMinApr, formattedMaxApr))
return infoWithMaxRate.type to
resourceReference(
id = R.string.common_range,
formatArgs = wrappedList(formattedMinRate, formattedMaxRate),
)
}
private fun showMinimumRequirementInfo(blockchainId: String): Boolean {

View file

@ -23,6 +23,10 @@ internal class ShowInfoBottomSheetStateTransformer(
title = resourceReference(R.string.staking_details_annual_percentage_rate),
text = resourceReference(R.string.staking_details_annual_percentage_rate_info),
)
InfoType.ANNUAL_PERCENTAGE_YIELD -> StakingInfoBottomSheetConfig(
title = resourceReference(R.string.staking_details_annual_percentage_yield),
text = resourceReference(R.string.staking_details_annual_percentage_yield_info),
)
InfoType.UNBONDING_PERIOD -> StakingInfoBottomSheetConfig(
title = resourceReference(R.string.staking_details_unbonding_period),
text = resourceReference(R.string.staking_details_unbonding_period_info),

View file

@ -52,6 +52,22 @@ internal fun getRewardScheduleText(
}
}
internal fun getRewardTypeShortText(rewardType: Yield.RewardType): TextReference {
return when (rewardType) {
Yield.RewardType.APR -> TextReference.Res(R.string.staking_details_apr)
Yield.RewardType.APY -> TextReference.Res(R.string.staking_details_apy)
else -> TextReference.EMPTY
}
}
internal fun getRewardTypeLongText(rewardType: Yield.RewardType): TextReference {
return when (rewardType) {
Yield.RewardType.APR -> TextReference.Res(R.string.staking_details_annual_percentage_rate)
Yield.RewardType.APY -> TextReference.Res(R.string.staking_details_annual_percentage_yield)
else -> TextReference.EMPTY
}
}
private fun getCustomRewardSchedule(networkId: String, decapitalize: Boolean = false): TextReference? {
return when {
isSolana(networkId) -> {

View file

@ -15,10 +15,11 @@ import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.staking.impl.R
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import com.tangem.features.staking.impl.presentation.state.BalanceState
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.utils.getRewardTypeShortText
import com.tangem.utils.extensions.orZero
@Composable
@ -64,11 +65,11 @@ internal fun StakingClaimRewardsValidatorContent(
@Suppress("UnusedPrivateMember")
@Composable
private fun BalanceState.getAprTextColored() = combinedReference(
resourceReference(R.string.staking_details_apr),
getRewardTypeShortText(validator?.rewardInfo?.type ?: Yield.RewardType.UNKNOWN),
annotatedReference {
appendSpace()
appendColored(
text = validator?.apr.orZero().format { percent() },
text = validator?.rewardInfo?.rate?.orZero().format { percent() },
color = TangemTheme.colors.text.accent,
)
},
@ -76,6 +77,6 @@ private fun BalanceState.getAprTextColored() = combinedReference(
@Composable
private fun BalanceState.getAprTextNeutral() = combinedReference(
resourceReference(R.string.staking_details_apr),
stringReference(" " + validator?.apr.orZero().format { percent() }),
getRewardTypeShortText(validator?.rewardInfo?.type ?: Yield.RewardType.UNKNOWN),
stringReference(" " + validator?.rewardInfo?.rate?.orZero().format { percent() }),
)

View file

@ -46,6 +46,7 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.StakingDetailsScreenTestTags
import com.tangem.domain.models.staking.BalanceType
import com.tangem.domain.models.staking.RewardBlockType
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import com.tangem.features.staking.impl.presentation.state.BalanceState
@ -53,6 +54,7 @@ import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceStat
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.previewdata.InitialStakingStatePreview
import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub
import com.tangem.features.staking.impl.presentation.state.utils.getRewardTypeShortText
import com.tangem.utils.StringsSigns.DOT
import com.tangem.utils.extensions.orZero
@ -312,17 +314,14 @@ private fun StakeButtonBlock(buttonState: NavigationButtonsState) {
}
}
/**
* For FCA fixes remove coloring for now
*/
@Suppress("UnusedPrivateMember")
@Composable
private fun BalanceState.getAprTextColored() = combinedReference(
resourceReference(R.string.staking_details_apr),
getRewardTypeShortText(validator?.rewardInfo?.type ?: Yield.RewardType.UNKNOWN),
annotatedReference {
appendSpace()
appendColored(
text = validator?.apr.orZero().format { percent() },
text = validator?.rewardInfo?.rate?.orZero().format { percent() },
color = TangemTheme.colors.text.accent,
)
},
@ -330,8 +329,8 @@ private fun BalanceState.getAprTextColored() = combinedReference(
@Composable
private fun BalanceState.getAprTextNeutral() = combinedReference(
resourceReference(R.string.staking_details_apr),
stringReference(" " + validator?.apr.orZero().format { percent() }),
getRewardTypeShortText(validator?.rewardInfo?.type ?: Yield.RewardType.UNKNOWN),
stringReference(" " + validator?.rewardInfo?.rate?.orZero().format { percent() }),
)
@Composable

View file

@ -33,6 +33,7 @@ import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.previewdata.ValidatorStatePreviewData
import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub
import com.tangem.features.staking.impl.presentation.state.utils.getRewardTypeLongText
import com.tangem.utils.extensions.orZero
/**
@ -106,11 +107,11 @@ internal fun StakingValidatorListContent(
@Suppress("UnusedPrivateMember")
@Composable
private fun Yield.Validator.getAprTextColored() = combinedReference(
resourceReference(R.string.staking_details_annual_percentage_rate),
getRewardTypeLongText(rewardInfo?.type ?: Yield.RewardType.UNKNOWN),
annotatedReference {
appendSpace()
appendColored(
text = apr.orZero().format { percent() },
text = rewardInfo?.rate?.orZero().format { percent() },
color = TangemTheme.colors.text.accent,
)
},
@ -118,8 +119,8 @@ private fun Yield.Validator.getAprTextColored() = combinedReference(
@Composable
private fun Yield.Validator.getAprTextNeutral() = combinedReference(
resourceReference(R.string.staking_details_annual_percentage_rate),
stringReference(" " + apr.orZero().format { percent() }),
getRewardTypeLongText(rewardInfo?.type ?: Yield.RewardType.UNKNOWN),
stringReference(" " + rewardInfo?.rate?.orZero().format { percent() }),
)
@Composable

View file

@ -17,8 +17,10 @@ import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.StakingSendDetailsScreenTestTags
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.StakingStates
import com.tangem.features.staking.impl.presentation.state.utils.getRewardTypeShortText
import com.tangem.features.staking.impl.presentation.ui.ValidatorImagePlaceholder
import com.tangem.utils.extensions.orZero
@ -57,16 +59,16 @@ internal fun ValidatorBlock(validatorState: StakingStates.ValidatorState, isClic
@Composable
private fun StakingStates.ValidatorState.Data.getInfoTitleColored() = combinedReference(
annotatedReference {
append(resourceReference(R.string.staking_details_apr).resolveReference())
append(getRewardTypeShortText(chosenValidator.rewardInfo?.type ?: Yield.RewardType.UNKNOWN).resolveReference())
appendSpace()
appendColored(
text = chosenValidator.apr.orZero().format { percent() },
text = chosenValidator.rewardInfo?.rate.orZero().format { percent() },
color = TangemTheme.colors.text.accent,
)
},
)
private fun StakingStates.ValidatorState.Data.getInfoTitleNeutral() = combinedReference(
resourceReference(R.string.staking_details_apr),
stringReference(" " + chosenValidator.apr.orZero().format { percent() }),
getRewardTypeShortText(chosenValidator.rewardInfo?.type ?: Yield.RewardType.UNKNOWN),
stringReference(" " + chosenValidator.rewardInfo?.rate?.orZero().format { percent() }),
)