fix: 修复交易统计和L2认证问题
- 修复交易统计数据计算逻辑(总订单数、已完成订单数、持仓数量) - 修复L2认证签名生成,支持URL-safe base64格式的secret - 添加标准HTTP请求头以匹配clob-client行为 - 移除数据库加密(私人应用,明文存储) - 添加自动获取API Key功能 - 优化持仓数量统计(包括正负仓位) - 添加EIP-712签名和L1认证支持 - 更新前端账户管理界面
This commit is contained in:
@@ -110,6 +110,29 @@ interface PolymarketClobApi {
|
||||
@Query("after") after: String? = null,
|
||||
@Query("next_cursor") next_cursor: String? = null
|
||||
): Response<GetTradesResponse>
|
||||
|
||||
/**
|
||||
* 创建 API Key(L1 认证)
|
||||
* 端点: /auth/api-key
|
||||
* 需要 L1 认证头(POLY_ADDRESS, POLY_SIGNATURE, POLY_TIMESTAMP, POLY_NONCE)
|
||||
*/
|
||||
@POST("/auth/api-key")
|
||||
suspend fun createApiKey(): Response<ApiKeyResponse>
|
||||
|
||||
/**
|
||||
* 获取现有 API Key(L1 认证)
|
||||
* 端点: /auth/derive-api-key
|
||||
* 需要 L1 认证头(POLY_ADDRESS, POLY_SIGNATURE, POLY_TIMESTAMP, POLY_NONCE)
|
||||
*/
|
||||
@GET("/auth/derive-api-key")
|
||||
suspend fun deriveApiKey(): Response<ApiKeyResponse>
|
||||
|
||||
/**
|
||||
* 获取服务器时间
|
||||
* 端点: /time
|
||||
*/
|
||||
@GET("/time")
|
||||
suspend fun getServerTime(): Response<ServerTimeResponse>
|
||||
}
|
||||
|
||||
// 请求和响应数据类
|
||||
@@ -205,3 +228,19 @@ data class GetTradesResponse(
|
||||
val next_cursor: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* API Key 响应
|
||||
*/
|
||||
data class ApiKeyResponse(
|
||||
val apiKey: String,
|
||||
val secret: String,
|
||||
val passphrase: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 服务器时间响应
|
||||
*/
|
||||
data class ServerTimeResponse(
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
|
||||
@@ -29,6 +29,16 @@ interface PolymarketDataApi {
|
||||
@Query("sortDirection") sortDirection: String? = null,
|
||||
@Query("title") title: String? = null
|
||||
): Response<List<PositionResponse>>
|
||||
|
||||
/**
|
||||
* 获取用户仓位总价值
|
||||
* 文档: https://docs.polymarket.com/api-reference/core/get-total-value-of-a-users-positions
|
||||
*/
|
||||
@GET("/value")
|
||||
suspend fun getTotalValue(
|
||||
@Query("user") user: String,
|
||||
@Query("market") market: List<String>? = null
|
||||
): Response<List<ValueResponse>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,4 +72,12 @@ data class PositionResponse(
|
||||
val negativeRisk: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 仓位价值响应(根据 Polymarket Data API 文档)
|
||||
*/
|
||||
data class ValueResponse(
|
||||
val user: String,
|
||||
val value: Double
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,9 +7,6 @@ data class AccountImportRequest(
|
||||
val privateKey: String, // 私钥(前端加密后传输)
|
||||
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
|
||||
val accountName: String? = null,
|
||||
val apiKey: String? = null, // Polymarket API Key(可选)
|
||||
val apiSecret: String? = null, // Polymarket API Secret(可选)
|
||||
val apiPassphrase: String? = null, // Polymarket API Passphrase(可选)
|
||||
val isDefault: Boolean = false
|
||||
)
|
||||
|
||||
@@ -19,9 +16,6 @@ data class AccountImportRequest(
|
||||
data class AccountUpdateRequest(
|
||||
val accountId: Long,
|
||||
val accountName: String? = null,
|
||||
val apiKey: String? = null,
|
||||
val apiSecret: String? = null,
|
||||
val apiPassphrase: String? = null,
|
||||
val isDefault: Boolean? = null
|
||||
)
|
||||
|
||||
@@ -66,7 +60,10 @@ data class AccountDto(
|
||||
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
|
||||
val balance: String? = null, // 账户余额(可选)
|
||||
val totalOrders: Long? = null, // 总订单数(可选)
|
||||
val totalPnl: String? = null // 总盈亏(可选)
|
||||
val totalPnl: String? = null, // 总盈亏(可选)
|
||||
val activeOrders: Long? = null, // 活跃订单数(可选)
|
||||
val completedOrders: Long? = null, // 已完成订单数(可选)
|
||||
val positionCount: Long? = null // 持仓数量(可选)
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@ data class Account(
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "private_key", nullable = false, length = 500)
|
||||
val privateKey: String, // 私钥(加密存储)
|
||||
val privateKey: String, // 私钥(明文存储)
|
||||
|
||||
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
|
||||
val walletAddress: String, // 钱包地址(从私钥推导)
|
||||
@@ -23,13 +23,13 @@ data class Account(
|
||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
|
||||
|
||||
@Column(name = "api_key", length = 500)
|
||||
val apiKey: String? = null, // Polymarket API Key(可选,加密存储)
|
||||
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
|
||||
|
||||
@Column(name = "api_secret", length = 500)
|
||||
val apiSecret: String? = null, // Polymarket API Secret(可选,加密存储)
|
||||
val apiSecret: String? = null, // Polymarket API Secret(可选,明文存储)
|
||||
|
||||
@Column(name = "api_passphrase", length = 500)
|
||||
val apiPassphrase: String? = null, // Polymarket API Passphrase(可选,加密存储)
|
||||
val apiPassphrase: String? = null, // Polymarket API Passphrase(可选,明文存储)
|
||||
|
||||
@Column(name = "account_name", length = 100)
|
||||
val accountName: String? = null,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -18,10 +18,10 @@ import java.math.BigDecimal
|
||||
@Service
|
||||
class AccountService(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val clobService: PolymarketClobService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val blockchainService: BlockchainService
|
||||
private val blockchainService: BlockchainService,
|
||||
private val apiKeyService: PolymarketApiKeyService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AccountService::class.java)
|
||||
@@ -49,11 +49,30 @@ class AccountService(
|
||||
return Result.failure(IllegalArgumentException("无效的私钥格式"))
|
||||
}
|
||||
|
||||
// 4. 加密私钥和 API 凭证
|
||||
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
||||
val encryptedApiKey = request.apiKey?.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedApiSecret = request.apiSecret?.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedApiPassphrase = request.apiPassphrase?.let { cryptoUtils.encrypt(it) }
|
||||
// 4. 自动获取或创建 API Key(必须成功,否则导入失败)
|
||||
logger.info("开始自动获取或创建 API Key: ${request.walletAddress}")
|
||||
val apiKeyCreds = runBlocking {
|
||||
val result = apiKeyService.createOrDeriveApiKey(
|
||||
privateKey = request.privateKey,
|
||||
walletAddress = request.walletAddress,
|
||||
chainId = 137L // Polygon 主网
|
||||
)
|
||||
|
||||
if (result.isSuccess) {
|
||||
val creds = result.getOrNull()
|
||||
if (creds != null) {
|
||||
logger.info("成功自动获取 API Key: ${request.walletAddress}")
|
||||
creds
|
||||
} else {
|
||||
logger.error("自动获取 API Key 返回空值")
|
||||
throw IllegalStateException("自动获取 API Key 失败:返回值为空")
|
||||
}
|
||||
} else {
|
||||
val error = result.exceptionOrNull()
|
||||
logger.error("自动获取 API Key 失败: ${error?.message}")
|
||||
throw IllegalStateException("自动获取 API Key 失败: ${error?.message}。请确保私钥有效且账户已激活")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 如果设置为默认账户,取消其他账户的默认状态
|
||||
if (request.isDefault) {
|
||||
@@ -84,12 +103,12 @@ class AccountService(
|
||||
|
||||
// 7. 创建账户
|
||||
val account = Account(
|
||||
privateKey = encryptedPrivateKey,
|
||||
privateKey = request.privateKey,
|
||||
walletAddress = request.walletAddress,
|
||||
proxyAddress = proxyAddress,
|
||||
apiKey = encryptedApiKey,
|
||||
apiSecret = encryptedApiSecret,
|
||||
apiPassphrase = encryptedApiPassphrase,
|
||||
apiKey = apiKeyCreds.apiKey,
|
||||
apiSecret = apiKeyCreds.secret,
|
||||
apiPassphrase = apiKeyCreds.passphrase,
|
||||
accountName = request.accountName,
|
||||
isDefault = request.isDefault,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
@@ -118,23 +137,6 @@ class AccountService(
|
||||
// 更新账户名称
|
||||
val updatedAccountName = request.accountName ?: account.accountName
|
||||
|
||||
// 更新 API 凭证
|
||||
val updatedApiKey = if (request.apiKey != null) {
|
||||
cryptoUtils.encrypt(request.apiKey)
|
||||
} else {
|
||||
account.apiKey
|
||||
}
|
||||
val updatedApiSecret = if (request.apiSecret != null) {
|
||||
cryptoUtils.encrypt(request.apiSecret)
|
||||
} else {
|
||||
account.apiSecret
|
||||
}
|
||||
val updatedApiPassphrase = if (request.apiPassphrase != null) {
|
||||
cryptoUtils.encrypt(request.apiPassphrase)
|
||||
} else {
|
||||
account.apiPassphrase
|
||||
}
|
||||
|
||||
// 如果设置为默认账户,取消其他账户的默认状态
|
||||
val updatedIsDefault = request.isDefault ?: account.isDefault
|
||||
if (updatedIsDefault && !account.isDefault) {
|
||||
@@ -146,9 +148,6 @@ class AccountService(
|
||||
|
||||
val updated = account.copy(
|
||||
accountName = updatedAccountName,
|
||||
apiKey = updatedApiKey,
|
||||
apiSecret = updatedApiSecret,
|
||||
apiPassphrase = updatedApiPassphrase,
|
||||
isDefault = updatedIsDefault,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
@@ -262,7 +261,7 @@ class AccountService(
|
||||
// 查询 USDC 余额和持仓信息
|
||||
val balanceResult = runBlocking {
|
||||
try {
|
||||
// 先查询持仓信息(用于计算仓位余额和返回持仓列表)
|
||||
// 查询持仓信息(用于返回持仓列表)
|
||||
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
|
||||
val positionsResult = blockchainService.getPositions(account.proxyAddress)
|
||||
val positions = if (positionsResult.isSuccess) {
|
||||
@@ -281,9 +280,13 @@ class AccountService(
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// 计算仓位余额(持仓总价值)
|
||||
val positionBalance = positions.sumOf {
|
||||
it.currentValue.toSafeBigDecimal()
|
||||
// 使用 /value 接口获取仓位总价值(而不是累加)
|
||||
val positionBalanceResult = blockchainService.getTotalValue(account.proxyAddress)
|
||||
val positionBalance = if (positionBalanceResult.isSuccess) {
|
||||
positionBalanceResult.getOrNull() ?: "0"
|
||||
} else {
|
||||
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
// 查询可用余额(通过 RPC 查询 USDC 余额)
|
||||
@@ -302,11 +305,11 @@ class AccountService(
|
||||
}
|
||||
|
||||
// 计算总余额 = 可用余额 + 仓位余额
|
||||
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance)
|
||||
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
|
||||
|
||||
AccountBalanceResponse(
|
||||
availableBalance = availableBalance,
|
||||
positionBalance = positionBalance.toPlainString(),
|
||||
positionBalance = positionBalance,
|
||||
totalBalance = totalBalance.toPlainString(),
|
||||
positions = positions
|
||||
)
|
||||
@@ -354,7 +357,7 @@ class AccountService(
|
||||
|
||||
/**
|
||||
* 转换为 DTO
|
||||
* 包含交易统计数据(总订单数和总盈亏)
|
||||
* 包含交易统计数据(总订单数、总盈亏、活跃订单数、已完成订单数、持仓数量)
|
||||
*/
|
||||
private fun toDto(account: Account): AccountDto {
|
||||
return runBlocking {
|
||||
@@ -368,7 +371,10 @@ class AccountService(
|
||||
apiSecretConfigured = account.apiSecret != null,
|
||||
apiPassphraseConfigured = account.apiPassphrase != null,
|
||||
totalOrders = statistics.totalOrders,
|
||||
totalPnl = statistics.totalPnl
|
||||
totalPnl = statistics.totalPnl,
|
||||
activeOrders = statistics.activeOrders,
|
||||
completedOrders = statistics.completedOrders,
|
||||
positionCount = statistics.positionCount
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -380,82 +386,138 @@ class AccountService(
|
||||
return try {
|
||||
// 如果账户没有配置 API 凭证,无法查询统计数据
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
return AccountStatistics(totalOrders = null, totalPnl = null)
|
||||
return AccountStatistics(
|
||||
totalOrders = null,
|
||||
totalPnl = null,
|
||||
activeOrders = null,
|
||||
completedOrders = null,
|
||||
positionCount = null
|
||||
)
|
||||
}
|
||||
|
||||
// 解密 API 凭证
|
||||
val apiKey = cryptoUtils.decrypt(account.apiKey)
|
||||
val apiSecret = cryptoUtils.decrypt(account.apiSecret)
|
||||
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase)
|
||||
// 使用 API 凭证(直接使用,无需解密)
|
||||
val apiKey = account.apiKey
|
||||
val apiSecret = account.apiSecret
|
||||
val apiPassphrase = account.apiPassphrase
|
||||
|
||||
// 创建带认证的 API 客户端
|
||||
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
|
||||
// 创建带认证的 API 客户端(需要钱包地址用于 POLY_ADDRESS 请求头)
|
||||
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase, account.walletAddress)
|
||||
|
||||
// 1. 查询交易记录数量(总订单数)
|
||||
val tradesResult = runBlocking {
|
||||
try {
|
||||
// 使用代理地址查询交易记录
|
||||
// 1. 查询活跃订单数量(open/active 状态)
|
||||
val activeOrdersResult = try {
|
||||
var totalActiveOrders = 0L
|
||||
var nextCursor: String? = null
|
||||
|
||||
// 分页查询所有活跃订单
|
||||
do {
|
||||
val response = clobApi.getActiveOrders(
|
||||
id = null,
|
||||
market = null,
|
||||
asset_id = null,
|
||||
next_cursor = nextCursor
|
||||
)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val ordersResponse = response.body()!!
|
||||
totalActiveOrders += ordersResponse.data.size
|
||||
nextCursor = ordersResponse.next_cursor
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} while (nextCursor != null && nextCursor.isNotEmpty())
|
||||
|
||||
Result.success(totalActiveOrders)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询活跃订单失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
// 2. 查询已完成订单数
|
||||
// 注意:交易记录数不等于已完成订单数,因为一个订单可能产生多笔交易
|
||||
// 已完成订单应该是指已完全成交或已关闭的订单
|
||||
// 由于 Polymarket CLOB API 没有直接查询所有订单(包括已完成)的接口,
|
||||
// 我们通过查询交易记录来估算已完成订单数
|
||||
// 但更准确的方式是统计去重后的订单ID数量
|
||||
val completedOrdersResult = try {
|
||||
// 使用代理地址查询交易记录(作为 maker 的交易)
|
||||
var allTrades = mutableListOf<TradeResponse>()
|
||||
var nextCursor: String? = null
|
||||
|
||||
// 分页查询所有交易(作为 maker)
|
||||
do {
|
||||
val response = clobApi.getTrades(
|
||||
maker_address = account.proxyAddress,
|
||||
next_cursor = null
|
||||
next_cursor = nextCursor
|
||||
)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val tradesResponse = response.body()!!
|
||||
// 统计所有交易(需要分页查询所有)
|
||||
var totalTrades = tradesResponse.data.size
|
||||
var nextCursor = tradesResponse.next_cursor
|
||||
|
||||
// 分页查询所有交易
|
||||
while (nextCursor != null && nextCursor.isNotEmpty()) {
|
||||
val nextResponse = clobApi.getTrades(
|
||||
maker_address = account.proxyAddress,
|
||||
next_cursor = nextCursor
|
||||
)
|
||||
if (nextResponse.isSuccessful && nextResponse.body() != null) {
|
||||
val nextTradesResponse = nextResponse.body()!!
|
||||
totalTrades += nextTradesResponse.data.size
|
||||
nextCursor = nextTradesResponse.next_cursor
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
Result.success(totalTrades.toLong())
|
||||
allTrades.addAll(tradesResponse.data)
|
||||
nextCursor = tradesResponse.next_cursor
|
||||
} else {
|
||||
Result.failure(Exception("查询交易记录失败: ${response.code()} ${response.message()}"))
|
||||
break
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询交易记录失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
} while (nextCursor != null && nextCursor.isNotEmpty())
|
||||
|
||||
// 注意:Polymarket API 的 getTrades 接口只支持查询 maker_address,
|
||||
// 如果需要查询作为 taker 的交易,可能需要使用其他接口或查询方式
|
||||
// 目前只统计作为 maker 的交易记录
|
||||
|
||||
// 由于 TradeResponse 没有 orderId 字段,我们无法直接去重订单
|
||||
// 这里使用交易记录数作为已完成订单数的近似值
|
||||
// 更准确的方式需要查询所有订单并统计状态为 "filled" 的订单
|
||||
val completedOrdersCount = allTrades.size.toLong()
|
||||
|
||||
Result.success(completedOrdersCount)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询交易记录失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
// 2. 查询仓位信息计算总盈亏(已实现盈亏)
|
||||
val totalPnlResult = runBlocking {
|
||||
try {
|
||||
val positionsResult = blockchainService.getPositions(account.proxyAddress)
|
||||
if (positionsResult.isSuccess) {
|
||||
val positions = positionsResult.getOrNull() ?: emptyList()
|
||||
// 汇总所有仓位的已实现盈亏
|
||||
val totalRealizedPnl = positions.sumOf { pos ->
|
||||
pos.realizedPnl?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
}
|
||||
Result.success(totalRealizedPnl.toPlainString())
|
||||
} else {
|
||||
Result.failure(Exception("查询仓位信息失败"))
|
||||
// 3. 查询仓位信息计算总盈亏(已实现盈亏)和持仓数量
|
||||
val positionsResult = try {
|
||||
val positions = blockchainService.getPositions(account.proxyAddress)
|
||||
if (positions.isSuccess) {
|
||||
val positionList = positions.getOrNull() ?: emptyList()
|
||||
// 汇总所有仓位的已实现盈亏
|
||||
val totalRealizedPnl = positionList.sumOf { pos ->
|
||||
pos.realizedPnl?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询仓位盈亏失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
// 统计持仓数量(所有非零持仓,包括正负仓位)
|
||||
// size 可能为正数(做多)或负数(做空),都应该统计
|
||||
val positionCount = positionList.count { pos ->
|
||||
val size = pos.size?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
size != BigDecimal.ZERO // 统计所有非零持仓
|
||||
}
|
||||
Result.success(Pair(totalRealizedPnl.toPlainString(), positionCount.toLong()))
|
||||
} else {
|
||||
Result.failure(Exception("查询仓位信息失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询仓位信息失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
val activeOrders = activeOrdersResult.getOrNull() ?: 0L
|
||||
val completedOrders = completedOrdersResult.getOrNull() ?: 0L
|
||||
// 总订单数 = 活跃订单数 + 已完成订单数
|
||||
val totalOrders = activeOrders + completedOrders
|
||||
val (totalPnl, positionCount) = positionsResult.getOrNull() ?: Pair(null, null)
|
||||
|
||||
AccountStatistics(
|
||||
totalOrders = tradesResult.getOrNull(),
|
||||
totalPnl = totalPnlResult.getOrNull()
|
||||
totalOrders = totalOrders,
|
||||
totalPnl = totalPnl,
|
||||
activeOrders = activeOrders,
|
||||
completedOrders = completedOrders, // 已完成订单数 = 交易记录数(已成交的订单)
|
||||
positionCount = positionCount
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取账户统计数据失败: ${e.message}", e)
|
||||
AccountStatistics(totalOrders = null, totalPnl = null)
|
||||
AccountStatistics(
|
||||
totalOrders = null,
|
||||
totalPnl = null,
|
||||
activeOrders = null,
|
||||
completedOrders = null,
|
||||
positionCount = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,7 +526,10 @@ class AccountService(
|
||||
*/
|
||||
private data class AccountStatistics(
|
||||
val totalOrders: Long?,
|
||||
val totalPnl: String?
|
||||
val totalPnl: String?,
|
||||
val activeOrders: Long?,
|
||||
val completedOrders: Long?,
|
||||
val positionCount: Long?
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -496,13 +561,13 @@ class AccountService(
|
||||
return false
|
||||
}
|
||||
|
||||
// 解密 API 凭证(前面已检查不为 null)
|
||||
val apiKey = cryptoUtils.decrypt(account.apiKey)
|
||||
val apiSecret = cryptoUtils.decrypt(account.apiSecret)
|
||||
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase)
|
||||
// 使用 API 凭证(直接使用,无需解密)
|
||||
val apiKey = account.apiKey
|
||||
val apiSecret = account.apiSecret
|
||||
val apiPassphrase = account.apiPassphrase
|
||||
|
||||
// 创建带认证的 API 客户端
|
||||
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
|
||||
// 创建带认证的 API 客户端(需要钱包地址用于 POLY_ADDRESS 请求头)
|
||||
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase, account.walletAddress)
|
||||
|
||||
// 查询活跃订单(只查询第一条,用于判断是否有订单)
|
||||
// 使用 next_cursor 参数进行分页,这里只查询第一页
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
import com.wrbug.polymarketbot.api.JsonRpcResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PositionResponse
|
||||
import com.wrbug.polymarketbot.api.ValueResponse
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
@@ -238,5 +239,39 @@ class BlockchainService(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户仓位总价值
|
||||
* 通过 Polymarket Data API 查询
|
||||
* 文档: https://docs.polymarket.com/api-reference/core/get-total-value-of-a-users-positions
|
||||
*/
|
||||
suspend fun getTotalValue(proxyWalletAddress: String): Result<String> {
|
||||
return try {
|
||||
// 使用代理钱包地址查询仓位总价值
|
||||
val response = dataApi.getTotalValue(
|
||||
user = proxyWalletAddress,
|
||||
market = null
|
||||
)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val values = response.body()!!
|
||||
// 根据文档,返回的是数组,通常只有一个元素
|
||||
val totalValue = if (values.isNotEmpty()) {
|
||||
values.first().value
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
logger.debug("查询到仓位总价值: $totalValue")
|
||||
Result.success(totalValue.toString())
|
||||
} else {
|
||||
val errorMsg = "Data API 请求失败: ${response.code()} ${response.message()}"
|
||||
logger.error(errorMsg)
|
||||
Result.failure(Exception(errorMsg))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询仓位总价值失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
|
||||
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
|
||||
/**
|
||||
* Polymarket API Key 服务
|
||||
* 用于自动创建或获取 API Key
|
||||
*/
|
||||
@Service
|
||||
class PolymarketApiKeyService(
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private val clobBaseUrl: String
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketApiKeyService::class.java)
|
||||
|
||||
/**
|
||||
* API Key 凭证数据类
|
||||
*/
|
||||
data class ApiKeyCreds(
|
||||
val apiKey: String,
|
||||
val secret: String,
|
||||
val passphrase: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 创建或获取 API Key
|
||||
* 先尝试获取现有的(derive),如果不存在,则创建新的
|
||||
* 与 JavaScript 实现保持一致:先 derive 后 create
|
||||
*
|
||||
* @param privateKey 私钥(十六进制字符串)
|
||||
* @param walletAddress 钱包地址
|
||||
* @param chainId 链 ID(默认 137,Polygon 主网)
|
||||
* @return API Key 凭证,如果失败则返回错误
|
||||
*/
|
||||
fun createOrDeriveApiKey(
|
||||
privateKey: String,
|
||||
walletAddress: String,
|
||||
chainId: Long = 137L
|
||||
): Result<ApiKeyCreds> {
|
||||
return runBlocking {
|
||||
try {
|
||||
// 先尝试获取现有的 API Key(derive)
|
||||
val deriveResult = deriveApiKey(privateKey, walletAddress, chainId)
|
||||
if (deriveResult.isSuccess) {
|
||||
val creds = deriveResult.getOrNull()
|
||||
if (creds != null && isApiCreds(creds)) {
|
||||
logger.info("成功获取现有 API Key: ${walletAddress}")
|
||||
return@runBlocking Result.success(creds)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果获取失败或返回无效,尝试创建新的
|
||||
logger.info("获取现有 API Key 失败,尝试创建新的: ${walletAddress}")
|
||||
val createResult = createApiKey(privateKey, walletAddress, chainId)
|
||||
if (createResult.isSuccess) {
|
||||
val creds = createResult.getOrNull()
|
||||
if (creds != null && isApiCreds(creds)) {
|
||||
logger.info("成功创建新 API Key: ${walletAddress}")
|
||||
return@runBlocking Result.success(creds)
|
||||
}
|
||||
}
|
||||
|
||||
// 两个都失败
|
||||
val error = createResult.exceptionOrNull() ?: deriveResult.exceptionOrNull()
|
||||
val errorMsg = error?.message ?: "未知错误"
|
||||
logger.error("获取和创建 API Key 都失败: ${walletAddress}", error)
|
||||
Result.failure(
|
||||
IllegalStateException("无法获取或创建 API Key: $errorMsg")
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建或获取 API Key 异常: ${walletAddress}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 API Key 凭证是否有效
|
||||
* 与 JavaScript 的 isApiCreds() 函数对应
|
||||
*/
|
||||
private fun isApiCreds(creds: ApiKeyCreds?): Boolean {
|
||||
return creds != null &&
|
||||
creds.apiKey.isNotBlank() &&
|
||||
creds.secret.isNotBlank() &&
|
||||
creds.passphrase.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的 API Key
|
||||
*/
|
||||
private suspend fun createApiKey(
|
||||
privateKey: String,
|
||||
walletAddress: String,
|
||||
chainId: Long
|
||||
): Result<ApiKeyCreds> {
|
||||
return try {
|
||||
// 获取服务器时间(可选,用于更准确的时间戳)
|
||||
val serverTime = try {
|
||||
val timeApi = createUnauthenticatedApi()
|
||||
val timeResponse = timeApi.getServerTime()
|
||||
if (timeResponse.isSuccessful && timeResponse.body() != null) {
|
||||
timeResponse.body()!!.timestamp
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取服务器时间失败,使用本地时间", e)
|
||||
null
|
||||
}
|
||||
|
||||
// 创建带 L1 认证的 API 客户端
|
||||
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
|
||||
|
||||
// 调用创建 API Key 接口
|
||||
val response = api.createApiKey()
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val apiKeyResponse = response.body()!!
|
||||
Result.success(
|
||||
ApiKeyCreds(
|
||||
apiKey = apiKeyResponse.apiKey,
|
||||
secret = apiKeyResponse.secret,
|
||||
passphrase = apiKeyResponse.passphrase
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
logger.warn("创建 API Key 失败: ${response.code()} $errorBody")
|
||||
Result.failure(
|
||||
IllegalStateException("创建 API Key 失败: ${response.code()} $errorBody")
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建 API Key 异常", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取现有的 API Key
|
||||
*/
|
||||
private suspend fun deriveApiKey(
|
||||
privateKey: String,
|
||||
walletAddress: String,
|
||||
chainId: Long
|
||||
): Result<ApiKeyCreds> {
|
||||
return try {
|
||||
// 获取服务器时间(可选)
|
||||
val serverTime = try {
|
||||
val timeApi = createUnauthenticatedApi()
|
||||
val timeResponse = timeApi.getServerTime()
|
||||
if (timeResponse.isSuccessful && timeResponse.body() != null) {
|
||||
timeResponse.body()!!.timestamp
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取服务器时间失败,使用本地时间", e)
|
||||
null
|
||||
}
|
||||
|
||||
// 创建带 L1 认证的 API 客户端
|
||||
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
|
||||
|
||||
// 调用获取 API Key 接口
|
||||
val response = api.deriveApiKey()
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val apiKeyResponse = response.body()!!
|
||||
Result.success(
|
||||
ApiKeyCreds(
|
||||
apiKey = apiKeyResponse.apiKey,
|
||||
secret = apiKeyResponse.secret,
|
||||
passphrase = apiKeyResponse.passphrase
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
logger.warn("获取 API Key 失败: ${response.code()} $errorBody")
|
||||
Result.failure(
|
||||
IllegalStateException("获取 API Key 失败: ${response.code()} $errorBody")
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取 API Key 异常", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带 L1 认证的 API 客户端
|
||||
*/
|
||||
private fun createL1AuthenticatedApi(
|
||||
privateKey: String,
|
||||
walletAddress: String,
|
||||
chainId: Long,
|
||||
serverTime: Long? = null
|
||||
): PolymarketClobApi {
|
||||
val authInterceptor = PolymarketL1AuthInterceptor(
|
||||
privateKey = privateKey,
|
||||
walletAddress = walletAddress,
|
||||
chainId = chainId,
|
||||
useServerTime = serverTime != null,
|
||||
serverTime = serverTime
|
||||
)
|
||||
|
||||
val okHttpClient = createClient()
|
||||
.addInterceptor(authInterceptor)
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(PolymarketClobApi::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建未认证的 API 客户端(用于获取服务器时间等公开接口)
|
||||
*/
|
||||
private fun createUnauthenticatedApi(): PolymarketClobApi {
|
||||
val okHttpClient = createClient().build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(PolymarketClobApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.*
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* 加密工具类
|
||||
* 用于加密存储私钥和 API Key
|
||||
*/
|
||||
@Component
|
||||
class CryptoUtils {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoUtils::class.java)
|
||||
|
||||
@Value("\${crypto.secret.key:}")
|
||||
private var secretKey: String = ""
|
||||
|
||||
private val algorithm = "AES"
|
||||
private val transformation = "AES"
|
||||
|
||||
/**
|
||||
* 获取密钥字节数组
|
||||
* 使用 SHA-256 哈希从任意长度的密钥生成固定 32 字节的密钥(AES-256)
|
||||
*/
|
||||
private fun getKeyBytes(): ByteArray {
|
||||
val rawKey = if (secretKey.isEmpty()) {
|
||||
logger.warn("未配置加密密钥,使用默认密钥(仅用于开发环境)")
|
||||
"default-secret-key-32-bytes-long!!"
|
||||
} else {
|
||||
secretKey
|
||||
}
|
||||
|
||||
// 将原始密钥转换为字节数组
|
||||
val keyBytes = rawKey.toByteArray(StandardCharsets.UTF_8)
|
||||
|
||||
// 使用 SHA-256 哈希生成固定 32 字节的密钥(AES-256)
|
||||
val messageDigest = java.security.MessageDigest.getInstance("SHA-256")
|
||||
return messageDigest.digest(keyBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密字符串
|
||||
*/
|
||||
fun encrypt(plainText: String): String {
|
||||
return try {
|
||||
val keyBytes = getKeyBytes()
|
||||
val key = SecretKeySpec(keyBytes, algorithm)
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
val encrypted = cipher.doFinal(plainText.toByteArray(StandardCharsets.UTF_8))
|
||||
Base64.getEncoder().encodeToString(encrypted)
|
||||
} catch (e: Exception) {
|
||||
logger.error("加密失败", e)
|
||||
throw RuntimeException("加密失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密字符串
|
||||
*/
|
||||
fun decrypt(encryptedText: String): String {
|
||||
return try {
|
||||
val keyBytes = getKeyBytes()
|
||||
val key = SecretKeySpec(keyBytes, algorithm)
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key)
|
||||
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedText))
|
||||
String(decrypted, StandardCharsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
logger.error("解密失败", e)
|
||||
throw RuntimeException("解密失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import org.bouncycastle.crypto.digests.KeccakDigest
|
||||
import org.web3j.utils.Numeric
|
||||
import java.math.BigInteger
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* EIP-712 编码工具类
|
||||
* 手动实现 EIP-712 编码,避免 web3j StructuredDataEncoder 的 verifyingContract 问题
|
||||
*
|
||||
* 参考 EIP-712 标准:https://eips.ethereum.org/EIPS/eip-712
|
||||
*/
|
||||
object Eip712Encoder {
|
||||
|
||||
/**
|
||||
* Keccak-256 哈希
|
||||
*/
|
||||
private fun keccak256(data: ByteArray): ByteArray {
|
||||
val digest = KeccakDigest(256)
|
||||
digest.update(data, 0, data.size)
|
||||
val hash = ByteArray(digest.digestSize)
|
||||
digest.doFinal(hash, 0)
|
||||
return hash
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码字符串类型
|
||||
*/
|
||||
private fun encodeString(value: String): ByteArray {
|
||||
val bytes = value.toByteArray(StandardCharsets.UTF_8)
|
||||
return keccak256(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码地址类型(20 字节,左对齐到 32 字节)
|
||||
*/
|
||||
private fun encodeAddress(address: String): ByteArray {
|
||||
val cleanAddress = address.removePrefix("0x").lowercase()
|
||||
val addressBytes = Numeric.hexStringToByteArray("0x$cleanAddress")
|
||||
// 地址是 20 字节,需要左对齐到 32 字节
|
||||
return ByteArray(32).apply {
|
||||
System.arraycopy(addressBytes, 0, this, 12, addressBytes.size)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 uint256 类型(32 字节,大端序)
|
||||
*/
|
||||
private fun encodeUint256(value: BigInteger): ByteArray {
|
||||
val bytes = value.toByteArray()
|
||||
val result = ByteArray(32)
|
||||
if (bytes.size <= 32) {
|
||||
// 左对齐
|
||||
System.arraycopy(bytes, 0, result, 32 - bytes.size, bytes.size)
|
||||
} else {
|
||||
// 如果超过 32 字节,取最后 32 字节
|
||||
System.arraycopy(bytes, bytes.size - 32, result, 0, 32)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码类型哈希(Type Hash)
|
||||
* 例如:encodeType("EIP712Domain", listOf("name", "version", "chainId"))
|
||||
*/
|
||||
private fun encodeType(typeName: String, fields: List<Pair<String, String>>): ByteArray {
|
||||
val typeString = buildString {
|
||||
append(typeName)
|
||||
append("(")
|
||||
fields.forEachIndexed { index, (name, type) ->
|
||||
if (index > 0) append(",")
|
||||
append(type)
|
||||
append(" ")
|
||||
append(name)
|
||||
}
|
||||
append(")")
|
||||
}
|
||||
return keccak256(typeString.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码域分隔符(Domain Separator)
|
||||
*/
|
||||
fun encodeDomain(
|
||||
name: String,
|
||||
version: String,
|
||||
chainId: Long
|
||||
): ByteArray {
|
||||
// EIP712Domain 类型定义(不包含 verifyingContract)
|
||||
val domainTypeHash = encodeType(
|
||||
"EIP712Domain",
|
||||
listOf(
|
||||
"name" to "string",
|
||||
"version" to "string",
|
||||
"chainId" to "uint256"
|
||||
)
|
||||
)
|
||||
|
||||
// 编码域字段
|
||||
val nameHash = encodeString(name)
|
||||
val versionHash = encodeString(version)
|
||||
val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId))
|
||||
|
||||
// 组合:keccak256(domainTypeHash || nameHash || versionHash || chainIdBytes)
|
||||
val encoded = ByteArray(32 + 32 + 32 + 32)
|
||||
System.arraycopy(domainTypeHash, 0, encoded, 0, 32)
|
||||
System.arraycopy(nameHash, 0, encoded, 32, 32)
|
||||
System.arraycopy(versionHash, 0, encoded, 64, 32)
|
||||
System.arraycopy(chainIdBytes, 0, encoded, 96, 32)
|
||||
|
||||
return keccak256(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码消息哈希(Message Hash)
|
||||
*/
|
||||
fun encodeMessage(
|
||||
address: String,
|
||||
timestamp: String,
|
||||
nonce: BigInteger,
|
||||
message: String
|
||||
): ByteArray {
|
||||
// ClobAuth 类型定义
|
||||
val clobAuthTypeHash = encodeType(
|
||||
"ClobAuth",
|
||||
listOf(
|
||||
"address" to "address",
|
||||
"timestamp" to "string",
|
||||
"nonce" to "uint256",
|
||||
"message" to "string"
|
||||
)
|
||||
)
|
||||
|
||||
// 编码消息字段
|
||||
val addressBytes = encodeAddress(address)
|
||||
val timestampHash = encodeString(timestamp)
|
||||
val nonceBytes = encodeUint256(nonce)
|
||||
val messageHash = encodeString(message)
|
||||
|
||||
// 组合:keccak256(clobAuthTypeHash || addressBytes || timestampHash || nonceBytes || messageHash)
|
||||
val encoded = ByteArray(32 + 32 + 32 + 32 + 32)
|
||||
System.arraycopy(clobAuthTypeHash, 0, encoded, 0, 32)
|
||||
System.arraycopy(addressBytes, 0, encoded, 32, 32)
|
||||
System.arraycopy(timestampHash, 0, encoded, 64, 32)
|
||||
System.arraycopy(nonceBytes, 0, encoded, 96, 32)
|
||||
System.arraycopy(messageHash, 0, encoded, 128, 32)
|
||||
|
||||
return keccak256(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算完整的结构化数据哈希
|
||||
* hash = keccak256("\x19\x01" || domainSeparator || messageHash)
|
||||
*/
|
||||
fun hashStructuredData(
|
||||
domainSeparator: ByteArray,
|
||||
messageHash: ByteArray
|
||||
): ByteArray {
|
||||
val prefix = byteArrayOf(0x19.toByte(), 0x01.toByte())
|
||||
val encoded = ByteArray(prefix.size + domainSeparator.size + messageHash.size)
|
||||
System.arraycopy(prefix, 0, encoded, 0, prefix.size)
|
||||
System.arraycopy(domainSeparator, 0, encoded, prefix.size, domainSeparator.size)
|
||||
System.arraycopy(messageHash, 0, encoded, prefix.size + domainSeparator.size, messageHash.size)
|
||||
|
||||
return keccak256(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.web3j.crypto.ECKeyPair
|
||||
import org.web3j.crypto.Sign
|
||||
import org.web3j.utils.Numeric
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* EIP-712 签名工具类
|
||||
* 用于创建 Polymarket CLOB API 的 L1 认证签名
|
||||
*
|
||||
* 手动实现 EIP-712 编码,避免 web3j StructuredDataEncoder 的 verifyingContract 问题
|
||||
* 参考 clob-client/src/signing/eip712.ts 实现
|
||||
*/
|
||||
object Eip712Signer {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(Eip712Signer::class.java)
|
||||
|
||||
/**
|
||||
* ClobAuthDomain 的 EIP-712 域定义
|
||||
*/
|
||||
private const val DOMAIN_NAME = "ClobAuthDomain"
|
||||
private const val DOMAIN_VERSION = "1"
|
||||
private const val MESSAGE_TO_SIGN = "This message attests that I control the given wallet" // 根据 clob-client 实现
|
||||
|
||||
/**
|
||||
* 构建 ClobAuth EIP-712 签名
|
||||
*
|
||||
* @param privateKey 私钥(十六进制字符串,带或不带 0x 前缀)
|
||||
* @param chainId 链 ID(Polygon 主网是 137)
|
||||
* @param timestamp 时间戳(秒)
|
||||
* @param nonce 随机数(默认 0)
|
||||
* @return EIP-712 签名字符串
|
||||
*/
|
||||
fun buildClobEip712Signature(
|
||||
privateKey: String,
|
||||
chainId: Long,
|
||||
timestamp: Long,
|
||||
nonce: Long = 0
|
||||
): String {
|
||||
try {
|
||||
// 从私钥创建 BigInteger
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
|
||||
// 从私钥推导地址(用于消息中的 address 字段)
|
||||
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
|
||||
val address = credentials.address
|
||||
|
||||
// 使用手动实现的 EIP-712 编码器
|
||||
// 1. 编码域分隔符
|
||||
val domainSeparator = Eip712Encoder.encodeDomain(
|
||||
name = DOMAIN_NAME,
|
||||
version = DOMAIN_VERSION,
|
||||
chainId = chainId
|
||||
)
|
||||
|
||||
// 2. 编码消息哈希
|
||||
val messageHash = Eip712Encoder.encodeMessage(
|
||||
address = address,
|
||||
timestamp = timestamp.toString(),
|
||||
nonce = BigInteger.valueOf(nonce),
|
||||
message = MESSAGE_TO_SIGN
|
||||
)
|
||||
|
||||
// 3. 计算完整的结构化数据哈希
|
||||
val structuredHash = Eip712Encoder.hashStructuredData(domainSeparator, messageHash)
|
||||
|
||||
// 4. 使用私钥签名
|
||||
val ecKeyPair = ECKeyPair.create(privateKeyBigInt)
|
||||
val signature = Sign.signMessage(structuredHash, ecKeyPair, false)
|
||||
|
||||
// 5. 组合签名(r + s + v)
|
||||
// 在 web3j 5.0 中,signature.r 和 signature.s 是 BigInteger 类型
|
||||
// signature.v 在 web3j 5.0 中仍然是 ByteArray 类型
|
||||
val rHex = Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0')
|
||||
val sHex = Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0')
|
||||
val vBytes = signature.v as ByteArray
|
||||
val vInt = if (vBytes.isNotEmpty()) {
|
||||
vBytes[0].toInt() and 0xff
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val vHex = String.format("%02x", vInt)
|
||||
|
||||
return "0x$rHex$sHex$vHex"
|
||||
} catch (e: Exception) {
|
||||
logger.error("EIP-712 签名失败", e)
|
||||
throw RuntimeException("EIP-712 签名失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+113
-24
@@ -1,9 +1,12 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import javax.crypto.Mac
|
||||
@@ -14,64 +17,150 @@ import java.util.Base64
|
||||
* Polymarket API 认证拦截器
|
||||
* 实现 L2 认证(使用 API Key、Secret、Passphrase)
|
||||
*
|
||||
* 参考 clob-client 实现:
|
||||
* - 请求头使用 POLY_* 前缀
|
||||
* - Secret 需要 base64 解码后用于 HMAC
|
||||
* - 签名结果需要 URL-safe base64 编码(+ -> -, / -> _)
|
||||
*
|
||||
* 认证方式:
|
||||
* 1. 生成时间戳(秒)
|
||||
* 2. 使用 Secret 对 (timestamp + method + path + body) 进行 HMAC-SHA256 签名
|
||||
* 2. 使用 Secret(base64 解码后)对 (timestamp + method + requestPath + body) 进行 HMAC-SHA256 签名
|
||||
* 3. 在请求头中添加:
|
||||
* - X-API-KEY: API Key
|
||||
* - X-API-SIGN: Base64 编码的签名
|
||||
* - X-API-TIMESTAMP: 时间戳
|
||||
* - X-API-PASSPHRASE: Passphrase
|
||||
* - POLY_ADDRESS: 钱包地址
|
||||
* - POLY_SIGNATURE: URL-safe Base64 编码的签名
|
||||
* - POLY_TIMESTAMP: 时间戳(字符串)
|
||||
* - POLY_API_KEY: API Key
|
||||
* - POLY_PASSPHRASE: Passphrase
|
||||
*/
|
||||
class PolymarketAuthInterceptor(
|
||||
private val apiKey: String,
|
||||
private val apiSecret: String,
|
||||
private val apiPassphrase: String
|
||||
private val apiPassphrase: String,
|
||||
private val walletAddress: String
|
||||
) : Interceptor {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketAuthInterceptor::class.java)
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
// 生成时间戳(秒)
|
||||
val timestamp = Instant.now().epochSecond.toString()
|
||||
val timestamp = Instant.now().epochSecond
|
||||
|
||||
// 构建签名字符串: timestamp + method + path + body
|
||||
val method = originalRequest.method
|
||||
val path = originalRequest.url.encodedPath + if (originalRequest.url.query != null) "?${originalRequest.url.query}" else ""
|
||||
// 构建签名字符串: timestamp + method + requestPath + body
|
||||
// requestPath 不包含 query string(根据 clob-client 实现)
|
||||
val method = originalRequest.method.uppercase()
|
||||
val requestPath = originalRequest.url.encodedPath
|
||||
|
||||
// 读取请求体(如果存在)
|
||||
val body = originalRequest.body?.let { requestBody ->
|
||||
// 注意:读取后需要重新创建请求体,否则原始请求体会被消费
|
||||
val bodyString = originalRequest.body?.let { requestBody ->
|
||||
val buffer = Buffer()
|
||||
requestBody.writeTo(buffer)
|
||||
buffer.readUtf8()
|
||||
} ?: ""
|
||||
}
|
||||
|
||||
val signString = "$timestamp$method$path$body"
|
||||
// 构建签名字符串(与 clob-client 保持一致)
|
||||
// clob-client: timestamp + method + requestPath + (body !== undefined ? body : "")
|
||||
// 注意:如果 body 是空字符串 "",也会添加到签名字符串中(虽然结果相同)
|
||||
val signString = if (bodyString != null) {
|
||||
"$timestamp$method$requestPath$bodyString"
|
||||
} else {
|
||||
"$timestamp$method$requestPath"
|
||||
}
|
||||
|
||||
// 使用 HMAC-SHA256 生成签名
|
||||
// 使用 HMAC-SHA256 生成签名(Secret 需要 base64 解码)
|
||||
val signature = generateSignature(signString, apiSecret)
|
||||
|
||||
// 构建新的请求,添加认证头
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.header("X-API-KEY", apiKey)
|
||||
.header("X-API-SIGN", signature)
|
||||
.header("X-API-TIMESTAMP", timestamp)
|
||||
.header("X-API-PASSPHRASE", apiPassphrase)
|
||||
.build()
|
||||
// 调试日志(仅在 DEBUG 级别输出)
|
||||
logger.debug("L2 认证签名生成: method=$method, path=$requestPath, bodyLength=${bodyString?.length ?: 0}, timestamp=$timestamp")
|
||||
logger.debug("签名字符串: $signString")
|
||||
logger.debug("签名结果: ${signature.take(20)}...")
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
// 重新创建请求体(如果原始请求有请求体)
|
||||
val newRequestBody = originalRequest.body?.let { requestBody ->
|
||||
val contentType = requestBody.contentType()
|
||||
RequestBody.create(
|
||||
contentType,
|
||||
bodyString?.toByteArray() ?: ByteArray(0)
|
||||
)
|
||||
}
|
||||
|
||||
// 构建新的请求,添加认证头(使用 POLY_* 前缀)
|
||||
// 参考 clob-client/src/http-helpers/index.ts 的 overloadHeaders 函数
|
||||
// 添加标准 HTTP 请求头以匹配 clob-client 的行为
|
||||
val newRequestBuilder = originalRequest.newBuilder()
|
||||
.header("POLY_ADDRESS", walletAddress)
|
||||
.header("POLY_SIGNATURE", signature)
|
||||
.header("POLY_TIMESTAMP", timestamp.toString())
|
||||
.header("POLY_API_KEY", apiKey)
|
||||
.header("POLY_PASSPHRASE", apiPassphrase)
|
||||
.header("User-Agent", "@polymarket/clob-client")
|
||||
.header("Accept", "*/*")
|
||||
.header("Connection", "keep-alive")
|
||||
.header("Content-Type", "application/json")
|
||||
.apply {
|
||||
// GET 请求添加 Accept-Encoding: gzip
|
||||
if (method == "GET") {
|
||||
header("Accept-Encoding", "gzip")
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有请求体,重新设置请求体
|
||||
if (newRequestBody != null) {
|
||||
newRequestBuilder.method(originalRequest.method, newRequestBody)
|
||||
}
|
||||
|
||||
return chain.proceed(newRequestBuilder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 HMAC-SHA256 生成签名
|
||||
* 参考 clob-client/src/signing/hmac.ts 实现
|
||||
*
|
||||
* 注意:Node.js 的 Buffer.from(secret, "base64") 可以处理 URL-safe base64(包含 - 和 _)
|
||||
* Java 提供了 Base64.getUrlDecoder() 来直接处理 URL-safe base64
|
||||
*
|
||||
* 1. Secret 需要 base64 解码(支持标准 base64 和 URL-safe base64)
|
||||
* 2. 使用解码后的 secret 进行 HMAC-SHA256
|
||||
* 3. 结果进行 URL-safe base64 编码(+ -> -, / -> _)
|
||||
*/
|
||||
private fun generateSignature(message: String, secret: String): String {
|
||||
// Secret 可能是标准 base64 或 URL-safe base64
|
||||
// 优先尝试标准 base64,如果失败则使用 URL-safe 解码器
|
||||
val decodedSecret = try {
|
||||
// 先尝试标准 base64 解码
|
||||
Base64.getDecoder().decode(secret)
|
||||
} catch (e: Exception) {
|
||||
// 如果失败,可能是 URL-safe base64,使用 URL 解码器
|
||||
// Base64.getUrlDecoder() 可以直接处理 URL-safe base64(- 和 _)
|
||||
try {
|
||||
Base64.getUrlDecoder().decode(secret)
|
||||
} catch (e2: Exception) {
|
||||
// 如果都失败,尝试转换为标准格式后再解码(向后兼容)
|
||||
val standardBase64 = secret.replace("-", "+").replace("_", "/")
|
||||
try {
|
||||
Base64.getDecoder().decode(standardBase64)
|
||||
} catch (e3: Exception) {
|
||||
// 最后尝试直接使用原始字符串(向后兼容)
|
||||
secret.toByteArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
val secretKeySpec = SecretKeySpec(secret.toByteArray(), "HmacSHA256")
|
||||
val secretKeySpec = SecretKeySpec(decodedSecret, "HmacSHA256")
|
||||
mac.init(secretKeySpec)
|
||||
val hash = mac.doFinal(message.toByteArray())
|
||||
return Base64.getEncoder().encodeToString(hash)
|
||||
|
||||
// Base64 编码
|
||||
val base64Signature = Base64.getEncoder().encodeToString(hash)
|
||||
|
||||
// URL-safe base64 编码:将 + 替换为 -,将 / 替换为 _
|
||||
// 注意:保留 = 后缀(根据 clob-client 注释)
|
||||
// 使用 replaceAll 替换所有匹配项(与 clob-client 的 replaceAll 函数一致)
|
||||
return base64Signature.replace("+", "-").replace("/", "_")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Polymarket API L1 认证拦截器
|
||||
* 用于创建/获取 API Key 时的认证
|
||||
*
|
||||
* 参考 clob-client/src/headers/index.ts 的 createL1Headers 实现
|
||||
*
|
||||
* 认证方式:
|
||||
* 1. 使用 EIP-712 签名对请求进行签名
|
||||
* 2. 在请求头中添加:
|
||||
* - POLY_ADDRESS: 钱包地址
|
||||
* - POLY_SIGNATURE: EIP-712 签名字符串
|
||||
* - POLY_TIMESTAMP: 时间戳(秒,字符串)
|
||||
* - POLY_NONCE: 随机数(字符串,默认 0)
|
||||
*/
|
||||
class PolymarketL1AuthInterceptor(
|
||||
private val privateKey: String,
|
||||
private val walletAddress: String,
|
||||
private val chainId: Long = 137L, // Polygon 主网
|
||||
private val nonce: Long = 0L,
|
||||
private val useServerTime: Boolean = false,
|
||||
private val serverTime: Long? = null
|
||||
) : Interceptor {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketL1AuthInterceptor::class.java)
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
// 获取时间戳(优先使用服务器时间,否则使用当前时间)
|
||||
val timestamp = if (useServerTime && serverTime != null) {
|
||||
serverTime
|
||||
} else {
|
||||
System.currentTimeMillis() / 1000
|
||||
}
|
||||
|
||||
// 生成 EIP-712 签名
|
||||
val signature = try {
|
||||
Eip712Signer.buildClobEip712Signature(
|
||||
privateKey = privateKey,
|
||||
chainId = chainId,
|
||||
timestamp = timestamp,
|
||||
nonce = nonce
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("生成 EIP-712 签名失败", e)
|
||||
throw IOException("生成签名失败: ${e.message}", e)
|
||||
}
|
||||
|
||||
// 构建新的请求,添加 L1 认证头
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.header("POLY_ADDRESS", walletAddress)
|
||||
.header("POLY_SIGNATURE", signature)
|
||||
.header("POLY_TIMESTAMP", timestamp.toString())
|
||||
.header("POLY_NONCE", nonce.toString())
|
||||
.build()
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,14 +22,16 @@ class RetrofitFactory(
|
||||
* @param apiKey API Key
|
||||
* @param apiSecret API Secret
|
||||
* @param apiPassphrase API Passphrase
|
||||
* @param walletAddress 钱包地址(用于 POLY_ADDRESS 请求头)
|
||||
* @return PolymarketClobApi 客户端
|
||||
*/
|
||||
fun createClobApi(
|
||||
apiKey: String,
|
||||
apiSecret: String,
|
||||
apiPassphrase: String
|
||||
apiPassphrase: String,
|
||||
walletAddress: String
|
||||
): PolymarketClobApi {
|
||||
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase)
|
||||
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
|
||||
|
||||
val okHttpClient = createClient()
|
||||
.addInterceptor(authInterceptor)
|
||||
|
||||
@@ -40,6 +40,3 @@ polymarket.data-api.base-url=https://data-api.polymarket.com
|
||||
# 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID
|
||||
ethereum.rpc.url=${ETHEREUM_RPC_URL:https://polygon-rpc.com}
|
||||
|
||||
# 加密配置
|
||||
crypto.secret.key=${CRYPTO_SECRET_KEY:wrbug123}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user