Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-17 17:19:05 +02:00
parent 2722983c42
commit b8fc2d4f76
47 changed files with 1677 additions and 0 deletions

View file

@ -0,0 +1,197 @@
package com.tangem.data.staking
import arrow.core.Either
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.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.domain.staking.model.ethpool.*
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
/**
* P2P staking repository implementation
*/
internal class DefaultP2PEthPoolRepository(
private val p2pApi: P2PEthPoolApi,
private val dispatchers: CoroutineDispatcherProvider,
) : P2PEthPoolRepository {
private val vaultConverter = P2PEthPoolVaultConverter
private val accountInfoConverter = P2PEthPoolAccountConverter
private val rewardConverter = P2PEthPoolRewardConverter
private val broadcastResultConverter = P2PEthPoolBroadcastResultConverter
private val errorConverter = P2PEthPoolErrorConverter
override suspend fun getVaults(network: P2PEthPoolNetwork): Either<StakingError, List<P2PEthPoolVault>> = either {
withContext(dispatchers.io) {
val response = p2pApi.getVaults(network.value)
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.vaults.map { vaultConverter.convert(it) }
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
override suspend fun createDepositTransaction(
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
amount: String,
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolDepositRequest(
delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress,
amount = amount.toDoubleOrNull() ?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")),
)
val response = p2pApi.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,
)
val response = p2pApi.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,
): Either<StakingError, P2PEthPoolUnsignedTx> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolWithdrawRequest(stakerAddress = stakerAddress)
val response = p2pApi.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" }
P2PEthPoolUnsignedTxConverter.convert(result.unsignedTransaction)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
override suspend fun broadcastTransaction(
network: P2PEthPoolNetwork,
signedTransaction: String,
): Either<StakingError, P2PEthPoolBroadcastResult> = either {
withContext(dispatchers.io) {
val requestBody = P2PEthPoolBroadcastRequest(signedTransaction = signedTransaction)
val response = p2pApi.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" }
broadcastResultConverter.convert(result)
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
override suspend fun getAccountInfo(
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
): Either<StakingError, P2PEthPoolAccount> = either {
withContext(dispatchers.io) {
val response = p2pApi.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))
}
}
}
override suspend fun getRewards(
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
period: Int?,
): Either<StakingError, List<P2PEthPoolReward>> = either {
withContext(dispatchers.io) {
val response = p2pApi.getRewards(network.value, delegatorAddress, vaultAddress, 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.rewards.map { rewardConverter.convert(it) }
}
is ApiResponse.Error -> raise(StakingError.UnknownError(response.cause))
}
}
}
}

View file

@ -29,6 +29,13 @@ internal class DefaultStakingErrorResolver(
is StakingError.DomainError -> {
analyticsEventHandler.send(StakingAnalyticsEvent.DomainError(error))
}
// P2P errors
is StakingError.InvalidAmount,
is StakingError.DataError,
is StakingError.UnknownError,
-> {
// P2P errors - no specific analytics event yet
}
}
return error

View file

@ -0,0 +1,50 @@
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.staking.model.ethpool.*
import com.tangem.utils.converter.Converter
import org.joda.time.Instant
/**
* Converter from P2P Account Info Response to Domain model
*/
internal object P2PEthPoolAccountConverter : Converter<P2PEthPoolAccountResponse, P2PEthPoolAccount> {
override fun convert(value: P2PEthPoolAccountResponse): P2PEthPoolAccount {
return P2PEthPoolAccount(
delegatorAddress = value.delegatorAddress,
vaultAddress = value.vaultAddress,
stake = convertStake(value.stake),
availableToUnstake = value.availableToUnstake,
availableToWithdraw = value.availableToWithdraw,
exitQueue = convertExitQueue(value.exitQueue),
)
}
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.ofEpochSecond(dto.timestamp),
withdrawalTimestamp = Instant.ofEpochSecond(dto.withdrawalTimestamp),
isClaimable = dto.isClaimable,
)
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolBroadcastResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolTxStatusDTO
import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult
import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastStatus
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
/**
* Converter from P2P Broadcast Transaction Response to Domain model
*/
internal object P2PEthPoolBroadcastResultConverter : Converter<P2PEthPoolBroadcastResponse, P2PEthPoolBroadcastResult> {
override fun convert(value: P2PEthPoolBroadcastResponse): P2PEthPoolBroadcastResult {
return P2PEthPoolBroadcastResult(
hash = value.hash,
status = convertStatus(value.status),
blockNumber = value.blockNumber,
transactionIndex = value.transactionIndex,
gasUsed = value.gasUsed.toBigDecimalOrNull() ?: BigDecimal.ZERO,
cumulativeGasUsed = value.cumulativeGasUsed.toBigDecimalOrNull() ?: BigDecimal.ZERO,
effectiveGasPrice = value.effectiveGasPrice?.toBigDecimalOrNull(),
from = value.from,
to = value.to,
)
}
private fun convertStatus(status: P2PEthPoolTxStatusDTO): P2PEthPoolBroadcastStatus {
return when (status) {
P2PEthPoolTxStatusDTO.SUCCESS -> P2PEthPoolBroadcastStatus.SUCCESS
P2PEthPoolTxStatusDTO.FAILED -> P2PEthPoolBroadcastStatus.FAILED
}
}
}

View file

