From b8fc2d4f765e7a5842f9c0e226ff20d6e3416f9e Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 17 Nov 2025 17:19:05 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../auth/DefaultP2PEthPoolAuthProvider.kt | 20 ++ .../tangem/tap/network/auth/di/AuthModule.kt | 8 + .../datasource/api/common/config/ApiConfig.kt | 2 + .../api/common/config/P2PEthPool.kt | 63 ++++++ .../datasource/api/ethpool/P2PEthPoolApi.kt | 119 +++++++++++ .../request/P2PEthPoolBroadcastRequest.kt | 15 ++ .../request/P2PEthPoolDepositRequest.kt | 19 ++ .../request/P2PEthPoolUnstakeRequest.kt | 20 ++ .../request/P2PEthPoolWithdrawRequest.kt | 15 ++ .../response/P2PEthPoolAccountResponse.kt | 54 +++++ .../response/P2PEthPoolBroadcastResponse.kt | 41 ++++ .../response/P2PEthPoolDepositResponse.kt | 22 ++ .../response/P2PEthPoolErrorResponse.kt | 29 +++ .../models/response/P2PEthPoolResponse.kt | 25 +++ .../response/P2PEthPoolRewardsResponse.kt | 31 +++ .../response/P2PEthPoolUnsignedTxDTO.kt | 32 +++ .../response/P2PEthPoolUnstakeResponse.kt | 22 ++ .../response/P2PEthPoolVaultsResponse.kt | 60 ++++++ .../response/P2PEthPoolWithdrawResponse.kt | 24 +++ .../tangem/datasource/di/ApiConfigsModule.kt | 7 + .../com/tangem/datasource/di/NetworkModule.kt | 10 + .../config/environment/EnvironmentConfig.kt | 1 + .../converter/EnvironmentConfigConverter.kt | 1 + .../models/EnvironmentConfigModel.kt | 7 + .../api/common/config/ApiConfigTest.kt | 1 + .../managers/ProdApiConfigsManagerTest.kt | 21 ++ .../staking/DefaultP2PEthPoolRepository.kt | 197 ++++++++++++++++++ .../staking/DefaultStakingErrorResolver.kt | 7 + .../ethpool/P2PEthPoolAccountConverter.kt | 50 +++++ .../P2PEthPoolBroadcastResultConverter.kt | 35 ++++ .../ethpool/P2PEthPoolErrorConverter.kt | 69 ++++++ .../ethpool/P2PEthPoolRewardConverter.kt | 20 ++ .../ethpool/P2PEthPoolUnsignedTxConverter.kt | 26 +++ .../ethpool/P2PEthPoolVaultConverter.kt | 30 +++ .../data/staking/di/StakingDataModule.kt | 15 ++ .../model/ethpool/P2PEthPoolAccount.kt | 44 ++++ .../staking/model/ethpool/P2PEthPoolAction.kt | 100 +++++++++ .../model/ethpool/P2PEthPoolBalance.kt | 55 +++++ .../ethpool/P2PEthPoolBroadcastResult.kt | 29 +++ .../model/ethpool/P2PEthPoolNetwork.kt | 67 ++++++ .../staking/model/ethpool/P2PEthPoolReward.kt | 15 ++ .../model/ethpool/P2PEthPoolStaking.kt | 77 +++++++ .../model/ethpool/P2PEthPoolUnsignedTx.kt | 19 ++ .../staking/model/ethpool/P2PEthPoolVault.kt | 25 +++ .../staking/model/stakekit/StakingError.kt | 10 + .../repositories/P2PEthPoolRepository.kt | 112 ++++++++++ .../tangem/lib/auth/P2PEthPoolAuthProvider.kt | 6 + 47 files changed, 1677 insertions(+) create mode 100644 app/src/main/java/com/tangem/tap/network/auth/DefaultP2PEthPoolAuthProvider.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/common/config/P2PEthPool.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolBroadcastRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolBroadcastResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolDepositResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolErrorResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolRewardsResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnsignedTxDTO.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolWithdrawResponse.kt create mode 100644 data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt create mode 100644 data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt create mode 100644 data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolBroadcastResultConverter.kt create mode 100644 data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolErrorConverter.kt create mode 100644 data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt create mode 100644 data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolUnsignedTxConverter.kt create mode 100644 data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAction.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBalance.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBroadcastResult.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolNetwork.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStaking.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolUnsignedTx.kt create mode 100644 domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolVault.kt create mode 100644 domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt create mode 100644 libs/auth/src/main/java/com/tangem/lib/auth/P2PEthPoolAuthProvider.kt diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultP2PEthPoolAuthProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultP2PEthPoolAuthProvider.kt new file mode 100644 index 0000000000..d26fdea229 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/network/auth/DefaultP2PEthPoolAuthProvider.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt index f1ba2de154..b6f4c2bec8 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt @@ -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 { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index 6b642716dd..24cb41f229 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -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 diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/P2PEthPool.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/P2PEthPool.kt new file mode 100644 index 0000000000..b699172b24 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/P2PEthPool.kt @@ -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 = 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" }) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt new file mode 100644 index 0000000000..60503742bb --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/P2PEthPoolApi.kt @@ -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> + + /** + * 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> + + /** + * 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> + + /** + * 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> + + /** + * 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> + + /** + * 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> + + /** + * 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> +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolBroadcastRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolBroadcastRequest.kt new file mode 100644 index 0000000000..822438c42f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolBroadcastRequest.kt @@ -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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt new file mode 100644 index 0000000000..e4b3200add --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolDepositRequest.kt @@ -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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt new file mode 100644 index 0000000000..0e56cd5112 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolUnstakeRequest.kt @@ -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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt new file mode 100644 index 0000000000..d0a7aac5cf --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/request/P2PEthPoolWithdrawRequest.kt @@ -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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt new file mode 100644 index 0000000000..526c3c7d16 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolAccountResponse.kt @@ -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, +) + +@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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolBroadcastResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolBroadcastResponse.kt new file mode 100644 index 0000000000..20ec7b67f8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolBroadcastResponse.kt @@ -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, +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolDepositResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolDepositResponse.kt new file mode 100644 index 0000000000..ed922bf827 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolDepositResponse.kt @@ -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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolErrorResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolErrorResponse.kt new file mode 100644 index 0000000000..88235092a1 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolErrorResponse.kt @@ -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? = null, // Optional validation errors array +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolResponse.kt new file mode 100644 index 0000000000..e9fe22fd51 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolResponse.kt @@ -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( + @Json(name = "error") + val error: P2PEthPoolErrorDetailsDTO?, + @Json(name = "result") + val result: T?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolRewardsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolRewardsResponse.kt new file mode 100644 index 0000000000..ef4dd03ff0 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolRewardsResponse.kt @@ -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, +) + +@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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnsignedTxDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnsignedTxDTO.kt new file mode 100644 index 0000000000..b391bbebd9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnsignedTxDTO.kt @@ -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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt new file mode 100644 index 0000000000..a3e2391339 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolUnstakeResponse.kt @@ -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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt new file mode 100644 index 0000000000..6aee62ed0e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolVaultsResponse.kt @@ -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, +) + +/** + * 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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolWithdrawResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolWithdrawResponse.kt new file mode 100644 index 0000000000..1d5ff5bb4a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/ethpool/models/response/P2PEthPoolWithdrawResponse.kt @@ -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, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index d126accfa7..a8d59b580e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -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( diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 22c745744a..10c72e8fa6 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -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 { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt index 252782f20a..ec74644f39 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt @@ -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, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt index 5c43a7c952..834ee30126 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt @@ -25,6 +25,7 @@ internal object EnvironmentConfigConverter : Converter TangemPay(appVersionProvider = mockk()) ApiConfig.ID.BlockAid -> BlockAid(configStorage = mockk()) ApiConfig.ID.MoonPay -> MoonPay() + ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk()) } } } diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index 8524296e8b..9e11effa7d 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -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() private val expressAuthProvider = mockk() private val stakeKitAuthProvider = mockk() + private val p2pEthPoolAuthProvider = mockk() private val appAuthProvider = mockk() private val appInfoProvider = mockk() private val tangemApiKeyProvider = mockk>() @@ -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" } diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt new file mode 100644 index 0000000000..4c0870aec5 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -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> = 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 = 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 = 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 = 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 = 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 = 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> = 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)) + } + } + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingErrorResolver.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingErrorResolver.kt index a5f5c43f33..99dbdf2927 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingErrorResolver.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingErrorResolver.kt @@ -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 diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt new file mode 100644 index 0000000000..a19708587c --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolAccountConverter.kt @@ -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 { + + 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, + ) + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolBroadcastResultConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolBroadcastResultConverter.kt new file mode 100644 index 0000000000..6f85db1475 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolBroadcastResultConverter.kt @@ -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 { + + 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 + } + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolErrorConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolErrorConverter.kt new file mode 100644 index 0000000000..baf0e6a718 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolErrorConverter.kt @@ -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 { + + 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}"), + ) + } + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt new file mode 100644 index 0000000000..da6432d026 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolRewardConverter.kt @@ -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 { + + override fun convert(value: P2PEthPoolRewardDTO): P2PEthPoolReward { + return P2PEthPoolReward( + date = value.date, + apy = value.apy.toBigDecimal(), + balance = value.balance, + rewards = value.rewards, + ) + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolUnsignedTxConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolUnsignedTxConverter.kt new file mode 100644 index 0000000000..98c7f7caf7 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolUnsignedTxConverter.kt @@ -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 { + + 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, + ) + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt new file mode 100644 index 0000000000..cc010e8faa --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/ethpool/P2PEthPoolVaultConverter.kt @@ -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 { + + 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, + ) + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index d96e876a73..cfab0afe66 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -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( diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt new file mode 100644 index 0000000000..491c28a775 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAccount.kt @@ -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, +) + +/** + * Individual exit request in the queue + */ +data class P2PEthPoolExitRequest( + val ticket: String, + val totalAssets: SerializedBigDecimal, + val timestamp: Instant, + val withdrawalTimestamp: Instant, + val isClaimable: Boolean, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAction.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAction.kt new file mode 100644 index 0000000000..5e5f10593f --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolAction.kt @@ -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?, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBalance.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBalance.kt new file mode 100644 index 0000000000..c6ea6b9477 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBalance.kt @@ -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, + 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, +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBroadcastResult.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBroadcastResult.kt new file mode 100644 index 0000000000..07f04f6b10 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolBroadcastResult.kt @@ -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 +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolNetwork.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolNetwork.kt new file mode 100644 index 0000000000..2321e39114 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolNetwork.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt new file mode 100644 index 0000000000..70af95ac9a --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolReward.kt @@ -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, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStaking.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStaking.kt new file mode 100644 index 0000000000..b3da05663c --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolStaking.kt @@ -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?, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolUnsignedTx.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolUnsignedTx.kt new file mode 100644 index 0000000000..5a801b205a --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolUnsignedTx.kt @@ -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, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolVault.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolVault.kt new file mode 100644 index 0000000000..97fbb3ae01 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/ethpool/P2PEthPoolVault.kt @@ -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, +) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/StakingError.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/StakingError.kt index a0f770b33d..3c401fcced 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/StakingError.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/StakingError.kt @@ -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() } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt new file mode 100644 index 0000000000..469d2cb74f --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/P2PEthPoolRepository.kt @@ -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> + + /** + * 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 + + /** + * 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 + + /** + * 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 + + /** + * 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 + + /** + * 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 + + /** + * 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> +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/P2PEthPoolAuthProvider.kt b/libs/auth/src/main/java/com/tangem/lib/auth/P2PEthPoolAuthProvider.kt new file mode 100644 index 0000000000..f95dc78e38 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/P2PEthPoolAuthProvider.kt @@ -0,0 +1,6 @@ +package com.tangem.lib.auth + +interface P2PEthPoolAuthProvider { + + fun getApiKey(): String +} \ No newline at end of file