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,20 @@
package com.tangem.tap.network.auth
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.lib.auth.P2PEthPoolAuthProvider
internal class DefaultP2PEthPoolAuthProvider(
private val environmentConfigStorage: EnvironmentConfigStorage,
) : P2PEthPoolAuthProvider {
override fun getApiKey(): String {
// val keys = environmentConfigStorage.getConfigSync().p2pApiKey
// ?: error("No P2P api keys provided")
//
// return keys.mainnet
environmentConfigStorage
return "TODO restore p2p config call after release 5.30"
}
}

View file

@ -6,10 +6,12 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.lib.auth.StakeKitAuthProvider
import com.tangem.tap.network.auth.DefaultAppVersionProvider
import com.tangem.tap.network.auth.DefaultAuthProvider
import com.tangem.tap.network.auth.DefaultExpressAuthProvider
import com.tangem.tap.network.auth.DefaultP2PEthPoolAuthProvider
import com.tangem.tap.network.auth.DefaultStakeKitAuthProvider
import com.tangem.utils.version.AppVersionProvider
import dagger.Module
@ -50,6 +52,12 @@ internal class AuthModule {
return DefaultStakeKitAuthProvider(environmentConfigStorage)
}
@Provides
@Singleton
fun provideP2PEthPoolAuthProvider(environmentConfigStorage: EnvironmentConfigStorage): P2PEthPoolAuthProvider {
return DefaultP2PEthPoolAuthProvider(environmentConfigStorage)
}
@Provides
@Singleton
fun provideAppVersionProvider(): AppVersionProvider {

View file

@ -24,6 +24,7 @@ sealed class ApiConfig {
Express,
TangemTech,
StakeKit,
P2PEthPool,
TangemPay,
BlockAid,
YieldSupply,
@ -35,6 +36,7 @@ sealed class ApiConfig {
is Express -> ID.Express
is TangemTech -> ID.TangemTech
is StakeKit -> ID.StakeKit
is P2PEthPool -> ID.P2PEthPool
is TangemPay -> ID.TangemPay
is BlockAid -> ID.BlockAid
is YieldSupply -> ID.YieldSupply

View file

@ -0,0 +1,63 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.utils.ProviderSuspend
/**
* P2P.org Ethereum Pooled Staking API configuration
*/
internal class P2PEthPool(
private val p2pAuthProvider: P2PEthPoolAuthProvider,
) : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createProdEnvironment(),
createTestEnvironment(),
createMockEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createProdEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.p2p.org/",
headers = createHeaders(),
)
}
private fun createTestEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = "https://api-test.p2p.org/",
headers = createHeaders(),
)
}
private fun createMockEnvironment(): ApiEnvironmentConfig {
return ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = "[REDACTED_ENV_URL]",
headers = createHeaders(),
)
}
private fun createHeaders() = buildMap {
put(key = "Authorization", value = ProviderSuspend { "Bearer ${p2pAuthProvider.getApiKey()}" })
put(key = "accept", value = ProviderSuspend { "application/json" })
put(key = "Content-Type", value = ProviderSuspend { "application/json" })
}
}

View file

@ -0,0 +1,119 @@
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.response.*
import retrofit2.http.*
/**
* P2P.org Ethereum Pooled Staking API client
*
* Documentation: https://docs.p2p.org/
*
* Base URL: https://api.p2p.org (prod) / https://api-test.p2p.org (testnet)
*/
interface P2PEthPoolApi {
/**
* Get list of available vaults
*
* @param network Ethereum pool network: "mainnet" or "hoodi" (testnet)
*/
@GET("api/v1/staking/pool/{network}/vaults")
suspend fun getVaults(
@Path("network") network: String = "mainnet",
): ApiResponse<P2PEthPoolResponse<P2PEthPoolVaultsResponse>>
/**
* Prepare deposit transaction
*
* 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)
*/
@POST("api/v1/staking/pool/{network}/staking/deposit")
suspend fun createDepositTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolDepositRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolDepositResponse>>
/**
* Prepare unstake transaction
*
* 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)
*/
@POST("api/v1/staking/pool/{network}/staking/unstake")
suspend fun createUnstakeTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolUnstakeRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolUnstakeResponse>>
/**
* Prepare withdrawal transaction
*
* Create unsigned transaction to withdraw available funds from exit queue.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Withdrawal parameters (staker address)
*/
@POST("api/v1/staking/pool/{network}/staking/withdraw")
suspend fun createWithdrawTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolWithdrawRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolWithdrawResponse>>
/**
* Broadcast signed transaction
*
* Submit a signed transaction to the blockchain network.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param body Signed transaction in hexadecimal format
*/
@POST("api/v1/staking/pool/{network}/transaction/send")
suspend fun broadcastTransaction(
@Path("network") network: String,
@Body body: P2PEthPoolBroadcastRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolBroadcastResponse>>
/**
* Get account summary
*
* Retrieve staking balance, rewards, and exit queue information for a specific account and vault.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param delegatorAddress Account address that initiated staking
* @param vaultAddress Ethereum address of the vault
*/
@GET("api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}")
suspend fun getAccountInfo(
@Path("network") network: String,
@Path("delegatorAddress") delegatorAddress: String,
@Path("vaultAddress") vaultAddress: String,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolAccountResponse>>
/**
* Get rewards history
*
* Retrieve historical rewards data for a specific account and vault.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param delegatorAddress Account address that initiated staking
* @param vaultAddress Ethereum address of the vault
* @param period Optional period filter (30, 60, or 90 days)
*/
@GET("api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards")
suspend fun getRewards(
@Path("network") network: String,
@Path("delegatorAddress") delegatorAddress: String,
@Path("vaultAddress") vaultAddress: String,
@Query("period") period: Int? = null,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolRewardsResponse>>
}

View file

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

View file

@ -0,0 +1,19 @@
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,20 @@
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

@ -0,0 +1,15 @@
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

@ -0,0 +1,54 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/**
* Response for GET /api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolAccountResponse(
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "stake")
val stake: P2PEthPoolStakeDTO,
@Json(name = "availableToUnstake")
val availableToUnstake: BigDecimal,
@Json(name = "availableToWithdraw")
val availableToWithdraw: BigDecimal,
@Json(name = "exitQueue")
val exitQueue: P2PEthPoolExitQueueDTO,
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolStakeDTO(
@Json(name = "assets")
val assets: BigDecimal,
@Json(name = "totalEarnedAssets")
val totalEarnedAssets: BigDecimal,
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolExitQueueDTO(
@Json(name = "total")
val total: Double,
@Json(name = "requests")
val requests: List<P2PEthPoolExitRequestDTO>,
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolExitRequestDTO(
@Json(name = "ticket")
val ticket: String,
@Json(name = "totalAssets")
val totalAssets: Double,
@Json(name = "timestamp")
val timestamp: Long,
@Json(name = "withdrawalTimestamp")
val withdrawalTimestamp: Long,
@Json(name = "isClaimable")
val isClaimable: Boolean,
)

View file

@ -0,0 +1,41 @@
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}/transaction/send
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolBroadcastResponse(
@Json(name = "hash")
val hash: String,
@Json(name = "status")
val status: P2PEthPoolTxStatusDTO,
@Json(name = "blockNumber")
val blockNumber: Int,
@Json(name = "transactionIndex")
val transactionIndex: Int,
@Json(name = "gasUsed")
val gasUsed: String,
@Json(name = "cumulativeGasUsed")
val cumulativeGasUsed: String,
@Json(name = "effectiveGasPrice")
val effectiveGasPrice: String?,
@Json(name = "from")
val from: String,
@Json(name = "to")
val to: String,
)
/**
* Transaction status from P2P API
*/
@JsonClass(generateAdapter = false)
enum class P2PEthPoolTxStatusDTO {
@Json(name = "success")
SUCCESS,
@Json(name = "failed")
FAILED,
}

View file

@ -0,0 +1,22 @@
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

@ -0,0 +1,29 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Error response structure for P2P.org API
*
* All P2P API endpoints return errors in this format
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolErrorResponse(
@Json(name = "error")
val error: P2PEthPoolErrorDetailsDTO,
@Json(name = "result")
val result: Any? = null, // null on error
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolErrorDetailsDTO(
@Json(name = "code")
val code: Int, // Error code (e.g., 127106, 101111)
@Json(name = "message")
val message: String, // Human-readable error message
@Json(name = "name")
val name: String, // Error name/type
@Json(name = "errors")
val errors: List<String>? = null, // Optional validation errors array
)

View file

@ -0,0 +1,25 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Unified response wrapper for all P2P.org API responses
*
* All P2P API endpoints return responses in this format:
* ```json
* {
* "error": null | { code, message, name, errors },
* "result": { ... } | null
* }
* ```
*
* @param T The type of the result data
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolResponse<T>(
@Json(name = "error")
val error: P2PEthPoolErrorDetailsDTO?,
@Json(name = "result")
val result: T?,
)

View file

@ -0,0 +1,31 @@
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 GET /api/v1/staking/pool/{network}/account/{delegatorAddress}/vault/{vaultAddress}/rewards
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolRewardsResponse(
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "rewards")
val rewards: List<P2PEthPoolRewardDTO>,
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolRewardDTO(
@Json(name = "date")
val date: DateTime,
@Json(name = "apy")
val apy: Double,
@Json(name = "balance")
val balance: BigDecimal,
@Json(name = "rewards")
val rewards: BigDecimal,
)

View file

@ -0,0 +1,32 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/**
* Unsigned transaction structure
*
* Used in deposit, withdraw responses
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolUnsignedTxDTO(
@Json(name = "serializeTx")
val serializeTx: String,
@Json(name = "to")
val to: String,
@Json(name = "data")
val data: String,
@Json(name = "value")
val value: String,
@Json(name = "nonce")
val nonce: Int,
@Json(name = "chainId")
val chainId: Int,
@Json(name = "gasLimit")
val gasLimit: BigDecimal,
@Json(name = "maxFeePerGas")
val maxFeePerGas: BigDecimal,
@Json(name = "maxPriorityFeePerGas")
val maxPriorityFeePerGas: BigDecimal,
)

View file

@ -0,0 +1,22 @@
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

@ -0,0 +1,60 @@
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 GET /api/v1/staking/pool/{network}/vaults
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolVaultsResponse(
@Json(name = "network")
val network: P2PEthPoolNetworkDTO,
@Json(name = "vaults")
val vaults: List<P2PEthPoolVaultDTO>,
)
/**
* Network identifier in P2P API
*/
@JsonClass(generateAdapter = false)
enum class P2PEthPoolNetworkDTO {
@Json(name = "mainnet")
MAINNET,
@Json(name = "hoodi")
HOODI,
}
@JsonClass(generateAdapter = true)
data class P2PEthPoolVaultDTO(
@Json(name = "vaultAddress")
val vaultAddress: String,
@Json(name = "displayName")
val displayName: String,
@Json(name = "apy")
val apy: Double,
@Json(name = "baseApy")
val baseApy: Double,
@Json(name = "capacity")
val capacity: Double,
@Json(name = "totalAssets")
val totalAssets: Double,
@Json(name = "feePercent")
val feePercent: Double,
@Json(name = "isPrivate")
val isPrivate: Boolean,
@Json(name = "isGenesis")
val isGenesis: Boolean,
@Json(name = "isSmoothingPool")
val isSmoothingPool: Boolean,
@Json(name = "isErc20")
val isErc20: Boolean,
@Json(name = "tokenName")
val tokenName: String?,
@Json(name = "tokenSymbol")
val tokenSymbol: String?,
@Json(name = "createdAt")
val createdAt: DateTime,
)

View file

@ -0,0 +1,24 @@
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/withdraw
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolWithdrawResponse(
@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,
@Json(name = "tickets")
val tickets: List<String>,
)

View file

@ -4,6 +4,7 @@ import com.tangem.datasource.api.common.AuthProvider
import com.tangem.datasource.api.common.config.*
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.lib.auth.StakeKitAuthProvider
import com.tangem.utils.info.AppInfoProvider
import com.tangem.utils.version.AppVersionProvider
@ -39,6 +40,12 @@ internal object ApiConfigsModule {
return StakeKit(stakeKitAuthProvider)
}
@Provides
@IntoSet
fun provideP2PEthPoolConfig(p2pAuthProvider: P2PEthPoolAuthProvider): ApiConfig {
return P2PEthPool(p2pAuthProvider)
}
@Provides
@IntoSet
fun provideTangemTechConfig(

View file

@ -14,6 +14,7 @@ import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.markets.TangemTechMarketsApi
import com.tangem.datasource.api.moonpay.MoonPayApi
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
@ -73,6 +74,15 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun provideP2PEthPoolApi(retrofitApiBuilder: RetrofitApiBuilder): P2PEthPoolApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.P2PEthPool,
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideOnrampApi(retrofitApiBuilder: RetrofitApiBuilder): OnrampApi {

View file

@ -16,6 +16,7 @@ data class EnvironmentConfig(
val express: ExpressModel? = null,
val devExpress: ExpressModel? = null,
val stakeKitApiKey: String? = null,
// val p2pApiKey: P2PKeys? = null, TODO p2p after release 5.30
val blockAidApiKey: String? = null,
val tangemApiKey: String? = null,
val tangemApiKeyDev: String? = null,

View file

@ -25,6 +25,7 @@ internal object EnvironmentConfigConverter : Converter<EnvironmentConfigModel, E
express = value.express,
devExpress = value.devExpress,
stakeKitApiKey = value.stakeKitApiKey,
// p2pApiKey = value.p2pApiKey, TODO p2p after release 5.30
blockAidApiKey = value.blockaidApiKey,
tangemApiKey = value.tangemApiKey,
tangemApiKeyDev = value.tangemApiKeyDev,

View file

@ -33,6 +33,7 @@ class EnvironmentConfigModel(
@Json(name = "hederaArkhiaKey") val hederaArkhiaKey: String?,
@Json(name = "polygonScanApiKey") val polygonScanApiKey: String?,
@Json(name = "stakeKitApiKey") val stakeKitApiKey: String?,
// @Json(name = "p2pApiKey") val p2pApiKey: P2PKeys?, TODO p2p after release 5.30
@Json(name = "bittensorDwellirKey") val bittensorDwellirApiKey: String?,
@Json(name = "bittensorOnfinalityKey") val bittensorOnfinalityKey: String?,
@Json(name = "koinosProApiKey") val koinosProApiKey: String?,
@ -97,6 +98,12 @@ data class TonCenterKeys(
@Json(name = "testnet") val testnet: String,
)
@JsonClass(generateAdapter = true)
data class P2PKeys(
@Json(name = "mainnet") val mainnet: String,
@Json(name = "hoodi") val hoodi: String,
)
@JsonClass(generateAdapter = true)
data class GetBlockToken(
@Json(name = "jsonRpc") val jsonRPC: String?,

View file

@ -72,6 +72,7 @@ class ApiConfigTest {
ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = mockk())
ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk())
ApiConfig.ID.MoonPay -> MoonPay()
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk())
}
}
}

View file

@ -14,6 +14,7 @@ import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.BLOCK_AID_API_KEY
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_API_KEY
import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.lib.auth.StakeKitAuthProvider
import com.tangem.utils.ProviderSuspend
import com.tangem.utils.info.AppInfoProvider
@ -39,6 +40,7 @@ internal class ProdApiConfigsManagerTest {
private val appVersionProvider = mockk<AppVersionProvider>()
private val expressAuthProvider = mockk<ExpressAuthProvider>()
private val stakeKitAuthProvider = mockk<StakeKitAuthProvider>()
private val p2pEthPoolAuthProvider = mockk<P2PEthPoolAuthProvider>()
private val appAuthProvider = mockk<AuthProvider>()
private val appInfoProvider = mockk<AppInfoProvider>()
private val tangemApiKeyProvider = mockk<ProviderSuspend<String>>()
@ -58,6 +60,7 @@ internal class ProdApiConfigsManagerTest {
every { appVersionProvider.versionName } returns VERSION_NAME
every { expressAuthProvider.getSessionId() } returns EXPRESS_SESSION_ID
every { stakeKitAuthProvider.getApiKey() } returns STAKE_KIT_API_KEY
every { p2pEthPoolAuthProvider.getApiKey() } returns P2P_API_KEY
every { appAuthProvider.getApiKey(any()) } returns tangemApiKeyProvider
coEvery { tangemApiKeyProvider.invoke() } returns TANGEM_API_KEY
coEvery { appAuthProvider.getCardId() } returns APP_CARD_ID
@ -110,6 +113,7 @@ internal class ProdApiConfigsManagerTest {
ApiConfig.ID.TangemPay -> TangemPay(appVersionProvider = appVersionProvider)
ApiConfig.ID.BlockAid -> BlockAid(configStorage = environmentConfigStorage)
ApiConfig.ID.MoonPay -> MoonPay()
ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider)
}
}
}
@ -123,6 +127,7 @@ internal class ProdApiConfigsManagerTest {
ApiConfig.ID.TangemPay -> createTangemPayModel()
ApiConfig.ID.BlockAid -> createBlockAidSdkModel()
ApiConfig.ID.MoonPay -> createMoonPayModel()
ApiConfig.ID.P2PEthPool -> createP2PModel()
}
}
@ -275,6 +280,21 @@ internal class ProdApiConfigsManagerTest {
)
}
private fun createP2PModel(): TestModel {
return TestModel(
id = ApiConfig.ID.P2PEthPool,
expected = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = "https://api.p2p.org/",
headers = mapOf(
"Authorization" to ProviderSuspend { "Bearer $P2P_API_KEY" },
"accept" to ProviderSuspend { "application/json" },
"Content-Type" to ProviderSuspend { "application/json" },
),
),
)
}
private fun String.checkHeaderValueOrEmpty(): String {
for (i in this.indices) {
val c = this[i]
@ -293,6 +313,7 @@ internal class ProdApiConfigsManagerTest {
const val VERSION_NAME = "debug"
const val EXPRESS_SESSION_ID = "express_session_id"
const val STAKE_KIT_API_KEY = "stake_kit_api_key"
const val P2P_API_KEY = "p2p_api_key"
const val APP_CARD_ID = "app_card_id"
const val APP_CARD_PUBLIC_KEY = "Bearer app_public_key"
}

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(

View file

@ -0,0 +1,44 @@
package com.tangem.domain.staking.model.ethpool
import com.tangem.domain.models.serialization.SerializedBigDecimal
import org.joda.time.Instant
/**
* P2P.org 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

@ -0,0 +1,100 @@
package com.tangem.domain.staking.model.ethpool
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
import org.joda.time.DateTime
/**
* P2P.org staking action
* Represents a staking operation (deposit, unstake, withdraw)
*/
data class P2PEthPoolAction(
val id: String,
val type: P2PEthPoolActionType,
val status: P2PEthPoolActionStatus,
val amount: SerializedBigDecimal,
val vaultAddress: String,
val delegatorAddress: String,
val transaction: P2PEthPoolStakingTransaction?,
val createdAt: DateTime?,
val completedAt: DateTime?,
val metadata: P2PEthPoolActionMetadata?,
)
/**
* Types of P2P staking actions
*/
@Serializable
enum class P2PEthPoolActionType {
DEPOSIT,
UNSTAKE,
WITHDRAW,
CLAIM_REWARDS,
}
/**
* Status of P2P staking action
*/
@Serializable
enum class P2PEthPoolActionStatus {
CREATED,
WAITING_FOR_SIGNATURE,
PROCESSING,
CONFIRMED,
FAILED,
CANCELLED,
}
/**
* Transaction details for P2P staking action
*/
data class P2PEthPoolStakingTransaction(
val id: String?,
val unsignedTransaction: P2PEthPoolUnsignedTxDetails?,
val signedTransaction: String?,
val status: P2PEthPoolTxStatus,
val gasEstimate: P2PEthPoolGasEstimate?,
val error: String?,
)
/**
* Unsigned transaction details
*/
data class P2PEthPoolUnsignedTxDetails(
val to: String,
val data: String,
val value: SerializedBigDecimal,
val nonce: Int,
val chainId: Int,
)
/**
* Gas estimation for transaction
*/
data class P2PEthPoolGasEstimate(
val gasLimit: SerializedBigDecimal,
val maxFeePerGas: SerializedBigDecimal,
val maxPriorityFeePerGas: SerializedBigDecimal,
)
/**
* Transaction status
*/
@Serializable
enum class P2PEthPoolTxStatus {
UNSIGNED,
SIGNED,
PENDING,
CONFIRMED,
FAILED,
}
/**
* Additional metadata for action
*/
data class P2PEthPoolActionMetadata(
val ticket: String?,
val exitQueuePosition: Int?,
val estimatedCompletionDate: DateTime?,
val unstakeFee: SerializedBigDecimal?,
)

View file

@ -0,0 +1,55 @@
package com.tangem.domain.staking.model.ethpool
import com.tangem.domain.models.serialization.SerializedBigDecimal
import org.joda.time.Instant
/**
* P2P.org staking balance information (similar to StakeKit's YieldBalanceItem)
* Contains staked amounts, rewards, and pending actions
*/
data class P2PEthPoolStakingBalance(
val vaultAddress: String,
val delegatorAddress: String,
val items: List<P2PEthPoolBalanceItem>,
val network: P2PEthPoolNetwork,
)
/**
* Individual balance item for P2P staking
*/
data class P2PEthPoolBalanceItem(
val type: P2PEthPoolBalanceType,
val amount: SerializedBigDecimal,
val rawAmount: String?,
val date: Instant?,
val pendingAction: P2PEthPoolPendingAction?,
)
/**
* Types of balances in P2P staking
*/
enum class P2PEthPoolBalanceType {
STAKED,
REWARDS,
UNSTAKING,
WITHDRAWABLE,
LOCKED,
}
/**
* Pending action information
*/
data class P2PEthPoolPendingAction(
val type: P2PEthPoolPendingActionType,
val ticket: String?,
val estimatedDate: Instant?,
val isClaimable: Boolean,
)
/**
* Types of pending actions
*/
enum class P2PEthPoolPendingActionType {
UNSTAKE_PENDING,
WITHDRAWAL_AVAILABLE,
}

View file

@ -0,0 +1,29 @@
package com.tangem.domain.staking.model.ethpool
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
/**
* P2P.org transaction broadcast result
* Contains transaction confirmation details
*/
data class P2PEthPoolBroadcastResult(
val hash: String,
val status: P2PEthPoolBroadcastStatus,
val blockNumber: Int,
val transactionIndex: Int,
val gasUsed: SerializedBigDecimal,
val cumulativeGasUsed: SerializedBigDecimal,
val effectiveGasPrice: SerializedBigDecimal?,
val from: String,
val to: String,
)
/**
* Transaction broadcast status
*/
@Serializable
enum class P2PEthPoolBroadcastStatus {
SUCCESS, // Transaction confirmed successfully
FAILED, // Transaction failed
}

View file

@ -0,0 +1,67 @@
package com.tangem.domain.staking.model.ethpool
/**
* P2P.org supported networks for pooled staking
*
* @property value Network identifier used in API requests
* @property displayName Human-readable network name
* @property chainId Ethereum chain ID
*/
enum class P2PEthPoolNetwork(
val value: String,
val displayName: String,
val chainId: Int,
) {
/**
* Ethereum mainnet
* Chain ID: 1
*/
MAINNET(
value = "mainnet",
displayName = "Ethereum",
chainId = 1,
),
/**
* Ethereum testnet (Holesky)
* Chain ID: 17000
*/
TESTNET(
value = "hoodi",
displayName = "Holesky Testnet",
chainId = 17000,
),
;
companion object {
/**
* Get P2PNetwork by chain ID
*
* @param chainId Ethereum chain ID
* @return P2PNetwork or null if not supported
*/
fun fromChainId(chainId: Int): P2PEthPoolNetwork? {
return entries.find { it.chainId == chainId }
}
/**
* Get P2PNetwork by API value
*
* @param value API network identifier
* @return P2PNetwork or null if not found
*/
fun fromValue(value: String): P2PEthPoolNetwork? {
return entries.find { it.value == value }
}
/**
* Check if chain ID is supported for P2P staking
*
* @param chainId Ethereum chain ID
* @return true if supported, false otherwise
*/
fun isSupported(chainId: Int): Boolean {
return fromChainId(chainId) != null
}
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.staking.model.ethpool
import com.tangem.domain.models.serialization.SerializedBigDecimal
import org.joda.time.DateTime
/**
* P2P.org rewards history entry
* Historical reward information for account
*/
data class P2PEthPoolReward(
val date: DateTime,
val apy: SerializedBigDecimal,
val balance: SerializedBigDecimal,
val rewards: SerializedBigDecimal,
)

View file

@ -0,0 +1,77 @@
package com.tangem.domain.staking.model.ethpool
import com.tangem.domain.models.serialization.SerializedBigDecimal
/**
* P2P.org pooled staking information (similar to StakeKit's Yield)
* Contains vault details, APY, status, and metadata
*/
data class P2PEthPoolStaking(
val id: String, // Vault address used as unique ID
val vault: P2PEthPoolVaultDetails,
val status: Status,
val apy: SerializedBigDecimal,
val metadata: Metadata,
val isAvailable: Boolean,
val network: P2PEthPoolNetwork,
) {
data class Status(
val enter: Boolean, // Can deposit
val exit: Boolean, // Can unstake/withdraw
)
data class Metadata(
val name: String,
val description: String?,
val logoUri: String?,
val documentation: String?,
val cooldownPeriod: Period?, // Exit queue waiting time
val warmupPeriod: Period?, // Time before rewards start
val minimumStake: SerializedBigDecimal?,
val maximumStake: SerializedBigDecimal?,
val fee: Fee,
val rewardSchedule: RewardSchedule,
val rewardClaiming: RewardClaiming,
) {
data class Period(
val days: Int,
)
data class Fee(
val enabled: Boolean,
val percent: SerializedBigDecimal?,
)
enum class RewardSchedule {
DAILY,
WEEKLY,
MONTHLY,
CONTINUOUS,
UNKNOWN,
}
enum class RewardClaiming {
AUTO, // Automatically compounded
MANUAL, // Need to claim manually
UNKNOWN,
}
}
}
/**
* Detailed vault information for P2P staking
*/
data class P2PEthPoolVaultDetails(
val vaultAddress: String,
val displayName: String,
val tokenSymbol: String,
val capacity: SerializedBigDecimal,
val totalAssets: SerializedBigDecimal,
val isPrivate: Boolean,
val isGenesis: Boolean,
val isSmoothingPool: Boolean,
val isErc20: Boolean,
val createdAt: String?,
)

View file

@ -0,0 +1,19 @@
package com.tangem.domain.staking.model.ethpool
import com.tangem.domain.models.serialization.SerializedBigDecimal
/**
* P2P.org unsigned transaction ready for signing
* Contains all necessary data for transaction signing
*/
data class P2PEthPoolUnsignedTx(
val serializeTx: String,
val to: String,
val data: String,
val value: SerializedBigDecimal,
val nonce: Int,
val chainId: Int,
val gasLimit: SerializedBigDecimal,
val maxFeePerGas: SerializedBigDecimal,
val maxPriorityFeePerGas: SerializedBigDecimal,
)

View file

@ -0,0 +1,25 @@
package com.tangem.domain.staking.model.ethpool
import com.tangem.domain.models.serialization.SerializedBigDecimal
import org.joda.time.DateTime
/**
* P2P.org pooled staking vault information
* Simplified version for vault list display
*/
data class P2PEthPoolVault(
val vaultAddress: String,
val displayName: String,
val apy: SerializedBigDecimal,
val baseApy: SerializedBigDecimal,
val capacity: SerializedBigDecimal,
val totalAssets: SerializedBigDecimal,
val feePercent: SerializedBigDecimal,
val isPrivate: Boolean,
val isGenesis: Boolean,
val isSmoothingPool: Boolean,
val isErc20: Boolean,
val tokenName: String?,
val tokenSymbol: String?,
val createdAt: DateTime,
)

View file

@ -24,6 +24,16 @@ sealed class StakingError {
// endregion
// region p2p errors
data class InvalidAmount(val message: String) : StakingError()
data class DataError(val exception: Throwable) : StakingError()
data class UnknownError(val exception: Throwable) : StakingError()
// endregion
data class DomainError(val message: String?) : StakingError()
}

View file

@ -0,0 +1,112 @@
package com.tangem.domain.staking.repositories
import arrow.core.Either
import com.tangem.domain.staking.model.ethpool.*
import com.tangem.domain.staking.model.stakekit.StakingError
/**
* P2P staking repository interface
*/
interface P2PEthPoolRepository {
/**
* Get list of available staking vaults
*
* @param network P2P network (MAINNET or TESTNET)
* @return Either error or list of vaults with APY, capacity, fees
*/
suspend fun getVaults(
network: P2PEthPoolNetwork = P2PEthPoolNetwork.MAINNET,
): Either<StakingError, List<P2PEthPoolVault>>
/**
* Create unsigned transaction for depositing ETH into a vault
*
* @param network P2P network (MAINNET or TESTNET)
* @param delegatorAddress User's wallet address
* @param vaultAddress Vault contract address
* @param amount Amount of ETH to deposit
* @return Either error or unsigned transaction ready to sign
*/
suspend fun createDepositTransaction(
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
amount: String,
): Either<StakingError, P2PEthPoolUnsignedTx>
/**
* Create unsigned transaction to initiate unstaking
*
* Unstaking adds funds to exit queue. After ~1-4 days, use [createWithdrawTransaction]
* to withdraw the funds.
*
* @param network P2P network (MAINNET or TESTNET)
* @param stakerPublicKey Staker's public key (note: API doc may have Bitcoin terminology)
* @param stakeTransactionHash Original stake transaction hash
* @return Either error or unsigned transaction
*/
suspend fun createUnstakeTransaction(
network: P2PEthPoolNetwork,
stakerPublicKey: String,
stakeTransactionHash: String,
): Either<StakingError, P2PEthPoolUnsignedTx>
/**
* Create unsigned transaction to withdraw funds from exit queue
*
* Only works when funds are available (after exit queue wait period).
*
* @param network P2P network (MAINNET or TESTNET)
* @param stakerAddress User's wallet address
* @return Either error or unsigned transaction with withdrawal tickets
*/
suspend fun createWithdrawTransaction(
network: P2PEthPoolNetwork,
stakerAddress: String,
): Either<StakingError, P2PEthPoolUnsignedTx>
/**
* Broadcast signed transaction to blockchain
*
* @param network P2P network (MAINNET or TESTNET)
* @param signedTransaction Signed transaction in hex format (with 0x prefix)
* @return Either error or broadcast result with transaction hash
*/
suspend fun broadcastTransaction(
network: P2PEthPoolNetwork,
signedTransaction: String,
): Either<StakingError, P2PEthPoolBroadcastResult>
/**
* Get account staking information for specific vault
*
* Returns current stake, rewards, exit queue status, and available amounts
*
* @param network P2P network (MAINNET or TESTNET)
* @param delegatorAddress User's wallet address
* @param vaultAddress Vault contract address
* @return Either error or account info
*/
suspend fun getAccountInfo(
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
): Either<StakingError, P2PEthPoolAccount>
/**
* Get rewards history for account and vault
*
* @param network P2P network (MAINNET or TESTNET)
* @param delegatorAddress User's wallet address
* @param vaultAddress Vault contract address
* @param period Optional period filter in days (30, 60, or 90)
* @return Either error or list of reward entries
*/
suspend fun getRewards(
network: P2PEthPoolNetwork,
delegatorAddress: String,
vaultAddress: String,
period: Int? = null,
): Either<StakingError, List<P2PEthPoolReward>>
}

View file

@ -0,0 +1,6 @@
package com.tangem.lib.auth
interface P2PEthPoolAuthProvider {
fun getApiKey(): String
}