Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-18 16:25:57 +02:00
parent fe6c26624a
commit 5060b5c028
19 changed files with 212 additions and 64 deletions

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/**
* Response for GET /api/v1/staking/pool/{network}/vaults
@ -33,15 +34,15 @@ data class P2PEthPoolVaultDTO(
@Json(name = "displayName")
val displayName: String,
@Json(name = "apy")
val apy: Double,
val apy: BigDecimal,
@Json(name = "baseApy")
val baseApy: Double,
val baseApy: BigDecimal,
@Json(name = "capacity")
val capacity: Double,
val capacity: BigDecimal,
@Json(name = "totalAssets")
val totalAssets: Double,
val totalAssets: BigDecimal,
@Json(name = "feePercent")
val feePercent: Double,
val feePercent: BigDecimal,
@Json(name = "isPrivate")
val isPrivate: Boolean,
@Json(name = "isGenesis")

View file

@ -44,7 +44,7 @@ internal class DefaultP2PEthPoolRepository(
Timber.e("Error fetching P2PEthPool vaults: $error")
emptyList()
}
p2pEthPoolVaultsStore.store(vaults)
p2pEthPoolVaultsStore.store(vaults.filter { !it.isPrivate }) // TODO eth isSmoothingPool?
}
override suspend fun getVaults(network: P2PEthPoolNetwork): Either<StakingError, List<P2PEthPoolVault>> = either {

View file

@ -8,11 +8,12 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.*
import com.tangem.domain.staking.model.StakingIntegrationID
import kotlinx.datetime.Instant
import java.math.BigDecimal
/** Converts P2PEthPool API response to [StakingBalance.Data.P2PEthPool] */
/** Converts P2PEthPool API response to [StakingBalance] */
internal object P2PEthPoolStakingBalanceConverter {
fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance.Data.P2PEthPool {
fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance {
val stakingId = StakingID(
integrationId = StakingIntegrationID.P2PEthPool.value,
address = response.delegatorAddress,
@ -27,11 +28,22 @@ internal object P2PEthPoolStakingBalanceConverter {
exitQueue = convertExitQueue(response.exitQueue),
)
return StakingBalance.Data.P2PEthPool(
stakingId = stakingId,
source = source,
account = account,
)
val hasActivePosition = account.stake.assets > BigDecimal.ZERO ||
account.exitQueue.total > BigDecimal.ZERO ||
account.availableToWithdraw > BigDecimal.ZERO
return if (hasActivePosition) {
StakingBalance.Data.P2PEthPool(
stakingId = stakingId,
source = source,
account = account,
)
} else {
StakingBalance.Empty(
stakingId = stakingId,
source = source,
)
}
}
private fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake {

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

@ -136,8 +136,8 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
val vaults = runSuspendCatching { p2pEthPoolVaultsStore.getSync() }.getOrNull().orEmpty()
if (vaults.isEmpty()) {
Timber.w("No P2PEthPool vaults available for $userWalletId")
p2PEthPoolBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
Timber.w("No P2PEthPool vaults available for $userWalletId, storing empty balances")
p2PEthPoolBalancesStore.storeEmpty(userWalletId = userWalletId, stakingIds = stakingIds)
return
}

View file

@ -90,6 +90,15 @@ internal class DefaultP2PEthPoolBalancesStore(
}
}
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,
@ -187,5 +196,8 @@ internal class DefaultP2PEthPoolBalancesStore(
}
}
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

@ -23,6 +23,8 @@ interface P2PEthPoolBalancesStore {
suspend fun storeActual(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>)
suspend fun storeEmpty(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>)

View file

@ -13,7 +13,7 @@ internal fun YieldBalanceWrapperDTO.toDomain(source: StatusSource = StatusSource
internal fun P2PEthPoolAccountResponse.toDomain(
source: StatusSource = StatusSource.CACHE,
): StakingBalance.Data.P2PEthPool {
): StakingBalance {
return P2PEthPoolStakingBalanceConverter.convert(
response = this,
source = source,

View file

@ -0,0 +1,10 @@
package com.tangem.domain.staking.model.common
import kotlinx.serialization.Serializable
@Serializable
enum class RewardClaiming {
AUTO,
MANUAL,
UNKNOWN,
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.staking.model.common
import kotlinx.serialization.Serializable
@Serializable
enum class RewardSchedule {
BLOCK,
HOUR,
DAY,
WEEK,
MONTH,
ERA,
EPOCH,
UNKNOWN,
}

View file

@ -0,0 +1,17 @@
package com.tangem.domain.staking.model.common
import kotlinx.serialization.Serializable
/**
* Provider-agnostic representation of staking action arguments.
* Contains amount requirements and constraints for enter/exit operations.
*
* Maps from:
* - StakeKit: Yield.Args.Enter
* - P2PEthPool: P2PEthPoolStaking.Metadata
*/
@Serializable
data class StakingActionArgs(
val amountRequirement: StakingAmountRequirement?,
val isPartialAmountDisabled: Boolean,
)

View file

@ -0,0 +1,19 @@
package com.tangem.domain.staking.model.common
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
/**
* Provider-agnostic representation of staking amount requirements.
* Contains validation constraints for stake/unstake amounts.
*
* Maps from:
* - StakeKit: AddressArgument with ArgType.AMOUNT
* - P2PEthPool: P2PEthPoolStaking.Metadata minimumStake/maximumStake
*/
@Serializable
data class StakingAmountRequirement(
val isRequired: Boolean,
val minimum: SerializedBigDecimal? = null,
val maximum: SerializedBigDecimal? = null,
)

View file

@ -2,8 +2,11 @@ package com.tangem.domain.staking.model
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.staking.YieldToken
import com.tangem.domain.staking.model.common.RewardClaiming
import com.tangem.domain.staking.model.common.RewardSchedule
import com.tangem.domain.staking.model.common.StakingActionArgs
import com.tangem.domain.staking.model.common.StakingAmountRequirement
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.stakekit.Yield
import java.math.BigDecimal
/**
@ -39,9 +42,23 @@ class P2PEthPoolIntegration(
override val exitMinimumAmount: BigDecimal? = null
override val enterArgs: Yield.Args.Enter? = null
override val enterArgs: StakingActionArgs = StakingActionArgs(
amountRequirement = StakingAmountRequirement(
isRequired = true,
minimum = DEFAULT_MINIMUM_STAKE,
maximum = null,
),
isPartialAmountDisabled = false,
)
override val exitArgs: Yield.Args.Enter? = null
override val exitArgs: StakingActionArgs = StakingActionArgs(
amountRequirement = StakingAmountRequirement(
isRequired = true,
minimum = null,
maximum = null,
),
isPartialAmountDisabled = false,
)
// Metadata
@ -49,9 +66,9 @@ class P2PEthPoolIntegration(
override val cooldownPeriodDays: Int = DEFAULT_COOLDOWN_DAYS
override val rewardSchedule: Yield.Metadata.RewardSchedule = Yield.Metadata.RewardSchedule.DAY
override val rewardSchedule: RewardSchedule = RewardSchedule.DAY
override val rewardClaiming: Yield.Metadata.RewardClaiming = Yield.Metadata.RewardClaiming.AUTO
override val rewardClaiming: RewardClaiming = RewardClaiming.AUTO
override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = token

View file

@ -2,6 +2,10 @@ package com.tangem.domain.staking.model
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.staking.YieldToken
import com.tangem.domain.staking.model.common.RewardClaiming
import com.tangem.domain.staking.model.common.RewardSchedule
import com.tangem.domain.staking.model.common.StakingActionArgs
import com.tangem.domain.staking.model.common.StakingAmountRequirement
import com.tangem.domain.staking.model.stakekit.Yield
import java.math.BigDecimal
@ -39,9 +43,9 @@ class StakeKitIntegration(
yield.args.exit?.args
?.get(Yield.Args.ArgType.AMOUNT)?.minimum
override val enterArgs: Yield.Args.Enter = yield.args.enter
override val enterArgs: StakingActionArgs = yield.args.enter.toStakingActionArgs()
override val exitArgs: Yield.Args.Enter? = yield.args.exit
override val exitArgs: StakingActionArgs? = yield.args.exit?.toStakingActionArgs()
// Metadata
@ -49,12 +53,47 @@ class StakeKitIntegration(
override val cooldownPeriodDays: Int? = yield.metadata.cooldownPeriod?.days
override val rewardSchedule: Yield.Metadata.RewardSchedule = yield.metadata.rewardSchedule
override val rewardSchedule: RewardSchedule = yield.metadata.rewardSchedule.toRewardSchedule()
override val rewardClaiming: Yield.Metadata.RewardClaiming = yield.metadata.rewardClaiming
override val rewardClaiming: RewardClaiming = yield.metadata.rewardClaiming.toRewardClaiming()
// Basic
override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken =
tokens.firstOrNull { rawCurrencyId?.value == it.coinGeckoId } ?: token
private fun Yield.Args.Enter.toStakingActionArgs(): StakingActionArgs {
val amountArg = args[Yield.Args.ArgType.AMOUNT]
return StakingActionArgs(
amountRequirement = amountArg?.let { arg ->
StakingAmountRequirement(
isRequired = arg.required,
minimum = arg.minimum,
maximum = arg.maximum,
)
},
isPartialAmountDisabled = isPartialAmountDisabled,
)
}
private fun Yield.Metadata.RewardSchedule.toRewardSchedule(): RewardSchedule {
return when (this) {
Yield.Metadata.RewardSchedule.BLOCK -> RewardSchedule.BLOCK
Yield.Metadata.RewardSchedule.HOUR -> RewardSchedule.HOUR
Yield.Metadata.RewardSchedule.DAY -> RewardSchedule.DAY
Yield.Metadata.RewardSchedule.WEEK -> RewardSchedule.WEEK
Yield.Metadata.RewardSchedule.MONTH -> RewardSchedule.MONTH
Yield.Metadata.RewardSchedule.ERA -> RewardSchedule.ERA
Yield.Metadata.RewardSchedule.EPOCH -> RewardSchedule.EPOCH
Yield.Metadata.RewardSchedule.UNKNOWN -> RewardSchedule.UNKNOWN
}
}
private fun Yield.Metadata.RewardClaiming.toRewardClaiming(): RewardClaiming {
return when (this) {
Yield.Metadata.RewardClaiming.AUTO -> RewardClaiming.AUTO
Yield.Metadata.RewardClaiming.MANUAL -> RewardClaiming.MANUAL
Yield.Metadata.RewardClaiming.UNKNOWN -> RewardClaiming.UNKNOWN
}
}
}

View file

@ -2,14 +2,15 @@ package com.tangem.domain.staking.model
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.staking.YieldToken
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.common.RewardClaiming
import com.tangem.domain.staking.model.common.RewardSchedule
import com.tangem.domain.staking.model.common.StakingActionArgs
import java.math.BigDecimal
/**
* Strategy interface for staking integrations.
* Abstracts over StakeKit and P2PEthPool staking providers.
*/
// TODO p2p get rid of stakekit-specific models in StakingIntegration and implementors
interface StakingIntegration {
// Basic
@ -36,9 +37,9 @@ interface StakingIntegration {
val exitMinimumAmount: BigDecimal?
val enterArgs: Yield.Args.Enter?
val enterArgs: StakingActionArgs?
val exitArgs: Yield.Args.Enter?
val exitArgs: StakingActionArgs?
// Metadata
@ -46,9 +47,9 @@ interface StakingIntegration {
val cooldownPeriodDays: Int?
val rewardSchedule: Yield.Metadata.RewardSchedule
val rewardSchedule: RewardSchedule
val rewardClaiming: Yield.Metadata.RewardClaiming
val rewardClaiming: RewardClaiming
// Basic

View file

@ -19,10 +19,10 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.BalanceItem
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.staking.model.common.RewardType
import com.tangem.domain.staking.model.StakingIntegration
import com.tangem.domain.staking.model.StakingTarget
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.common.RewardClaiming
import com.tangem.domain.staking.model.common.RewardType
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.model.StakingClickIntents
import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState
@ -293,9 +293,9 @@ internal class SetInitialDataStateTransformer(
val EQUALITY_THRESHOLD = BigDecimal(1E-10)
val rewardClaimingResources = mapOf(
Yield.Metadata.RewardClaiming.MANUAL to R.string.staking_reward_claiming_manual,
Yield.Metadata.RewardClaiming.AUTO to R.string.staking_reward_claiming_auto,
Yield.Metadata.RewardSchedule.UNKNOWN to null,
RewardClaiming.MANUAL to R.string.staking_reward_claiming_manual,
RewardClaiming.AUTO to R.string.staking_reward_claiming_auto,
RewardClaiming.UNKNOWN to null,
)
}
}

View file

@ -13,8 +13,7 @@ import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.staking.model.StakingIntegration
import com.tangem.domain.staking.model.stakekit.AddressArgument
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.common.StakingAmountRequirement
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.R
import com.tangem.lib.crypto.BlockchainUtils.isTron
@ -97,11 +96,11 @@ internal class AmountRequirementStateTransformer(
return when (actionType) {
is StakingActionCommonType.Enter -> {
val enterRequirements = integration.enterArgs?.args?.get(Yield.Args.ArgType.AMOUNT)
val enterRequirements = integration.enterArgs?.amountRequirement
enterRequirements?.getError(amountDecimal, R.string.staking_amount_requirement_error)
}
is StakingActionCommonType.Exit -> {
val exitRequirements = integration.exitArgs?.args?.get(Yield.Args.ArgType.AMOUNT)
val exitRequirements = integration.exitArgs?.amountRequirement
exitRequirements?.getError(amountDecimal, R.string.staking_unstake_amount_requirement_error)
}
else -> null
@ -119,7 +118,7 @@ internal class AmountRequirementStateTransformer(
return isEnterOrExit && isTron && !isIntegerOnly
}
private fun AddressArgument.getError(amount: BigDecimal, @StringRes errorTextRes: Int): TextReference? {
private fun StakingAmountRequirement.getError(amount: BigDecimal, @StringRes errorTextRes: Int): TextReference? {
val isExceedsMinRequirement = minimum?.compareTo(amount) == 1
val isExceedsMaxRequirement = if (maximum?.isPositive() == true) {
maximum?.compareTo(amount) == -1
@ -143,7 +142,7 @@ internal class AmountRequirementStateTransformer(
return resourceReference(
errorTextRes,
wrappedList(errorText),
).takeIf { required && (isExceedsMinRequirement || isExceedsMaxRequirement) }
).takeIf { isRequired && (isExceedsMinRequirement || isExceedsMaxRequirement) }
}
data class Data(

View file

@ -9,7 +9,6 @@ import com.tangem.domain.models.staking.BalanceType
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.action.StakingActionType
import com.tangem.domain.staking.model.StakingIntegration
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState
@ -214,12 +213,12 @@ internal class StakingInfoNotificationsFactory(
if (prevState.actionType !is StakingActionCommonType.Exit) return
val maxAmount = prevState.balanceState?.cryptoAmount ?: return
val exitRequirements = integration.exitArgs?.args?.get(Yield.Args.ArgType.AMOUNT) ?: return
val exitRequirements = integration.exitArgs?.amountRequirement ?: return
val amountLeft = maxAmount - actionAmount
val isNotEnoughLeft = !amountLeft.isZero() && amountLeft < exitRequirements.minimum.orZero()
if (exitRequirements.required && isNotEnoughLeft) {
if (exitRequirements.isRequired && isNotEnoughLeft) {
add(StakingNotification.Warning.LowStakedBalance)
}
}

View file

@ -1,12 +1,12 @@
package com.tangem.features.staking.impl.presentation.state.utils
import com.tangem.core.ui.extensions.*
import com.tangem.domain.staking.model.common.RewardSchedule
import com.tangem.domain.staking.model.common.RewardType
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.features.staking.impl.R
import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.COSMOS_SCHEDULE
import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.SOLANA_SCHEDULE
import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.TON_SCHEDULE
import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardScheduleConstants.COSMOS_SCHEDULE
import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardScheduleConstants.SOLANA_SCHEDULE
import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardScheduleConstants.TON_SCHEDULE
import com.tangem.lib.crypto.BlockchainUtils.isCosmos
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.lib.crypto.BlockchainUtils.isTon
@ -14,42 +14,42 @@ import com.tangem.lib.crypto.BlockchainUtils.isTron
import com.tangem.utils.StringsSigns.MINUS
import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE
private data object StakingRewardSchedule {
private data object StakingRewardScheduleConstants {
val COSMOS_SCHEDULE = 5 to 12
val SOLANA_SCHEDULE = 2 to 3
val TON_SCHEDULE = 1 to 2
}
internal fun getRewardScheduleText(
rewardSchedule: Yield.Metadata.RewardSchedule,
rewardSchedule: RewardSchedule,
networkId: String,
decapitalize: Boolean,
): TextReference? {
return when (rewardSchedule) {
Yield.Metadata.RewardSchedule.WEEK -> resourceReference(
RewardSchedule.WEEK -> resourceReference(
id = R.string.staking_reward_schedule_week,
decapitalize = decapitalize,
)
Yield.Metadata.RewardSchedule.HOUR -> resourceReference(
RewardSchedule.HOUR -> resourceReference(
id = R.string.staking_reward_schedule_hour,
decapitalize = decapitalize,
)
Yield.Metadata.RewardSchedule.DAY -> resourceReference(
RewardSchedule.DAY -> resourceReference(
id = R.string.staking_reward_schedule_day,
decapitalize = decapitalize,
)
Yield.Metadata.RewardSchedule.MONTH -> resourceReference(
RewardSchedule.MONTH -> resourceReference(
id = R.string.staking_reward_schedule_month,
decapitalize = decapitalize,
)
Yield.Metadata.RewardSchedule.BLOCK,
Yield.Metadata.RewardSchedule.EPOCH,
Yield.Metadata.RewardSchedule.ERA,
RewardSchedule.BLOCK,
RewardSchedule.EPOCH,
RewardSchedule.ERA,
-> getCustomRewardSchedule(
networkId = networkId,
decapitalize = decapitalize,
)
else -> null
RewardSchedule.UNKNOWN -> null
}
}