@ -0,0 +1,69 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolErrorDetailsDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolErrorResponse
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.utils.converter.Converter
/**
* Converter from P2P Error Response to Domain StakingError
*/
@Suppress("MagicNumber")
internal object P2PEthPoolErrorConverter : Converter<P2PEthPoolErrorResponse, StakingError> {
override fun convert(value: P2PEthPoolErrorResponse): StakingError {
return convertFromErrorDetails(value.error)
}
fun convertFromErrorDetails(details: P2PEthPoolErrorDetailsDTO): StakingError {
return when (details.code) {
// Authentication errors
101111 -> StakingError.UnknownError(
Exception("Missing Bearer token: ${details.message}"),
)
101109 -> StakingError.UnknownError(
Exception("Invalid Bearer token: ${details.message}"),
)
101110 -> StakingError.UnknownError(
Exception("Server authorization error: ${details.message}"),
)
// Validation errors
100101 -> StakingError.InvalidAmount(details.message)
// Withdrawal errors
127104 -> StakingError.DataError(
IllegalStateException("No withdrawable balance: ${details.message}"),
)
127105 -> StakingError.UnknownError(
Exception("Gas amount too low: ${details.message}"),
)
127106 -> StakingError.InvalidAmount("Invalid delegator address: ${details.message}")
127107 -> StakingError.UnknownError(
Exception("Gas price too low: ${details.message}"),
)
127108 -> StakingError.UnknownError(
Exception("Transaction simulation failed: ${details.message}"),
)
// Vault errors
127101 -> StakingError.DataError(
IllegalStateException("Invalid vault: ${details.message}"),
)
// Account errors
124108 -> StakingError.DataError(
IllegalStateException("Invalid delegator: ${details.message}"),
)
// Network errors
127100 -> StakingError.DataError(
IllegalStateException("Unsupported network: ${details.message}"),
)
else -> StakingError.UnknownError(
Exception("${details.code}: ${details.message}"),
)
}
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolRewardDTO
import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward
import com.tangem.utils.converter.Converter
/**
* Converter from P2P Reward Entry DTO to Domain model
*/
internal object P2PEthPoolRewardConverter : Converter<P2PEthPoolRewardDTO, P2PEthPoolReward> {
override fun convert(value: P2PEthPoolRewardDTO): P2PEthPoolReward {
return P2PEthPoolReward(
date = value.date,
apy = value.apy.toBigDecimal(),
balance = value.balance,
rewards = value.rewards,
)
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolUnsignedTxDTO
import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
/**
* Converter from P2P Unsigned Transaction DTO to Domain model
*/
internal object P2PEthPoolUnsignedTxConverter : Converter<P2PEthPoolUnsignedTxDTO, P2PEthPoolUnsignedTx> {
override fun convert(value: P2PEthPoolUnsignedTxDTO): P2PEthPoolUnsignedTx {
return P2PEthPoolUnsignedTx(
serializeTx = value.serializeTx,
to = value.to,
data = value.data,
value = value.value.toBigDecimalOrNull() ?: BigDecimal.ZERO,
nonce = value.nonce,
chainId = value.chainId,
gasLimit = value.gasLimit,
maxFeePerGas = value.maxFeePerGas,
maxPriorityFeePerGas = value.maxPriorityFeePerGas,
)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultDTO
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.utils.converter.Converter
/**
* Converter from P2P Vault DTO to Domain model
*/
internal object P2PEthPoolVaultConverter : Converter<P2PEthPoolVaultDTO, P2PEthPoolVault> {
override fun convert(value: P2PEthPoolVaultDTO): P2PEthPoolVault {
return P2PEthPoolVault(
vaultAddress = value.vaultAddress,
displayName = value.displayName,
apy = value.apy.toBigDecimal(),
baseApy = value.baseApy.toBigDecimal(),
capacity = value.capacity.toBigDecimal(),
totalAssets = value.totalAssets.toBigDecimal(),
feePercent = value.feePercent.toBigDecimal(),
isPrivate = value.isPrivate,
isGenesis = value.isGenesis,
isSmoothingPool = value.isSmoothingPool,
isErc20 = value.isErc20,
tokenName = value.tokenName,
tokenSymbol = value.tokenSymbol,
createdAt = value.createdAt,
)
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.data.staking.di
import com.squareup.moshi.Moshi
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.staking.DefaultP2PEthPoolRepository
import com.tangem.data.staking.DefaultStakingActionRepository
import com.tangem.data.staking.DefaultStakingErrorResolver
import com.tangem.data.staking.DefaultStakingRepository
@ -11,12 +12,14 @@ import com.tangem.data.staking.converters.error.StakeKitErrorConverter
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.toggles.DefaultStakingFeatureToggles
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.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.StakingActionsStore
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.repositories.StakingActionRepository
import com.tangem.domain.staking.repositories.StakingErrorResolver
import com.tangem.domain.staking.repositories.StakingRepository
@ -60,6 +63,18 @@ internal object StakingDataModule {
)
}
@Provides
@Singleton
fun provideP2PEthPoolRepository(
p2pApi: P2PEthPoolApi,
dispatchers: CoroutineDispatcherProvider,
): P2PEthPoolRepository {
return DefaultP2PEthPoolRepository(
p2pApi = p2pApi,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideStakingTransactionHashRepository(