Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-26 17:07:40 +02:00
parent e04ac1fb2c
commit 99d32963b5
20 changed files with 336 additions and 27 deletions

View file

@ -0,0 +1,16 @@
package com.tangem.domain.staking.model.ethpool
import java.math.BigDecimal
/**
* Per-vault capacity limits from Tangem API /v1/coins/settings.
*
* @property limit max stakeable amount in ETH (pre-computed as MAX_Threshold - TVL).
* Vaults absent from the API response or with null limit are not stored.
* @property coefficient threshold multiplier (e.g. 1.25×); optional server-side field,
* reserved for future use, not used in client-side calculations
*/
data class VaultLimitInfo(
val limit: BigDecimal,
val coefficient: BigDecimal?,
)

View file

@ -26,6 +26,7 @@ class FetchStakingOptionsUseCase(
coroutineScope {
launch { stakeKitRepository.fetchYields() }
launch { p2pEthPoolRepository.fetchVaults() }
launch { p2pEthPoolRepository.fetchVaultLimits() }
}
},
catch = { stakingErrorResolver.resolve(it) },

View file

@ -7,7 +7,9 @@ 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.ethpool.VaultLimitInfo
import java.math.BigDecimal
import java.math.RoundingMode
/**
* StakingIntegration implementation for P2PEthPool pooled staking.
@ -16,6 +18,7 @@ import java.math.BigDecimal
class P2PEthPoolIntegration(
override val integrationId: StakingIntegrationID,
private val vaults: List<P2PEthPoolVault>,
private val vaultLimits: Map<String, VaultLimitInfo>,
) : StakingIntegration {
// Basic
@ -30,9 +33,11 @@ class P2PEthPoolIntegration(
vault.toStakingTarget()
}
override val preferredTargets: List<StakingTarget> = targets
override val preferredTargets: List<StakingTarget> = vaults
.filter { isVaultAvailable(it) }
.map { it.toStakingTarget() }
override val areAllTargetsFull: Boolean = false
override val areAllTargetsFull: Boolean = preferredTargets.isEmpty()
// Enter/Exit Args
@ -45,7 +50,7 @@ class P2PEthPoolIntegration(
override val enterArgs: StakingActionArgs = StakingActionArgs(
amountRequirement = StakingAmountRequirement(
isRequired = true,
minimum = DEFAULT_MINIMUM_STAKE,
minimum = enterMinimumAmount,
maximum = calculateMaximumStakeAmount(),
),
isPartialAmountDisabled = false,
@ -82,19 +87,27 @@ class P2PEthPoolIntegration(
override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = token
private fun isVaultAvailable(vault: P2PEthPoolVault): Boolean {
val info = vaultLimits[vault.vaultAddress.lowercase()] ?: return false
return info.limit - vault.totalAssets > AVAILABILITY_THRESHOLD
}
private fun calculateMaximumStakeAmount(): BigDecimal? {
return vaults
.filter { isVaultAvailable(it) }
.mapNotNull { vault ->
val availableCapacity = vault.capacity - vault.totalAssets
if (availableCapacity > BigDecimal.ZERO) availableCapacity else null
vaultLimits[vault.vaultAddress.lowercase()]?.let { it.limit - vault.totalAssets }
}
.maxOrNull()
.minOrNull()
?.setScale(MAX_AMOUNT_SCALE, RoundingMode.FLOOR)
}
companion object {
private const val MIN_COOLDOWN_DAYS = 1
private const val MAX_COOLDOWN_DAYS = 4
private const val MAX_AMOUNT_SCALE = 1
private val DEFAULT_MINIMUM_STAKE = BigDecimal("0.01")
private val AVAILABILITY_THRESHOLD = BigDecimal("2")
private const val TERMS_OF_SERVICE_URL = "https://www.p2p.org/terms-of-use"
private const val PRIVACY_POLICY_URL = "https://www.p2p.org/privacy-policy"

View file

@ -8,6 +8,7 @@ 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.ethpool.P2PEthPoolStakingConfig
import com.tangem.domain.staking.model.ethpool.VaultLimitInfo
import com.tangem.domain.staking.model.stakekit.StakingError
import kotlinx.coroutines.flow.Flow
@ -131,6 +132,25 @@ interface P2PEthPoolRepository {
*/
suspend fun getVaultsSync(): List<P2PEthPoolVault>
/**
* Fetch and store vault limits from Tangem API /v1/coins/settings
*/
suspend fun fetchVaultLimits()
/**
* Get flow of cached vault limits.
*
* @return Flow of map from vaultAddress.lowercase() to VaultLimitInfo, null if not yet fetched
*/
fun getVaultLimitsFlow(): Flow<Map<String, VaultLimitInfo>?>
/**
* Get cached vault limits synchronously.
*
* @return Map from vaultAddress.lowercase() to VaultLimitInfo, null if not yet fetched
*/
suspend fun getVaultLimitsSyncOrNull(): Map<String, VaultLimitInfo>?
/**
* Check P2PEthPool staking availability by finding public vault
*

View file

@ -0,0 +1,150 @@
package com.tangem.domain.staking.model
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.ethpool.VaultLimitInfo
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class P2PEthPoolIntegrationTest {
private fun buildVault(
address: String,
capacity: String,
totalAssets: String,
) = P2PEthPoolVault(
vaultAddress = address,
displayName = "Test Vault",
apy = BigDecimal("4.5"),
baseApy = BigDecimal("4.0"),
capacity = BigDecimal(capacity),
totalAssets = BigDecimal(totalAssets),
feePercent = BigDecimal("0.1"),
isPrivate = false,
isGenesis = false,
isSmoothingPool = true,
isErc20 = false,
tokenName = null,
tokenSymbol = null,
createdAt = 0L,
)
private fun buildLimits(vararg pairs: Pair<String, BigDecimal>) =
pairs.associate { (addr, limit) ->
addr.lowercase() to VaultLimitInfo(limit = limit, coefficient = BigDecimal("1.25"))
}
@Nested
inner class MaximumAmount {
@Test
fun `vault available - uses remaining space as max, rounded down to 0_1 ETH`() {
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "10"))
val limits = buildLimits("0xABC" to BigDecimal("50"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isEqualTo(BigDecimal("40.0"))
}
@Test
fun `remaining with fractional ETH - floored to 0_1 ETH precision`() {
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "10"))
val limits = buildLimits("0xABC" to BigDecimal("22.37"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isEqualTo(BigDecimal("12.3"))
}
@Test
fun `vault absent from limits map - treated as full, max is null`() {
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "30"))
val limits = emptyMap<String, VaultLimitInfo>()
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
assertThat(integration.areAllTargetsFull).isTrue()
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isNull()
}
@Test
fun `vault with exactly 2 ETH remaining - not available, max is null`() {
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48"))
val limits = buildLimits("0xABC" to BigDecimal("50"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isNull()
}
@Test
fun `vault with less than 2 ETH remaining - not available, max is null`() {
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48.5"))
val limits = buildLimits("0xABC" to BigDecimal("50"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isNull()
}
@Test
fun `multiple available vaults - uses minimum remaining space`() {
val vault1 = buildVault("0xA", capacity = "100", totalAssets = "10")
val vault2 = buildVault("0xB", capacity = "100", totalAssets = "20")
val limits = buildLimits("0xa" to BigDecimal("50"), "0xb" to BigDecimal("50"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, listOf(vault1, vault2), limits)
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isEqualTo(BigDecimal("30.0"))
}
}
@Nested
inner class Availability {
@Test
fun `all vaults full - areAllTargetsFull is true`() {
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48"))
val limits = buildLimits("0xABC" to BigDecimal("50"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
assertThat(integration.areAllTargetsFull).isTrue()
}
@Test
fun `vault with remaining between 0_1 and 2 ETH - also considered full`() {
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48.5"))
val limits = buildLimits("0xABC" to BigDecimal("50"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
assertThat(integration.areAllTargetsFull).isTrue()
}
@Test
fun `at least one vault available - areAllTargetsFull is false`() {
val vault1 = buildVault("0xA", capacity = "100", totalAssets = "48.5") // full (1.5 remaining < 2)
val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available (40 remaining > 2)
val limits = buildLimits("0xa" to BigDecimal("50"), "0xb" to BigDecimal("50"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, listOf(vault1, vault2), limits)
assertThat(integration.areAllTargetsFull).isFalse()
}
@Test
fun `preferred targets only contains available vaults`() {
val vault1 = buildVault("0xA", capacity = "100", totalAssets = "48.5") // full
val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available
val limits = buildLimits("0xa" to BigDecimal("50"), "0xb" to BigDecimal("50"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, listOf(vault1, vault2), limits)
assertThat(integration.preferredTargets).hasSize(1)
assertThat(integration.preferredTargets.first().address).isEqualTo("0xB")
}
}
@Nested
inner class MinimumAmount {
@Test
fun `minimum stake is 0_01 ETH`() {
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, emptyList(), emptyMap())
assertThat(integration.enterMinimumAmount).isEqualTo(BigDecimal("0.01"))
}
}
}