From 99d32963b5e7fd098308fe98e3fb2d9e1820a89c Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 May 2026 17:07:40 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../api/tangemTech/TangemTechApi.kt | 3 + .../models/CoinsSettingsResponse.kt | 22 +++ .../local/token/P2PVaultLimitsStore.kt | 15 ++ .../staking/DefaultP2PEthPoolRepository.kt | 70 ++++++-- .../data/staking/di/StakingDataModule.kt | 6 + .../data/staking/P2PEthPoolVaultFilterTest.kt | 6 + .../staking/model/ethpool/VaultLimitInfo.kt | 16 ++ .../staking/FetchStakingOptionsUseCase.kt | 1 + .../staking/model/P2PEthPoolIntegration.kt | 25 ++- .../repositories/P2PEthPoolRepository.kt | 20 +++ .../model/P2PEthPoolIntegrationTest.kt | 150 ++++++++++++++++++ .../tokens/actions/BaseActionsFactory.kt | 10 +- .../tokens/actions/CommonActionsFactory.kt | 2 +- .../actions/OutdatedDataActionsFactory.kt | 2 +- .../features/feed/model/earn/EarnModel.kt | 2 +- .../impl/presentation/model/StakingModel.kt | 3 +- .../impl/presentation/state/StakingUiState.kt | 1 + .../SetButtonsStateTransformer.kt | 6 +- .../SetInitialDataStateTransformer.kt | 1 + .../model/StakingModelTransactionTest.kt | 2 + 20 files changed, 336 insertions(+), 27 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CoinsSettingsResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/token/P2PVaultLimitsStore.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/VaultLimitInfo.kt create mode 100644 domain/staking/src/test/kotlin/com/tangem/domain/staking/model/P2PEthPoolIntegrationTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index 0549a6634b..7530892018 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -219,6 +219,9 @@ interface TangemTechApi { @POST("v2/transaction-events") suspend fun transactionEvents(@Body name: TransactionEventBody): ApiResponse + @GET("v1/coins/settings") + suspend fun getCoinsSettings(): ApiResponse + // region Earn @GET("v1/earn/markets") suspend fun getEarnTokens( diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CoinsSettingsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CoinsSettingsResponse.kt new file mode 100644 index 0000000000..fbeff3c48e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CoinsSettingsResponse.kt @@ -0,0 +1,22 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.math.BigDecimal + +@JsonClass(generateAdapter = true) +data class CoinsSettingsResponse( + @Json(name = "staking") val staking: StakingSettingsDTO?, +) + +@JsonClass(generateAdapter = true) +data class StakingSettingsDTO( + @Json(name = "vaults") val vaults: List = emptyList(), +) + +@JsonClass(generateAdapter = true) +data class VaultSettingsDTO( + @Json(name = "vaultAddress") val vaultAddress: String, + @Json(name = "limit") val limit: BigDecimal?, + @Json(name = "coefficient") val coefficient: BigDecimal?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/P2PVaultLimitsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/P2PVaultLimitsStore.kt new file mode 100644 index 0000000000..b272d1dc98 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/P2PVaultLimitsStore.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.local.token + +import com.tangem.datasource.local.datastore.RuntimeStateStore +import com.tangem.domain.staking.model.ethpool.VaultLimitInfo +import javax.inject.Inject +import javax.inject.Singleton + +/** + * In-memory store for P2P vault limits from Tangem API /v1/coins/settings. + * Map key is vaultAddress.lowercase(). Null map value means limits not yet fetched. + * A missing key means the vault is full (null-limit vaults are excluded at fetch time). + */ +@Singleton +class P2PVaultLimitsStore @Inject constructor() : + RuntimeStateStore?> by RuntimeStateStore(defaultValue = null) \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index 09221d88a5..37502feb87 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -7,29 +7,35 @@ import arrow.core.raise.either import arrow.core.raise.ensure import com.tangem.data.staking.converters.ethpool.* import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.ethpool.P2PEthPoolApi import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionRequest import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolTransactionResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.token.P2PEthPoolVaultsStore +import com.tangem.datasource.local.token.P2PVaultLimitsStore import com.tangem.domain.models.staking.P2PEthPoolStakingAccount +import com.tangem.domain.staking.model.P2PEthPoolIntegration import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.StakingOption import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault +import com.tangem.domain.staking.model.ethpool.VaultLimitInfo import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.P2PEthPoolRepository -import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext /** @@ -38,6 +44,8 @@ import kotlinx.coroutines.withContext internal class DefaultP2PEthPoolRepository( private val p2pEthPoolApi: P2PEthPoolApi, private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore, + private val p2pVaultLimitsStore: P2PVaultLimitsStore, + private val tangemTechApi: TangemTechApi, private val dispatchers: CoroutineDispatcherProvider, private val stakingFeatureToggles: StakingFeatureToggles, ) : P2PEthPoolRepository { @@ -183,21 +191,32 @@ internal class DefaultP2PEthPoolRepository( } override fun getStakingAvailability(): Flow { - return getVaultsFlow() - .distinctUntilChanged() - .map { vaults -> - if (vaults.isEmpty()) { - return@map StakingAvailability.TemporaryUnavailable - } else { - StakingAvailability.Available(StakingOption.P2PEthPool(vaults)) + return combine( + getVaultsFlow().distinctUntilChanged(), + getVaultLimitsFlow().distinctUntilChanged(), + ) { vaults, limits -> + when { + vaults.isEmpty() -> StakingAvailability.TemporaryUnavailable + limits == null -> StakingAvailability.TemporaryUnavailable + else -> { + val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) + if (integration.areAllTargetsFull) { + StakingAvailability.Unavailable + } else { + StakingAvailability.Available(StakingOption.P2PEthPool(vaults)) + } } } + }.distinctUntilChanged() } override suspend fun getStakingAvailabilitySync(): StakingAvailability { val vaults = getVaultsSync() - return if (vaults.isEmpty()) { - StakingAvailability.TemporaryUnavailable + if (vaults.isEmpty()) return StakingAvailability.TemporaryUnavailable + val limits = getVaultLimitsSyncOrNull() ?: return StakingAvailability.TemporaryUnavailable + val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) + return if (integration.areAllTargetsFull) { + StakingAvailability.Unavailable } else { StakingAvailability.Available(StakingOption.P2PEthPool(vaults)) } @@ -206,4 +225,33 @@ internal class DefaultP2PEthPoolRepository( override suspend fun getVaultsSync(): List { return p2pEthPoolVaultsStore.getSync() } + + override suspend fun fetchVaultLimits() { + runSuspendCatching { + val response = withContext(dispatchers.io) { + tangemTechApi.getCoinsSettings().getOrThrow() + } + val vaults = response.staking?.vaults.orEmpty() + val limits = vaults + .mapNotNull { vault -> + val limit = vault.limit ?: return@mapNotNull null + vault.vaultAddress.lowercase() to VaultLimitInfo( + limit = limit, + coefficient = vault.coefficient, + ) + } + .toMap() + p2pVaultLimitsStore.store(limits) + }.onFailure { e -> + TangemLogger.e("Error fetching P2P vault limits: ${e.message}", e) + } + } + + override fun getVaultLimitsFlow(): Flow?> { + return p2pVaultLimitsStore.get() + } + + override suspend fun getVaultLimitsSyncOrNull(): Map? { + return p2pVaultLimitsStore.getSyncOrNull() + } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index a997cbce26..44bd4e1dc0 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -12,9 +12,11 @@ import com.tangem.data.staking.utils.DefaultStakingCleaner import com.tangem.datasource.api.ethpool.P2PEthPoolApi import com.tangem.datasource.api.stakekit.StakeKitApi import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.P2PEthPoolVaultsStore +import com.tangem.datasource.local.token.P2PVaultLimitsStore import com.tangem.datasource.local.token.StakingActionsStore import com.tangem.datasource.local.token.StakingYieldsStore import com.tangem.domain.staking.StakingIdFactory @@ -77,12 +79,16 @@ internal object StakingDataModule { fun provideP2PEthPoolRepository( p2pEthPoolApi: P2PEthPoolApi, p2pEthPoolVaultsStore: P2PEthPoolVaultsStore, + p2pVaultLimitsStore: P2PVaultLimitsStore, + tangemTechApi: TangemTechApi, dispatchers: CoroutineDispatcherProvider, stakingFeatureToggles: StakingFeatureToggles, ): P2PEthPoolRepository { return DefaultP2PEthPoolRepository( p2pEthPoolApi = p2pEthPoolApi, p2pEthPoolVaultsStore = p2pEthPoolVaultsStore, + p2pVaultLimitsStore = p2pVaultLimitsStore, + tangemTechApi = tangemTechApi, dispatchers = dispatchers, stakingFeatureToggles = stakingFeatureToggles, ) diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/P2PEthPoolVaultFilterTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/P2PEthPoolVaultFilterTest.kt index c3bd1c75af..e1c7c97324 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/P2PEthPoolVaultFilterTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/P2PEthPoolVaultFilterTest.kt @@ -7,7 +7,9 @@ import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolNetworkDTO import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultDTO import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultsResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.token.P2PEthPoolVaultsStore +import com.tangem.datasource.local.token.P2PVaultLimitsStore import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork import com.tangem.domain.staking.toggles.StakingFeatureToggles @@ -30,12 +32,16 @@ internal class P2PEthPoolVaultFilterTest { private val api = mockk() private val store = mockk(relaxed = true) + private val limitsStore = mockk(relaxed = true) + private val tangemTechApi = mockk(relaxed = true) private val featureToggles = mockk { every { isIntegrationEnabled(StakingIntegrationID.P2PEthPool) } returns true } private val repository = DefaultP2PEthPoolRepository( p2pEthPoolApi = api, p2pEthPoolVaultsStore = store, + p2pVaultLimitsStore = limitsStore, + tangemTechApi = tangemTechApi, dispatchers = TestingCoroutineDispatcherProvider(), stakingFeatureToggles = featureToggles, ) diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/VaultLimitInfo.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/VaultLimitInfo.kt new file mode 100644 index 0000000000..efa9fb18c9 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/VaultLimitInfo.kt @@ -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?, +) \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingOptionsUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingOptionsUseCase.kt index 2573289397..402690ee26 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingOptionsUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingOptionsUseCase.kt @@ -26,6 +26,7 @@ class FetchStakingOptionsUseCase( coroutineScope { launch { stakeKitRepository.fetchYields() } launch { p2pEthPoolRepository.fetchVaults() } + launch { p2pEthPoolRepository.fetchVaultLimits() } } }, catch = { stakingErrorResolver.resolve(it) }, diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt index cf61efdcff..1b68a63e0c 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt @@ -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, + private val vaultLimits: Map, ) : StakingIntegration { // Basic @@ -30,9 +33,11 @@ class P2PEthPoolIntegration( vault.toStakingTarget() } - override val preferredTargets: List = targets + override val preferredTargets: List = 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" diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt index f0f34da571..847d405a6f 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt @@ -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 + /** + * 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?> + + /** + * Get cached vault limits synchronously. + * + * @return Map from vaultAddress.lowercase() to VaultLimitInfo, null if not yet fetched + */ + suspend fun getVaultLimitsSyncOrNull(): Map? + /** * Check P2PEthPool staking availability by finding public vault * diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/P2PEthPoolIntegrationTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/P2PEthPoolIntegrationTest.kt new file mode 100644 index 0000000000..29ea60484e --- /dev/null +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/P2PEthPoolIntegrationTest.kt @@ -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) = + 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() + 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")) + } + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt index b69c0ad6d6..8e58cc711c 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt @@ -174,17 +174,17 @@ internal open class BaseActionsFactory( protected fun createStakingAction( currency: CryptoCurrency, stakingAvailability: StakingAvailability, - ): ActionState.Stake { - return if (stakingAvailability is StakingAvailability.Available) { - ActionState.Stake( + ): ActionState.Stake? { + return when (stakingAvailability) { + is StakingAvailability.Available -> ActionState.Stake( unavailabilityReason = ScenarioUnavailabilityReason.None, option = stakingAvailability.option, ) - } else { - ActionState.Stake( + StakingAvailability.TemporaryUnavailable -> ActionState.Stake( unavailabilityReason = ScenarioUnavailabilityReason.StakingUnavailable(currency.name), option = null, ) + StakingAvailability.Unavailable -> null } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt index 73d73a599f..4d9837958f 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt @@ -81,7 +81,7 @@ internal class CommonActionsFactory( // region Stake createStakingAction(currency = cryptoCurrencyStatus.currency, stakingAvailability = stakingAvailability) - .addByReason() + ?.addByReason() // endregion val sendUnavailabilityReason = sendUnavailabilityReasonDeferred.await() diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt index 9c5659e1d0..49084a3a21 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt @@ -97,7 +97,7 @@ internal class OutdatedDataActionsFactory( stakingAvailability = stakingAvailability, ) - stakingAction.addByReason() + stakingAction?.addByReason() } else { val stakingAction = ActionState.Stake( unavailabilityReason = ScenarioUnavailabilityReason.UsedOutdatedData, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt index bd7e22b2e4..77b81313fe 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt @@ -22,8 +22,8 @@ import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.EarnNetworks import com.tangem.domain.models.earn.EarnTokenWithCurrency -import com.tangem.domain.models.earn.PreselectedEarnType import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.domain.models.earn.PreselectedEarnType import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams.Companion.CategoryEarn import com.tangem.features.feed.components.earn.DefaultEarnComponent import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 4928b3356f..8afe46564e 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -182,7 +182,8 @@ internal class StakingModel @Inject constructor( } StakingIntegrationID.P2PEthPool -> { val vaults = p2pEthPoolRepository.getVaultsSync() - P2PEthPoolIntegration(integrationId, vaults) + val limits = p2pEthPoolRepository.getVaultLimitsSyncOrNull().orEmpty() + P2PEthPoolIntegration(integrationId, vaults, limits) } } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt index f9a8b1cf23..2f2e7914e2 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt @@ -57,6 +57,7 @@ internal sealed class StakingStates { val yieldBalance: InnerYieldBalanceState, val pullToRefreshConfig: PullToRefreshConfig, val legalUrls: LegalUrls, + val areAllTargetsFull: Boolean = false, ) : InitialInfoState() data class LegalUrls( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt index b67b009796..99177bb796 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt @@ -166,7 +166,11 @@ internal class SetButtonsStateTransformer( val hasNotStaking = initialState?.yieldBalance == InnerYieldBalanceState.Empty val isCardano = BlockchainUtils.isCardano(cryptoCurrencyBlockchainId) - return !hasNotStaking && isCardano && currentStep == StakingStep.InitialInfo + if (!hasNotStaking && isCardano && currentStep == StakingStep.InitialInfo) return true + + val hasStaking = initialState?.yieldBalance is InnerYieldBalanceState.Data + val areAllTargetsFull = initialState?.areAllTargetsFull == true + return hasStaking && areAllTargetsFull && currentStep == StakingStep.InitialInfo } private fun StakingUiState.isApprovalRequired(): Boolean { diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index bb57a75f24..a4276d56a6 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -110,6 +110,7 @@ internal class SetInitialDataStateTransformer( termsOfServiceUrl = integration.legalUrls.termsOfServiceUrl, privacyPolicyUrl = integration.legalUrls.privacyPolicyUrl, ), + areAllTargetsFull = integration.areAllTargetsFull, ) } diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt index 7e192c45b1..fbb6e9cca6 100644 --- a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt @@ -133,6 +133,7 @@ internal class StakingModelTransactionTest : StakingModelTestBase() { integrationId = StakingIntegrationID.P2PEthPool, ) coEvery { p2pEthPoolRepository.getVaultsSync() } returns emptyList() + coEvery { p2pEthPoolRepository.getVaultLimitsSyncOrNull() } returns emptyMap() val uiStateFlow = MutableStateFlow(initialUiState) every { stateController.uiState } returns uiStateFlow coEvery { @@ -218,6 +219,7 @@ internal class StakingModelTransactionTest : StakingModelTestBase() { integrationId = StakingIntegrationID.P2PEthPool, ) coEvery { p2pEthPoolRepository.getVaultsSync() } returns emptyList() + coEvery { p2pEthPoolRepository.getVaultLimitsSyncOrNull() } returns emptyMap() val uiStateFlow = MutableStateFlow(initialUiState) every { stateController.uiState } returns uiStateFlow coEvery {