Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-29 15:05:35 +02:00
parent 721ab042e0
commit 7d599256d1
27 changed files with 366 additions and 454 deletions

View file

@ -35,7 +35,7 @@ object MockP2PEthPoolAccountResponseFactory {
availableToUnstake = stakedAmount,
availableToWithdraw = BigDecimal.ZERO,
exitQueue = P2PEthPoolExitQueueDTO(
total = 0.0,
total = BigDecimal.ZERO,
requests = emptyList(),
),
)
@ -55,7 +55,7 @@ object MockP2PEthPoolAccountResponseFactory {
availableToUnstake = BigDecimal.ZERO,
availableToWithdraw = BigDecimal.ZERO,
exitQueue = P2PEthPoolExitQueueDTO(
total = 0.0,
total = BigDecimal.ZERO,
requests = emptyList(),
),
)

View file

@ -2,9 +2,7 @@ package com.tangem.datasource.api.ethpool
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolDepositRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolUnstakeRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolWithdrawRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionRequest
import com.tangem.datasource.api.ethpool.models.response.*
import retrofit2.http.*
@ -31,13 +29,13 @@ interface P2PEthPoolApi {
* Create unsigned transaction for depositing ETH into a vault.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Deposit parameters (delegator address, vault address, amount)
* @param body Transaction parameters (delegator address, vault address, amount)
*/
@POST("api/v1/staking/pool/{network}/staking/deposit")
suspend fun createDepositTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolDepositRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolDepositResponse>>
@Body body: P2PEthPoolTransactionRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolTransactionResponse>>
/**
* Prepare unstake transaction
@ -45,13 +43,13 @@ interface P2PEthPoolApi {
* Create unsigned transaction to initiate unstaking process.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Unstake parameters (staker public key, stake transaction hash)
* @param body Transaction parameters (delegator address, vault address, amount)
*/
@POST("api/v1/staking/pool/{network}/staking/unstake")
suspend fun createUnstakeTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolUnstakeRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolUnstakeResponse>>
@Body body: P2PEthPoolTransactionRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolTransactionResponse>>
/**
* Prepare withdrawal transaction
@ -59,13 +57,13 @@ interface P2PEthPoolApi {
* Create unsigned transaction to withdraw available funds from exit queue.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Withdrawal parameters (staker address)
* @param body Transaction parameters (delegator address, vault address, amount)
*/
@POST("api/v1/staking/pool/{network}/staking/withdraw")
suspend fun createWithdrawTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolWithdrawRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolWithdrawResponse>>
@Body body: P2PEthPoolTransactionRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolTransactionResponse>>
/**
* Broadcast signed transaction
@ -107,6 +105,7 @@ interface P2PEthPoolApi {
* @param vaultAddress Ethereum address of the vault
* @param period Optional period filter (30, 60, or 90 days)
*/
// TODO p2p not used, consider removing this method
@GET("api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards")
suspend fun getRewards(
@Path("network") network: String,

View file

@ -1,19 +0,0 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for creating deposit transaction
*
* Used in: POST /api/v1/staking/pool/{network}/staking/deposit
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolDepositRequest(
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "amount")
val amount: Double,
)

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/**
* Unified request body for creating staking transactions (deposit, unstake, withdraw)
*
* Used in:
* - POST /api/v1/staking/pool/{network}/staking/deposit
* - POST /api/v1/staking/pool/{network}/staking/unstake
* - POST /api/v1/staking/pool/{network}/staking/withdraw
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolTransactionRequest(
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "amount")
val amount: BigDecimal,
)

View file

@ -1,20 +0,0 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for creating unstake transaction
*
* Used in: POST /api/v1/staking/pool/{network}/staking/unstake
*
* Note: Documentation seems to contain Bitcoin-related fields (possibly copy-paste error).
* Using as-is per specification.
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolUnstakeRequest(
@Json(name = "stakerPublicKey")
val stakerPublicKey: String,
@Json(name = "stakeTransactionHash")
val stakeTransactionHash: String,
)

View file

@ -1,15 +0,0 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for creating withdrawal transaction
*
* Used in: POST /api/v1/staking/pool/{network}/staking/withdraw
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolWithdrawRequest(
@Json(name = "stakerAddress")
val stakerAddress: String,
)

View file

@ -34,7 +34,7 @@ data class P2PEthPoolStakeDTO(
@JsonClass(generateAdapter = true)
data class P2PEthPoolExitQueueDTO(
@Json(name = "total")
val total: Double,
val total: BigDecimal,
@Json(name = "requests")
val requests: List<P2PEthPoolExitRequestDTO>,
)
@ -44,11 +44,11 @@ data class P2PEthPoolExitRequestDTO(
@Json(name = "ticket")
val ticket: String,
@Json(name = "totalAssets")
val totalAssets: Double,
val totalAssets: BigDecimal,
@Json(name = "timestamp")
val timestamp: Long,
@Json(name = "withdrawalTimestamp")
val withdrawalTimestamp: Long,
val withdrawalTimestamp: Long?,
@Json(name = "isClaimable")
val isClaimable: Boolean,
)

View file

@ -1,22 +0,0 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.joda.time.DateTime
/**
* Response for POST /api/v1/staking/pool/{network}/staking/deposit
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolDepositResponse(
@Json(name = "amount")
val amount: Double,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "unsignedTransaction")
val unsignedTransaction: P2PEthPoolUnsignedTxDTO,
@Json(name = "createdAt")
val createdAt: DateTime,
)

View file

@ -3,14 +3,20 @@ package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.joda.time.DateTime
import java.math.BigDecimal
/**
* Response for POST /api/v1/staking/pool/{network}/staking/withdraw
* Unified response for staking transactions (deposit, unstake, withdraw)
*
* Response for:
* - POST /api/v1/staking/pool/{network}/staking/deposit
* - POST /api/v1/staking/pool/{network}/staking/unstake
* - POST /api/v1/staking/pool/{network}/staking/withdraw
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolWithdrawResponse(
data class P2PEthPoolTransactionResponse(
@Json(name = "amount")
val amount: Double,
val amount: BigDecimal,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "delegatorAddress")
@ -20,5 +26,5 @@ data class P2PEthPoolWithdrawResponse(
@Json(name = "createdAt")
val createdAt: DateTime,
@Json(name = "tickets")
val tickets: List<String>,
val tickets: List<String>? = null,
)

View file

@ -1,22 +0,0 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response for POST /api/v1/staking/pool/{network}/staking/unstake
*
* Note: Contains Bitcoin-related fields (likely documentation error).
* Using as-is per specification.
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolUnstakeResponse(
@Json(name = "stakerPublicKey")
val stakerPublicKey: String,
@Json(name = "stakeTransactionHash")
val stakeTransactionHash: String,
@Json(name = "unstakeTransactionHex")
val unstakeTransactionHex: String, // unsigned
@Json(name = "unstakeFee")
val unstakeFee: Double,
)

View file

@ -38,6 +38,8 @@ internal object NetworkModule {
private const val TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS = 60L
private const val STAKE_KIT_API_TIMEOUT_SECONDS = 60L
private const val P2P_ETH_POOL_API_TIMEOUT_SECONDS = 60L
@Provides
@Singleton
fun provideApiConfigManager(
@ -82,6 +84,12 @@ internal object NetworkModule {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.P2PEthPool,
applyTimeoutAnnotations = false,
timeouts = Timeouts(
callTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS,
connectTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS,
readTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS,
writeTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS,
),
)
}

View file

@ -2,21 +2,32 @@ package com.tangem.data.staking
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.Raise
import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.data.staking.converters.ethpool.*
import com.tangem.data.staking.converters.ethpool.P2PEthPoolBroadcastResultConverter
import com.tangem.data.staking.converters.ethpool.P2PEthPoolErrorConverter
import com.tangem.data.staking.converters.ethpool.P2PEthPoolRewardConverter
import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingAccountConverter
import com.tangem.data.staking.converters.ethpool.P2PEthPoolUnsignedTxConverter
import com.tangem.data.staking.converters.ethpool.P2PEthPoolVaultConverter
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolDepositRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolUnstakeRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolWithdrawRequest
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.local.token.P2PEthPoolVaultsStore
import com.tangem.domain.models.staking.P2PEthPoolStakingAccount
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.ethpool.*
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult
import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork
import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward
import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
@ -36,11 +47,30 @@ internal class DefaultP2PEthPoolRepository(
) : P2PEthPoolRepository {
private val vaultConverter = P2PEthPoolVaultConverter
private val accountInfoConverter = P2PEthPoolAccountConverter
private val accountConverter = P2PEthPoolStakingAccountConverter
private val rewardConverter = P2PEthPoolRewardConverter
private val broadcastResultConverter = P2PEthPoolBroadcastResultConverter
private val errorConverter = P2PEthPoolErrorConverter
/**
* Handles P2PEthPool API response with error checking and result extraction.
* Reduces duplication across all API call methods.
*/
private inline fun <T, R> Raise<StakingError>.handleApiResponse(
response: ApiResponse<P2PEthPoolResponse<T>>,
transform: (T) -> R,
): R = when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
transform(result)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
override suspend fun fetchVaults(network: P2PEthPoolNetwork) {
val vaults = if (stakingFeatureToggles.isEthStakingEnabled) {
getVaults(network).getOrElse { error ->
@ -56,17 +86,9 @@ internal class DefaultP2PEthPoolRepository(
override suspend fun getVaults(network: P2PEthPoolNetwork): Either<StakingError, List<P2PEthPoolVault>> = either {
withContext(dispatchers.io) {
when (val response = p2pEthPoolApi.getVaults(network.value)) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
handleApiResponse(p2pEthPoolApi.getVaults(network.value)) { result ->
result.vaults.map { vaultConverter.convert(it) }
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
@ -75,82 +97,61 @@ internal class DefaultP2PEthPoolRepository(
delegatorAddress: String,
vaultAddress: String,
amount: String,
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolDepositRequest(
): Either<StakingError, P2PEthPoolUnsignedTx> = createStakingTransaction(
network = network,
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
amount = amount.toDoubleOrNull() ?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")),
amount = amount,
apiCall = p2pEthPoolApi::createDepositTransaction,
)
val response = p2pEthPoolApi.createDepositTransaction(network.value, requestBody)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
override suspend fun createUnstakeTransaction(
network: P2PEthPoolNetwork,
stakerPublicKey: String,
stakeTransactionHash: String,
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolUnstakeRequest(
stakerPublicKey = stakerPublicKey,
stakeTransactionHash = stakeTransactionHash,
delegatorAddress: String,
vaultAddress: String,
amount: String,
): Either<StakingError, P2PEthPoolUnsignedTx> = createStakingTransaction(
network = network,
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
amount = amount,
apiCall = p2pEthPoolApi::createUnstakeTransaction,
)
val response = p2pEthPoolApi.createUnstakeTransaction(network.value, requestBody)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
// Note: API returns only hex string for unstake, not full transaction structure
P2PEthPoolUnsignedTx(
serializeTx = result.unstakeTransactionHex,
to = "", // Will be parsed from hex by wallet
data = result.unstakeTransactionHex,
value = java.math.BigDecimal.ZERO,
nonce = 0,
chainId = network.chainId,
gasLimit = java.math.BigDecimal.ZERO,
maxFeePerGas = java.math.BigDecimal.ZERO,
maxPriorityFeePerGas = java.math.BigDecimal.ZERO,
)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
override suspend fun createWithdrawTransaction(
network: P2PEthPoolNetwork,
stakerAddress: String,
delegatorAddress: String,
vaultAddress: String,
amount: String,
): Either<StakingError, P2PEthPoolUnsignedTx> = createStakingTransaction(
network = network,
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
amount = amount,
apiCall = p2pEthPoolApi::createWithdrawTransaction,
)
private suspend fun createStakingTransaction(
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
amount: String,
apiCall:
suspend (
String,
P2PEthPoolTransactionRequest,
) -> ApiResponse<P2PEthPoolResponse<P2PEthPoolTransactionResponse>>,
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolWithdrawRequest(stakerAddress = stakerAddress)
val response = p2pEthPoolApi.createWithdrawTransaction(network.value, requestBody)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
val requestBody = P2PEthPoolTransactionRequest(
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
amount = amount.toBigDecimalOrNull()
?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")),
)
handleApiResponse(apiCall(network.value, requestBody)) { result ->
P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
@ -160,18 +161,9 @@ internal class DefaultP2PEthPoolRepository(
): Either<StakingError, P2PEthPoolBroadcastResult> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolBroadcastRequest(signedTransaction = signedTransaction)
val response = p2pEthPoolApi.broadcastTransaction(network.value, requestBody)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
handleApiResponse(p2pEthPoolApi.broadcastTransaction(network.value, requestBody)) { result ->
broadcastResultConverter.convert(result)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
@ -179,19 +171,12 @@ internal class DefaultP2PEthPoolRepository(
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
): Either<StakingError, P2PEthPoolAccount> = either {
): Either<StakingError, P2PEthPoolStakingAccount> = either {
withContext(dispatchers.io) {
val response = p2pEthPoolApi.getAccountInfo(network.value, delegatorAddress, vaultAddress)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
accountInfoConverter.convert(result)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
handleApiResponse(
p2pEthPoolApi.getAccountInfo(network.value, delegatorAddress, vaultAddress),
) { result ->
accountConverter.convert(result)
}
}
}
@ -203,23 +188,16 @@ internal class DefaultP2PEthPoolRepository(
period: Int?,
): Either<StakingError, List<P2PEthPoolReward>> = either {
withContext(dispatchers.io) {
val response = p2pEthPoolApi.getRewards(
handleApiResponse(
p2pEthPoolApi.getRewards(
network = network.value,
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
period = period,
)
when (response) {
is ApiResponse.Success -> {
val data = response.data
ensure(data.error == null) {
errorConverter.convertFromErrorDetails(requireNotNull(data.error))
}
val result = requireNotNull(data.result) { "Result is null in successful response" }
),
) { result ->
result.rewards.map { rewardConverter.convert(it) }
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}

View file

@ -4,17 +4,20 @@ import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountRespon
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitQueueDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitRequestDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolStakeDTO
import com.tangem.domain.staking.model.ethpool.*
import com.tangem.domain.models.staking.P2PEthPoolExitQueue
import com.tangem.domain.models.staking.P2PEthPoolExitRequest
import com.tangem.domain.models.staking.P2PEthPoolStake
import com.tangem.domain.models.staking.P2PEthPoolStakingAccount
import com.tangem.utils.converter.Converter
import org.joda.time.Instant
import kotlinx.datetime.Instant
/**
* Converter from P2PEthPool Account Info Response to Domain model
* Converts P2PEthPool Account API response to domain [P2PEthPoolStakingAccount].
*/
internal object P2PEthPoolAccountConverter : Converter<P2PEthPoolAccountResponse, P2PEthPoolAccount> {
internal object P2PEthPoolStakingAccountConverter : Converter<P2PEthPoolAccountResponse, P2PEthPoolStakingAccount> {
override fun convert(value: P2PEthPoolAccountResponse): P2PEthPoolAccount {
return P2PEthPoolAccount(
override fun convert(value: P2PEthPoolAccountResponse): P2PEthPoolStakingAccount {
return P2PEthPoolStakingAccount(
delegatorAddress = value.delegatorAddress,
vaultAddress = value.vaultAddress,
stake = convertStake(value.stake),
@ -24,26 +27,26 @@ internal object P2PEthPoolAccountConverter : Converter<P2PEthPoolAccountResponse
)
}
private fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake {
fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake {
return P2PEthPoolStake(
assets = dto.assets,
totalEarnedAssets = dto.totalEarnedAssets,
)
}
private fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PEthPoolExitQueue {
fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PEthPoolExitQueue {
return P2PEthPoolExitQueue(
total = dto.total.toBigDecimal(),
total = dto.total,
requests = dto.requests.map(::convertExitRequest),
)
}
private fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PEthPoolExitRequest {
fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PEthPoolExitRequest {
return P2PEthPoolExitRequest(
ticket = dto.ticket,
totalAssets = dto.totalAssets.toBigDecimal(),
timestamp = Instant.ofEpochSecond(dto.timestamp),
withdrawalTimestamp = Instant.ofEpochSecond(dto.withdrawalTimestamp),
totalAssets = dto.totalAssets,
timestamp = Instant.fromEpochMilliseconds(dto.timestamp),
withdrawalTimestamp = dto.withdrawalTimestamp?.let { Instant.fromEpochMilliseconds(it) },
isClaimable = dto.isClaimable,
)
}

View file

@ -1,16 +1,17 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitQueueDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolExitRequestDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolStakeDTO
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.*
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.staking.model.StakingIntegrationID
import kotlinx.datetime.Instant
import java.math.BigDecimal
/** Converts P2PEthPool API response to [StakingBalance] */
/**
* Converts P2PEthPool API response to [StakingBalance].
*
* Uses [P2PEthPoolStakingAccountConverter] for account conversion to avoid duplication.
*/
internal object P2PEthPoolStakingBalanceConverter {
fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance {
@ -19,14 +20,7 @@ internal object P2PEthPoolStakingBalanceConverter {
address = response.delegatorAddress,
)
val account = P2PEthPoolStakingAccount(
delegatorAddress = response.delegatorAddress,
vaultAddress = response.vaultAddress,
stake = convertStake(response.stake),
availableToUnstake = response.availableToUnstake,
availableToWithdraw = response.availableToWithdraw,
exitQueue = convertExitQueue(response.exitQueue),
)
val account = P2PEthPoolStakingAccountConverter.convert(response)
val hasActivePosition = account.stake.assets > BigDecimal.ZERO ||
account.exitQueue.total > BigDecimal.ZERO ||
@ -45,28 +39,4 @@ internal object P2PEthPoolStakingBalanceConverter {
)
}
}
private fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake {
return P2PEthPoolStake(
assets = dto.assets,
totalEarnedAssets = dto.totalEarnedAssets,
)
}
private fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PEthPoolExitQueue {
return P2PEthPoolExitQueue(
total = dto.total.toBigDecimal(),
requests = dto.requests.map(::convertExitRequest),
)
}
private fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PEthPoolExitRequest {
return P2PEthPoolExitRequest(
ticket = dto.ticket,
totalAssets = dto.totalAssets.toBigDecimal(),
timestamp = Instant.fromEpochSeconds(dto.timestamp),
withdrawalTimestamp = Instant.fromEpochSeconds(dto.withdrawalTimestamp),
isClaimable = dto.isClaimable,
)
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.data.staking.store
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* Base interface for staking balances stores.
*
* Defines common read/query operations shared by all staking provider stores.
*/
interface BaseStakingBalancesStore {
/** Get flow of staking balances for a wallet */
fun get(userWalletId: UserWalletId): Flow<Set<StakingBalance>>
/** Get a single staking balance synchronously */
suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance?
/** Get all staking balances for a wallet synchronously */
suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<StakingBalance>?
/** Refresh a single staking balance from cache */
suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID)
/** Refresh multiple staking balances from cache */
suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
/** Store error state for staking balances */
suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
/** Clear staking balances */
suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
}

View file

@ -1,31 +1,19 @@
package com.tangem.data.staking.store
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/**
* Store for P2PEthPool staking balances
* Store for P2PEthPool staking balances.
*
* Extends [BaseStakingBalancesStore] with P2PEthPool-specific storage operations.
*/
interface P2PEthPoolBalancesStore {
fun get(userWalletId: UserWalletId): Flow<Set<StakingBalance>>
suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance?
suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<StakingBalance>?
suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID)
suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
interface P2PEthPoolBalancesStore : BaseStakingBalancesStore {
/** Store actual P2PEthPool account balances */
suspend fun storeActual(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>)
/** Store empty state for accounts with no active positions */
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

@ -1,27 +1,15 @@
package com.tangem.data.staking.store
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
/** Store of StakeKit [StakingBalance] */
interface StakingBalancesStore {
fun get(userWalletId: UserWalletId): Flow<Set<StakingBalance>>
suspend fun getSyncOrNull(userWalletId: UserWalletId, stakingId: StakingID): StakingBalance?
suspend fun getAllSyncOrNull(userWalletId: UserWalletId): Set<StakingBalance>?
suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID)
suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
/**
* Store for StakeKit staking balances.
*
* Extends [BaseStakingBalancesStore] with StakeKit-specific storage operations.
*/
interface StakingBalancesStore : BaseStakingBalancesStore {
/** Store actual StakeKit yield balances */
suspend fun storeActual(userWalletId: UserWalletId, values: Set<YieldBalanceWrapperDTO>)
suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
}

View file

@ -32,6 +32,6 @@ data class P2PEthPoolExitRequest(
val ticket: String,
val totalAssets: SerializedBigDecimal,
val timestamp: Instant,
val withdrawalTimestamp: Instant,
val withdrawalTimestamp: Instant?,
val isClaimable: Boolean,
)

View file

@ -5,27 +5,37 @@ import java.math.BigDecimal
fun P2PEthPoolStakingAccount.toStakingBalanceEntries(vaultName: String? = null): List<StakingBalanceEntry> {
return buildList {
if (stake.assets > BigDecimal.ZERO) {
add(
StakingBalanceEntry(
add(createStakedEntry(vaultAddress, stake.assets, vaultName))
}
exitQueue.requests.filter { !it.isClaimable }.forEach { add(createUnstakingEntry(vaultAddress, it, vaultName)) }
if (availableToWithdraw > BigDecimal.ZERO) {
add(createWithdrawableEntry(vaultAddress, availableToWithdraw, vaultName))
}
if (stake.totalEarnedAssets > BigDecimal.ZERO) {
add(createRewardsEntry(vaultAddress, stake.totalEarnedAssets, vaultName))
}
}
}
private fun createStakedEntry(vaultAddress: String, amount: BigDecimal, vaultName: String?): StakingBalanceEntry {
return StakingBalanceEntry(
id = vaultAddress,
type = StakingEntryType.STAKED,
amount = stake.assets,
amount = amount,
validator = ValidatorInfo(address = vaultAddress, name = vaultName),
date = null,
actions = StakingEntryActions.P2PEthPool(
ticket = null,
estimatedWithdrawalDate = null,
isClaimable = false,
),
actions = StakingEntryActions.P2PEthPool(ticket = null, estimatedWithdrawalDate = null, isClaimable = false),
isPending = false,
rawCurrencyId = null,
),
)
}
exitQueue.requests.forEach { request ->
add(
StakingBalanceEntry(
private fun createUnstakingEntry(
vaultAddress: String,
request: P2PEthPoolExitRequest,
vaultName: String?,
): StakingBalanceEntry {
return StakingBalanceEntry(
id = "${vaultAddress}_${request.ticket}",
type = StakingEntryType.UNSTAKING,
amount = request.totalAssets,
@ -34,20 +44,22 @@ fun P2PEthPoolStakingAccount.toStakingBalanceEntries(vaultName: String? = null):
actions = StakingEntryActions.P2PEthPool(
ticket = request.ticket,
estimatedWithdrawalDate = request.withdrawalTimestamp,
isClaimable = request.isClaimable,
isClaimable = false,
),
isPending = false,
rawCurrencyId = null,
),
)
}
if (availableToWithdraw > BigDecimal.ZERO) {
add(
StakingBalanceEntry(
private fun createWithdrawableEntry(
vaultAddress: String,
amount: BigDecimal,
vaultName: String?,
): StakingBalanceEntry {
return StakingBalanceEntry(
id = "${vaultAddress}_withdrawable",
type = StakingEntryType.WITHDRAWABLE,
amount = availableToWithdraw,
amount = amount,
validator = ValidatorInfo(address = vaultAddress, name = vaultName),
date = null,
actions = StakingEntryActions.P2PEthPool(
@ -57,8 +69,18 @@ fun P2PEthPoolStakingAccount.toStakingBalanceEntries(vaultName: String? = null):
),
isPending = false,
rawCurrencyId = null,
),
)
}
}
private fun createRewardsEntry(vaultAddress: String, amount: BigDecimal, vaultName: String?): StakingBalanceEntry {
return StakingBalanceEntry(
id = "${vaultAddress}_rewards",
type = StakingEntryType.REWARDS,
amount = amount,
validator = ValidatorInfo(address = vaultAddress, name = vaultName),
date = null,
actions = StakingEntryActions.P2PEthPool(ticket = null, estimatedWithdrawalDate = null, isClaimable = false),
isPending = false,
rawCurrencyId = null,
)
}

View file

@ -1,44 +0,0 @@
package com.tangem.domain.staking.model.ethpool
import com.tangem.domain.models.serialization.SerializedBigDecimal
import org.joda.time.Instant
/**
* Account staking information
* Contains detailed balance and exit queue information
*/
data class P2PEthPoolAccount(
val delegatorAddress: String,
val vaultAddress: String,
val stake: P2PEthPoolStake,
val availableToUnstake: SerializedBigDecimal,
val availableToWithdraw: SerializedBigDecimal,
val exitQueue: P2PEthPoolExitQueue,
)
/**
* Current stake information
*/
data class P2PEthPoolStake(
val assets: SerializedBigDecimal,
val totalEarnedAssets: SerializedBigDecimal,
)
/**
* Exit queue information
*/
data class P2PEthPoolExitQueue(
val total: SerializedBigDecimal,
val requests: List<P2PEthPoolExitRequest>,
)
/**
* Individual exit request in the queue
*/
data class P2PEthPoolExitRequest(
val ticket: String,
val totalAssets: SerializedBigDecimal,
val timestamp: Instant,
val withdrawalTimestamp: Instant,
val isClaimable: Boolean,
)

View file

@ -15,7 +15,7 @@ import java.math.BigDecimal
*/
class P2PEthPoolIntegration(
override val integrationId: StakingIntegrationID,
vaults: List<P2PEthPoolVault>,
private val vaults: List<P2PEthPoolVault>,
) : StakingIntegration {
// Basic
@ -46,7 +46,7 @@ class P2PEthPoolIntegration(
amountRequirement = StakingAmountRequirement(
isRequired = true,
minimum = DEFAULT_MINIMUM_STAKE,
maximum = null,
maximum = calculateMaximumStakeAmount(),
),
isPartialAmountDisabled = false,
)
@ -75,6 +75,15 @@ class P2PEthPoolIntegration(
override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = token
private fun calculateMaximumStakeAmount(): BigDecimal? {
return vaults
.mapNotNull { vault ->
val availableCapacity = vault.capacity - vault.totalAssets
if (availableCapacity > BigDecimal.ZERO) availableCapacity else null
}
.maxOrNull()
}
companion object {
private const val MIN_COOLDOWN_DAYS = 1
private const val MAX_COOLDOWN_DAYS = 4

View file

@ -1,8 +1,8 @@
package com.tangem.domain.staking.repositories
import arrow.core.Either
import com.tangem.domain.models.staking.P2PEthPoolStakingAccount
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.ethpool.P2PEthPoolAccount
import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult
import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork
import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward
@ -54,14 +54,16 @@ interface P2PEthPoolRepository {
* to withdraw the funds.
*
* @param network P2PEthPool network (MAINNET or TESTNET)
* @param stakerPublicKey Staker's public key (note: API doc may have Bitcoin terminology)
* @param stakeTransactionHash Original stake transaction hash
* @param delegatorAddress User's wallet address
* @param vaultAddress Vault contract address
* @param amount Amount of ETH to unstake
* @return Either error or unsigned transaction
*/
suspend fun createUnstakeTransaction(
network: P2PEthPoolNetwork,
stakerPublicKey: String,
stakeTransactionHash: String,
delegatorAddress: String,
vaultAddress: String,
amount: String,
): Either<StakingError, P2PEthPoolUnsignedTx>
/**
@ -70,12 +72,16 @@ interface P2PEthPoolRepository {
* Only works when funds are available (after exit queue wait period).
*
* @param network P2PEthPool network (MAINNET or TESTNET)
* @param stakerAddress User's wallet address
* @param delegatorAddress User's wallet address
* @param vaultAddress Vault contract address
* @param amount Amount of ETH to withdraw
* @return Either error or unsigned transaction with withdrawal tickets
*/
suspend fun createWithdrawTransaction(
network: P2PEthPoolNetwork,
stakerAddress: String,
delegatorAddress: String,
vaultAddress: String,
amount: String,
): Either<StakingError, P2PEthPoolUnsignedTx>
/**
@ -104,7 +110,7 @@ interface P2PEthPoolRepository {
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
): Either<StakingError, P2PEthPoolAccount>
): Either<StakingError, P2PEthPoolStakingAccount>
/**
* Get rewards history for account and vault

View file

@ -27,7 +27,6 @@ import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.isTon
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.datetime.Instant
import java.math.BigDecimal
@ -197,7 +196,11 @@ internal class StakingBalanceEntryConverter(
private fun StakingBalanceEntry.getPendingActions(): List<PendingAction> {
return when (val actions = this.actions) {
is StakingEntryActions.StakeKit -> actions.pendingActions
is StakingEntryActions.P2PEthPool -> persistentListOf()
is StakingEntryActions.P2PEthPool -> if (type == StakingEntryType.WITHDRAWABLE) {
listOf(STUB_WITHDRAW_ACTION)
} else {
emptyList()
}
}
}
@ -223,6 +226,11 @@ internal class StakingBalanceEntryConverter(
}
private companion object {
val STUB_WITHDRAW_ACTION = PendingAction(
StakingActionType.WITHDRAW,
passthrough = "",
args = null,
)
const val DAY_IN_MILLIS = 24 * 60 * 60 * 1000
}
}

View file

@ -81,14 +81,7 @@ internal class YieldBalancesConverter(
private fun getRewardBlockType(stakingBalance: StakingBalance?): RewardBlockType {
val blockchainId = cryptoCurrencyStatus.currency.network.rawId
if (stakingBalance is StakingBalance.Data.P2PEthPool) {
return if (isStakingRewardUnavailable(blockchainId)) {
RewardBlockType.RewardUnavailable.DefaultRewardUnavailable
} else {
RewardBlockType.NoRewards
}
}
val isCoin = cryptoCurrencyStatus.currency.id.isCoin
val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit
val rewards = stakeKitBalance?.balance?.items
@ -98,7 +91,7 @@ internal class YieldBalancesConverter(
val isRewardsClaimable = rewards?.isNotEmpty() == true
return when {
isStakingRewardUnavailable(blockchainId) -> {
isStakingRewardUnavailable(blockchainId, isCoin) -> {
if (BlockchainUtils.isSolana(blockchainId)) {
RewardBlockType.RewardUnavailable.SolanaRewardUnavailable
} else {

View file

@ -75,13 +75,20 @@ internal class P2PEthPoolTransactionCreator @Inject constructor(
)
}
is StakingActionCommonType.Exit -> {
p2pEthPoolRepository.createWithdrawTransaction(
p2pEthPoolRepository.createUnstakeTransaction(
network = network,
stakerAddress = sourceAddress,
delegatorAddress = sourceAddress,
vaultAddress = vaultAddress,
amount = amount.toPlainString(),
)
}
is StakingActionCommonType.Pending -> {
Either.Left(StakingError.DomainError("Pending actions not supported for P2PEthPool"))
p2pEthPoolRepository.createWithdrawTransaction(
network = network,
delegatorAddress = sourceAddress,
vaultAddress = vaultAddress,
amount = amount.toPlainString(),
)
}
}
}

View file

@ -174,8 +174,12 @@ internal class TokenDetailsStakingInfoConverter(
private fun getRewardText(status: CryptoCurrencyStatus, stakingRewardAmount: BigDecimal?): TextReference {
val blockchainId = status.currency.network.rawId
val isCoin = status.currency.id.isCoin
val rewardBlockType = when {
isStakingRewardUnavailable(blockchainId) -> RewardBlockType.RewardUnavailable.DefaultRewardUnavailable
isStakingRewardUnavailable(blockchainId, isCoin) -> {
RewardBlockType.RewardUnavailable.DefaultRewardUnavailable
}
stakingRewardAmount.isNullOrZero() -> RewardBlockType.NoRewards
else -> RewardBlockType.Rewards
}

View file

@ -115,6 +115,11 @@ object BlockchainUtils {
return blockchain == Blockchain.BSC || blockchain == Blockchain.BSCTestnet
}
fun isEthereum(blockchainId: String): Boolean {
val blockchain = Blockchain.fromId(blockchainId)
return blockchain == Blockchain.Ethereum || blockchain == Blockchain.EthereumTestnet
}
data class BlockchainInfo(
val blockchainId: String,
val name: String,
@ -155,8 +160,10 @@ object BlockchainUtils {
return blockchain != Blockchain.Cardano
}
fun isStakingRewardUnavailable(blockchainId: String): Boolean {
return isSolana(blockchainId) || isBSC(blockchainId) || isTon(blockchainId)
fun isStakingRewardUnavailable(blockchainId: String, isCoin: Boolean): Boolean {
val isP2PEthPool = isEthereum(blockchainId) && isCoin
return isSolana(blockchainId) || isBSC(blockchainId) || isTon(blockchainId) || isP2PEthPool
}
/** Checks if the blockchain uses case-insensitive contract addresses */