Updated on 2026-08-14
This commit is contained in:
parent
e04ac1fb2c
commit
99d32963b5
20 changed files with 336 additions and 27 deletions
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -26,6 +26,7 @@ class FetchStakingOptionsUseCase(
|
|||
coroutineScope {
|
||||
launch { stakeKitRepository.fetchYields() }
|
||||
launch { p2pEthPoolRepository.fetchVaults() }
|
||||
launch { p2pEthPoolRepository.fetchVaultLimits() }
|
||||
}
|
||||
},
|
||||
catch = { stakingErrorResolver.resolve(it) },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue