fix: 修复交易统计和L2认证问题

- 修复交易统计数据计算逻辑(总订单数、已完成订单数、持仓数量)
- 修复L2认证签名生成,支持URL-safe base64格式的secret
- 添加标准HTTP请求头以匹配clob-client行为
- 移除数据库加密(私人应用,明文存储)
- 添加自动获取API Key功能
- 优化持仓数量统计(包括正负仓位)
- 添加EIP-712签名和L1认证支持
- 更新前端账户管理界面
This commit is contained in:
WrBug
2025-11-26 22:26:50 +08:00
parent 4f7fef145f
commit 389a758c89
33 changed files with 3242 additions and 275 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
.pnpm-store/ .pnpm-store/
polymarket-trading-bot/
# Frontend build # Frontend build
frontend/dist/ frontend/dist/
frontend/.vite/ frontend/.vite/
+3
View File
@@ -54,6 +54,9 @@ dependencies {
// Keccak-256 for Ethereum function selector // Keccak-256 for Ethereum function selector
implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
// Web3j for Ethereum wallet and EIP-712 signing
implementation("org.web3j:core:5.0.0")
// Logging // Logging
implementation("org.slf4j:slf4j-api") implementation("org.slf4j:slf4j-api")
@@ -110,6 +110,29 @@ interface PolymarketClobApi {
@Query("after") after: String? = null, @Query("after") after: String? = null,
@Query("next_cursor") next_cursor: String? = null @Query("next_cursor") next_cursor: String? = null
): Response<GetTradesResponse> ): Response<GetTradesResponse>
/**
* 创建 API KeyL1 认证)
* 端点: /auth/api-key
* 需要 L1 认证头(POLY_ADDRESS, POLY_SIGNATURE, POLY_TIMESTAMP, POLY_NONCE
*/
@POST("/auth/api-key")
suspend fun createApiKey(): Response<ApiKeyResponse>
/**
* 获取现有 API KeyL1 认证)
* 端点: /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 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("sortDirection") sortDirection: String? = null,
@Query("title") title: String? = null @Query("title") title: String? = null
): Response<List<PositionResponse>> ): 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 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 privateKey: String, // 私钥(前端加密后传输)
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证) val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
val accountName: String? = null, 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 val isDefault: Boolean = false
) )
@@ -19,9 +16,6 @@ data class AccountImportRequest(
data class AccountUpdateRequest( data class AccountUpdateRequest(
val accountId: Long, val accountId: Long,
val accountName: String? = null, val accountName: String? = null,
val apiKey: String? = null,
val apiSecret: String? = null,
val apiPassphrase: String? = null,
val isDefault: Boolean? = null val isDefault: Boolean? = null
) )
@@ -66,7 +60,10 @@ data class AccountDto(
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置 val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
val balance: String? = null, // 账户余额(可选) val balance: String? = null, // 账户余额(可选)
val totalOrders: Long? = 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, val id: Long? = null,
@Column(name = "private_key", nullable = false, length = 500) @Column(name = "private_key", nullable = false, length = 500)
val privateKey: String, // 私钥(加密存储) val privateKey: String, // 私钥(明文存储)
@Column(name = "wallet_address", unique = true, nullable = false, length = 42) @Column(name = "wallet_address", unique = true, nullable = false, length = 42)
val walletAddress: String, // 钱包地址(从私钥推导) val walletAddress: String, // 钱包地址(从私钥推导)
@@ -23,13 +23,13 @@ data class Account(
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须) val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
@Column(name = "api_key", length = 500) @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) @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) @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) @Column(name = "account_name", length = 100)
val accountName: String? = null, val accountName: String? = null,
@@ -1,9 +1,9 @@
package com.wrbug.polymarketbot.service package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.dto.* import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Account import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.repository.AccountRepository import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -18,10 +18,10 @@ import java.math.BigDecimal
@Service @Service
class AccountService( class AccountService(
private val accountRepository: AccountRepository, private val accountRepository: AccountRepository,
private val cryptoUtils: CryptoUtils,
private val clobService: PolymarketClobService, private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory, private val retrofitFactory: RetrofitFactory,
private val blockchainService: BlockchainService private val blockchainService: BlockchainService,
private val apiKeyService: PolymarketApiKeyService
) { ) {
private val logger = LoggerFactory.getLogger(AccountService::class.java) private val logger = LoggerFactory.getLogger(AccountService::class.java)
@@ -49,11 +49,30 @@ class AccountService(
return Result.failure(IllegalArgumentException("无效的私钥格式")) return Result.failure(IllegalArgumentException("无效的私钥格式"))
} }
// 4. 加密私钥和 API 凭证 // 4. 自动获取或创建 API Key(必须成功,否则导入失败)
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey) logger.info("开始自动获取或创建 API Key: ${request.walletAddress}")
val encryptedApiKey = request.apiKey?.let { cryptoUtils.encrypt(it) } val apiKeyCreds = runBlocking {
val encryptedApiSecret = request.apiSecret?.let { cryptoUtils.encrypt(it) } val result = apiKeyService.createOrDeriveApiKey(
val encryptedApiPassphrase = request.apiPassphrase?.let { cryptoUtils.encrypt(it) } 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. 如果设置为默认账户,取消其他账户的默认状态 // 5. 如果设置为默认账户,取消其他账户的默认状态
if (request.isDefault) { if (request.isDefault) {
@@ -84,12 +103,12 @@ class AccountService(
// 7. 创建账户 // 7. 创建账户
val account = Account( val account = Account(
privateKey = encryptedPrivateKey, privateKey = request.privateKey,
walletAddress = request.walletAddress, walletAddress = request.walletAddress,
proxyAddress = proxyAddress, proxyAddress = proxyAddress,
apiKey = encryptedApiKey, apiKey = apiKeyCreds.apiKey,
apiSecret = encryptedApiSecret, apiSecret = apiKeyCreds.secret,
apiPassphrase = encryptedApiPassphrase, apiPassphrase = apiKeyCreds.passphrase,
accountName = request.accountName, accountName = request.accountName,
isDefault = request.isDefault, isDefault = request.isDefault,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
@@ -118,23 +137,6 @@ class AccountService(
// 更新账户名称 // 更新账户名称
val updatedAccountName = request.accountName ?: account.accountName 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 val updatedIsDefault = request.isDefault ?: account.isDefault
if (updatedIsDefault && !account.isDefault) { if (updatedIsDefault && !account.isDefault) {
@@ -146,9 +148,6 @@ class AccountService(
val updated = account.copy( val updated = account.copy(
accountName = updatedAccountName, accountName = updatedAccountName,
apiKey = updatedApiKey,
apiSecret = updatedApiSecret,
apiPassphrase = updatedApiPassphrase,
isDefault = updatedIsDefault, isDefault = updatedIsDefault,
updatedAt = System.currentTimeMillis() updatedAt = System.currentTimeMillis()
) )
@@ -262,7 +261,7 @@ class AccountService(
// 查询 USDC 余额和持仓信息 // 查询 USDC 余额和持仓信息
val balanceResult = runBlocking { val balanceResult = runBlocking {
try { try {
// 查询持仓信息(用于计算仓位余额和返回持仓列表) // 查询持仓信息(用于返回持仓列表)
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓) // 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
val positionsResult = blockchainService.getPositions(account.proxyAddress) val positionsResult = blockchainService.getPositions(account.proxyAddress)
val positions = if (positionsResult.isSuccess) { val positions = if (positionsResult.isSuccess) {
@@ -281,9 +280,13 @@ class AccountService(
emptyList() emptyList()
} }
// 计算仓位余额(持仓总价值 // 使用 /value 接口获取仓位总价值(而不是累加
val positionBalance = positions.sumOf { val positionBalanceResult = blockchainService.getTotalValue(account.proxyAddress)
it.currentValue.toSafeBigDecimal() val positionBalance = if (positionBalanceResult.isSuccess) {
positionBalanceResult.getOrNull() ?: "0"
} else {
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
"0"
} }
// 查询可用余额(通过 RPC 查询 USDC 余额) // 查询可用余额(通过 RPC 查询 USDC 余额)
@@ -302,11 +305,11 @@ class AccountService(
} }
// 计算总余额 = 可用余额 + 仓位余额 // 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance) val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
AccountBalanceResponse( AccountBalanceResponse(
availableBalance = availableBalance, availableBalance = availableBalance,
positionBalance = positionBalance.toPlainString(), positionBalance = positionBalance,
totalBalance = totalBalance.toPlainString(), totalBalance = totalBalance.toPlainString(),
positions = positions positions = positions
) )
@@ -354,7 +357,7 @@ class AccountService(
/** /**
* 转换为 DTO * 转换为 DTO
* 包含交易统计数据(总订单数总盈亏) * 包含交易统计数据(总订单数总盈亏、活跃订单数、已完成订单数、持仓数量
*/ */
private fun toDto(account: Account): AccountDto { private fun toDto(account: Account): AccountDto {
return runBlocking { return runBlocking {
@@ -368,7 +371,10 @@ class AccountService(
apiSecretConfigured = account.apiSecret != null, apiSecretConfigured = account.apiSecret != null,
apiPassphraseConfigured = account.apiPassphrase != null, apiPassphraseConfigured = account.apiPassphrase != null,
totalOrders = statistics.totalOrders, 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 { return try {
// 如果账户没有配置 API 凭证,无法查询统计数据 // 如果账户没有配置 API 凭证,无法查询统计数据
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { 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 凭证 // 使用 API 凭证(直接使用,无需解密)
val apiKey = cryptoUtils.decrypt(account.apiKey) val apiKey = account.apiKey
val apiSecret = cryptoUtils.decrypt(account.apiSecret) val apiSecret = account.apiSecret
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase) val apiPassphrase = account.apiPassphrase
// 创建带认证的 API 客户端 // 创建带认证的 API 客户端(需要钱包地址用于 POLY_ADDRESS 请求头)
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase) val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase, account.walletAddress)
// 1. 查询交易记录数量(总订单数 // 1. 查询活跃订单数量(open/active 状态
val tradesResult = runBlocking { val activeOrdersResult = try {
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( val response = clobApi.getTrades(
maker_address = account.proxyAddress, maker_address = account.proxyAddress,
next_cursor = null next_cursor = nextCursor
) )
if (response.isSuccessful && response.body() != null) { if (response.isSuccessful && response.body() != null) {
val tradesResponse = response.body()!! val tradesResponse = response.body()!!
// 统计所有交易(需要分页查询所有) allTrades.addAll(tradesResponse.data)
var totalTrades = tradesResponse.data.size nextCursor = tradesResponse.next_cursor
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())
} else { } else {
Result.failure(Exception("查询交易记录失败: ${response.code()} ${response.message()}")) break
} }
} catch (e: Exception) { } while (nextCursor != null && nextCursor.isNotEmpty())
logger.warn("查询交易记录失败: ${e.message}", e)
Result.failure(e) // 注意: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. 查询仓位信息计算总盈亏(已实现盈亏) // 3. 查询仓位信息计算总盈亏(已实现盈亏)和持仓数量
val totalPnlResult = runBlocking { val positionsResult = try {
try { val positions = blockchainService.getPositions(account.proxyAddress)
val positionsResult = blockchainService.getPositions(account.proxyAddress) if (positions.isSuccess) {
if (positionsResult.isSuccess) { val positionList = positions.getOrNull() ?: emptyList()
val positions = positionsResult.getOrNull() ?: emptyList() // 汇总所有仓位的已实现盈亏
// 汇总所有仓位的已实现盈亏 val totalRealizedPnl = positionList.sumOf { pos ->
val totalRealizedPnl = positions.sumOf { pos -> pos.realizedPnl?.toSafeBigDecimal() ?: BigDecimal.ZERO
pos.realizedPnl?.toSafeBigDecimal() ?: BigDecimal.ZERO
}
Result.success(totalRealizedPnl.toPlainString())
} else {
Result.failure(Exception("查询仓位信息失败"))
} }
} catch (e: Exception) { // 统计持仓数量(所有非零持仓,包括正负仓位)
logger.warn("查询仓位盈亏失败: ${e.message}", e) // size 可能为正数(做多)或负数(做空),都应该统计
Result.failure(e) 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( AccountStatistics(
totalOrders = tradesResult.getOrNull(), totalOrders = totalOrders,
totalPnl = totalPnlResult.getOrNull() totalPnl = totalPnl,
activeOrders = activeOrders,
completedOrders = completedOrders, // 已完成订单数 = 交易记录数(已成交的订单)
positionCount = positionCount
) )
} catch (e: Exception) { } catch (e: Exception) {
logger.warn("获取账户统计数据失败: ${e.message}", e) 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( private data class AccountStatistics(
val totalOrders: Long?, 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 return false
} }
// 解密 API 凭证(前面已检查不为 null // 使用 API 凭证(直接使用,无需解密
val apiKey = cryptoUtils.decrypt(account.apiKey) val apiKey = account.apiKey
val apiSecret = cryptoUtils.decrypt(account.apiSecret) val apiSecret = account.apiSecret
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase) val apiPassphrase = account.apiPassphrase
// 创建带认证的 API 客户端 // 创建带认证的 API 客户端(需要钱包地址用于 POLY_ADDRESS 请求头)
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase) val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase, account.walletAddress)
// 查询活跃订单(只查询第一条,用于判断是否有订单) // 查询活跃订单(只查询第一条,用于判断是否有订单)
// 使用 next_cursor 参数进行分页,这里只查询第一页 // 使用 next_cursor 参数进行分页,这里只查询第一页
@@ -5,6 +5,7 @@ import com.wrbug.polymarketbot.api.JsonRpcRequest
import com.wrbug.polymarketbot.api.JsonRpcResponse import com.wrbug.polymarketbot.api.JsonRpcResponse
import com.wrbug.polymarketbot.api.PolymarketDataApi import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.PositionResponse import com.wrbug.polymarketbot.api.PositionResponse
import com.wrbug.polymarketbot.api.ValueResponse
import com.wrbug.polymarketbot.util.EthereumUtils import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient import com.wrbug.polymarketbot.util.createClient
@@ -238,5 +239,39 @@ class BlockchainService(
Result.failure(e) 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(默认 137Polygon 主网)
* @return API Key 凭证,如果失败则返回错误
*/
fun createOrDeriveApiKey(
privateKey: String,
walletAddress: String,
chainId: Long = 137L
): Result<ApiKeyCreds> {
return runBlocking {
try {
// 先尝试获取现有的 API Keyderive
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 链 IDPolygon 主网是 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)
}
}
}
@@ -1,9 +1,12 @@
package com.wrbug.polymarketbot.util package com.wrbug.polymarketbot.util
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response import okhttp3.Response
import okio.Buffer import okio.Buffer
import org.slf4j.LoggerFactory
import java.io.IOException import java.io.IOException
import java.time.Instant import java.time.Instant
import javax.crypto.Mac import javax.crypto.Mac
@@ -14,64 +17,150 @@ import java.util.Base64
* Polymarket API 认证拦截器 * Polymarket API 认证拦截器
* 实现 L2 认证(使用 API Key、Secret、Passphrase * 实现 L2 认证(使用 API Key、Secret、Passphrase
* *
* 参考 clob-client 实现:
* - 请求头使用 POLY_* 前缀
* - Secret 需要 base64 解码后用于 HMAC
* - 签名结果需要 URL-safe base64 编码(+ -> -, / -> _
*
* 认证方式: * 认证方式:
* 1. 生成时间戳(秒) * 1. 生成时间戳(秒)
* 2. 使用 Secret 对 (timestamp + method + path + body) 进行 HMAC-SHA256 签名 * 2. 使用 Secretbase64 解码后)对 (timestamp + method + requestPath + body) 进行 HMAC-SHA256 签名
* 3. 在请求头中添加: * 3. 在请求头中添加:
* - X-API-KEY: API Key * - POLY_ADDRESS: 钱包地址
* - X-API-SIGN: Base64 编码的签名 * - POLY_SIGNATURE: URL-safe Base64 编码的签名
* - X-API-TIMESTAMP: 时间戳 * - POLY_TIMESTAMP: 时间戳(字符串)
* - X-API-PASSPHRASE: Passphrase * - POLY_API_KEY: API Key
* - POLY_PASSPHRASE: Passphrase
*/ */
class PolymarketAuthInterceptor( class PolymarketAuthInterceptor(
private val apiKey: String, private val apiKey: String,
private val apiSecret: String, private val apiSecret: String,
private val apiPassphrase: String private val apiPassphrase: String,
private val walletAddress: String
) : Interceptor { ) : Interceptor {
private val logger = LoggerFactory.getLogger(PolymarketAuthInterceptor::class.java)
@Throws(IOException::class) @Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request() val originalRequest = chain.request()
// 生成时间戳(秒) // 生成时间戳(秒)
val timestamp = Instant.now().epochSecond.toString() val timestamp = Instant.now().epochSecond
// 构建签名字符串: timestamp + method + path + body // 构建签名字符串: timestamp + method + requestPath + body
val method = originalRequest.method // requestPath 不包含 query string(根据 clob-client 实现)
val path = originalRequest.url.encodedPath + if (originalRequest.url.query != null) "?${originalRequest.url.query}" else "" 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() val buffer = Buffer()
requestBody.writeTo(buffer) requestBody.writeTo(buffer)
buffer.readUtf8() 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 signature = generateSignature(signString, apiSecret)
// 构建新的请求,添加认证头 // 调试日志(仅在 DEBUG 级别输出)
val newRequest = originalRequest.newBuilder() logger.debug("L2 认证签名生成: method=$method, path=$requestPath, bodyLength=${bodyString?.length ?: 0}, timestamp=$timestamp")
.header("X-API-KEY", apiKey) logger.debug("签名字符串: $signString")
.header("X-API-SIGN", signature) logger.debug("签名结果: ${signature.take(20)}...")
.header("X-API-TIMESTAMP", timestamp)
.header("X-API-PASSPHRASE", apiPassphrase)
.build()
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 生成签名 * 使用 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 { 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 mac = Mac.getInstance("HmacSHA256")
val secretKeySpec = SecretKeySpec(secret.toByteArray(), "HmacSHA256") val secretKeySpec = SecretKeySpec(decodedSecret, "HmacSHA256")
mac.init(secretKeySpec) mac.init(secretKeySpec)
val hash = mac.doFinal(message.toByteArray()) 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 apiKey API Key
* @param apiSecret API Secret * @param apiSecret API Secret
* @param apiPassphrase API Passphrase * @param apiPassphrase API Passphrase
* @param walletAddress 钱包地址(用于 POLY_ADDRESS 请求头)
* @return PolymarketClobApi 客户端 * @return PolymarketClobApi 客户端
*/ */
fun createClobApi( fun createClobApi(
apiKey: String, apiKey: String,
apiSecret: String, apiSecret: String,
apiPassphrase: String apiPassphrase: String,
walletAddress: String
): PolymarketClobApi { ): PolymarketClobApi {
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase) val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
val okHttpClient = createClient() val okHttpClient = createClient()
.addInterceptor(authInterceptor) .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 # 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID
ethereum.rpc.url=${ETHEREUM_RPC_URL:https://polygon-rpc.com} ethereum.rpc.url=${ETHEREUM_RPC_URL:https://polygon-rpc.com}
# 加密配置
crypto.secret.key=${CRYPTO_SECRET_KEY:wrbug123}
+2
View File
@@ -5,6 +5,7 @@ import Layout from './components/Layout'
import AccountList from './pages/AccountList' import AccountList from './pages/AccountList'
import AccountImport from './pages/AccountImport' import AccountImport from './pages/AccountImport'
import AccountDetail from './pages/AccountDetail' import AccountDetail from './pages/AccountDetail'
import AccountEdit from './pages/AccountEdit'
import LeaderList from './pages/LeaderList' import LeaderList from './pages/LeaderList'
import LeaderAdd from './pages/LeaderAdd' import LeaderAdd from './pages/LeaderAdd'
import ConfigPage from './pages/ConfigPage' import ConfigPage from './pages/ConfigPage'
@@ -21,6 +22,7 @@ function App() {
<Route path="/accounts" element={<AccountList />} /> <Route path="/accounts" element={<AccountList />} />
<Route path="/accounts/import" element={<AccountImport />} /> <Route path="/accounts/import" element={<AccountImport />} />
<Route path="/accounts/detail" element={<AccountDetail />} /> <Route path="/accounts/detail" element={<AccountDetail />} />
<Route path="/accounts/edit" element={<AccountEdit />} />
<Route path="/leaders" element={<LeaderList />} /> <Route path="/leaders" element={<LeaderList />} />
<Route path="/leaders/add" element={<LeaderAdd />} /> <Route path="/leaders/add" element={<LeaderAdd />} />
<Route path="/config" element={<ConfigPage />} /> <Route path="/config" element={<ConfigPage />} />
+1 -1
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { Layout as AntLayout, Menu, Drawer, Button } from 'antd' import { Layout as AntLayout, Menu, Drawer, Button } from 'antd'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
+174 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { Card, Descriptions, Button, Space, Tag, Spin, message, Typography, Divider } from 'antd' import { Card, Descriptions, Button, Space, Tag, Spin, message, Typography, Divider, Modal, Form, Input, Checkbox, Alert } from 'antd'
import { ArrowLeftOutlined, ReloadOutlined, EditOutlined } from '@ant-design/icons' import { ArrowLeftOutlined, ReloadOutlined, EditOutlined } from '@ant-design/icons'
import { useAccountStore } from '../store/accountStore' import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types' import type { Account } from '../types'
@@ -14,11 +14,14 @@ const AccountDetail: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const accountId = searchParams.get('id') const accountId = searchParams.get('id')
const { fetchAccountDetail, fetchAccountBalance } = useAccountStore() const { fetchAccountDetail, fetchAccountBalance, updateAccount } = useAccountStore()
const [account, setAccount] = useState<Account | null>(null) const [account, setAccount] = useState<Account | null>(null)
const [balance, setBalance] = useState<string | null>(null) const [balance, setBalance] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [balanceLoading, setBalanceLoading] = useState(false) const [balanceLoading, setBalanceLoading] = useState(false)
const [editModalVisible, setEditModalVisible] = useState(false)
const [editForm] = Form.useForm()
const [editLoading, setEditLoading] = useState(false)
useEffect(() => { useEffect(() => {
if (accountId) { if (accountId) {
@@ -51,7 +54,7 @@ const AccountDetail: React.FC = () => {
setBalanceLoading(true) setBalanceLoading(true)
try { try {
const balanceData = await fetchAccountBalance(Number(accountId)) const balanceData = await fetchAccountBalance(Number(accountId))
setBalance(balanceData.balance || null) setBalance(balanceData.totalBalance || null)
} catch (error: any) { } catch (error: any) {
console.error('获取余额失败:', error) console.error('获取余额失败:', error)
// 余额查询失败不显示错误,只显示 "-" // 余额查询失败不显示错误,只显示 "-"
@@ -61,6 +64,46 @@ const AccountDetail: React.FC = () => {
} }
} }
const handleEditSubmit = async (values: any) => {
if (!account) return
setEditLoading(true)
try {
// 构建更新请求,空字符串转换为 undefined(不修改)
const updateData: any = {
accountId: account.id,
accountName: values.accountName || undefined,
isDefault: values.isDefault || false
}
// 只有非空字符串才更新 API 凭证
if (values.apiKey && values.apiKey.trim()) {
updateData.apiKey = values.apiKey.trim()
}
if (values.apiSecret && values.apiSecret.trim()) {
updateData.apiSecret = values.apiSecret.trim()
}
if (values.apiPassphrase && values.apiPassphrase.trim()) {
updateData.apiPassphrase = values.apiPassphrase.trim()
}
await updateAccount(updateData)
message.success('更新账户成功')
setEditModalVisible(false)
editForm.resetFields()
// 刷新账户详情
if (accountId) {
await loadAccountDetail()
}
} catch (error: any) {
message.error(error.message || '更新账户失败')
} finally {
setEditLoading(false)
}
}
if (loading) { if (loading) {
return ( return (
<div style={{ textAlign: 'center', padding: '50px' }}> <div style={{ textAlign: 'center', padding: '50px' }}>
@@ -113,7 +156,16 @@ const AccountDetail: React.FC = () => {
<Button <Button
type="primary" type="primary"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => navigate(`/accounts/edit?id=${account.id}`)} onClick={() => {
setEditModalVisible(true)
editForm.setFieldsValue({
accountName: account.accountName || '',
apiKey: '', // 不显示实际值,留空表示不修改
apiSecret: '', // 不显示实际值,留空表示不修改
apiPassphrase: '', // 不显示实际值,留空表示不修改
isDefault: account.isDefault || false
})
}}
size={isMobile ? 'middle' : 'large'} size={isMobile ? 'middle' : 'large'}
block={isMobile} block={isMobile}
style={isMobile ? { minHeight: '44px' } : undefined} style={isMobile ? { minHeight: '44px' } : undefined}
@@ -210,7 +262,9 @@ const AccountDetail: React.FC = () => {
</Descriptions> </Descriptions>
</Card> </Card>
{account.totalOrders !== undefined || account.totalPnl !== undefined ? ( {(account.totalOrders !== undefined || account.totalPnl !== undefined ||
account.activeOrders !== undefined ||
account.completedOrders !== undefined || account.positionCount !== undefined) ? (
<> <>
<Divider style={{ margin: isMobile ? '12px 0' : '16px 0' }} /> <Divider style={{ margin: isMobile ? '12px 0' : '16px 0' }} />
<Card <Card
@@ -232,6 +286,21 @@ const AccountDetail: React.FC = () => {
{account.totalOrders} {account.totalOrders}
</Descriptions.Item> </Descriptions.Item>
)} )}
{account.activeOrders !== undefined && (
<Descriptions.Item label="活跃订单数">
<Tag color={account.activeOrders > 0 ? 'orange' : 'default'}>{account.activeOrders}</Tag>
</Descriptions.Item>
)}
{account.completedOrders !== undefined && (
<Descriptions.Item label="已完成订单数">
<Tag color="success">{account.completedOrders}</Tag>
</Descriptions.Item>
)}
{account.positionCount !== undefined && (
<Descriptions.Item label="持仓数量">
<Tag color={account.positionCount > 0 ? 'blue' : 'default'}>{account.positionCount}</Tag>
</Descriptions.Item>
)}
{account.totalPnl !== undefined && ( {account.totalPnl !== undefined && (
<Descriptions.Item label="总盈亏"> <Descriptions.Item label="总盈亏">
<span style={{ <span style={{
@@ -246,6 +315,106 @@ const AccountDetail: React.FC = () => {
</Card> </Card>
</> </>
) : null} ) : null}
{/* 编辑账户 Modal */}
<Modal
title={account ? `编辑账户 - ${account.accountName || `账户 ${account.id}`}` : '编辑账户'}
open={editModalVisible}
onCancel={() => {
setEditModalVisible(false)
editForm.resetFields()
}}
footer={null}
width={isMobile ? '95%' : 600}
style={{ top: isMobile ? 20 : 50 }}
destroyOnClose
maskClosable
closable
>
{account ? (
<Form
form={editForm}
layout="vertical"
onFinish={handleEditSubmit}
size={isMobile ? 'middle' : 'large'}
>
<Alert
message="编辑提示"
description="API 凭证字段留空表示不修改。如需更新 API 凭证,请输入新值;如需保持原值不变,请留空。"
type="info"
showIcon
style={{ marginBottom: '24px' }}
/>
<Form.Item
label="账户名称"
name="accountName"
>
<Input placeholder="账户名称(可选)" />
</Form.Item>
<Form.Item
label="API Key"
name="apiKey"
help="留空表示不修改,输入新值将更新 API Key"
>
<Input.Password placeholder="留空表示不修改" />
</Form.Item>
<Form.Item
label="API Secret"
name="apiSecret"
help="留空表示不修改,输入新值将更新 API Secret"
>
<Input.Password placeholder="留空表示不修改" />
</Form.Item>
<Form.Item
label="API Passphrase"
name="apiPassphrase"
help="留空表示不修改,输入新值将更新 API Passphrase"
>
<Input.Password placeholder="留空表示不修改" />
</Form.Item>
<Form.Item
name="isDefault"
valuePropName="checked"
>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button
onClick={() => {
setEditModalVisible(false)
editForm.resetFields()
}}
size={isMobile ? 'middle' : 'large'}
style={isMobile ? { minHeight: '44px' } : undefined}
>
</Button>
<Button
type="primary"
htmlType="submit"
loading={editLoading}
size={isMobile ? 'middle' : 'large'}
style={isMobile ? { minHeight: '44px' } : undefined}
>
</Button>
</Space>
</Form.Item>
</Form>
) : (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin size="large" />
<div style={{ marginTop: '16px' }}>...</div>
</div>
)}
</Modal>
</div> </div>
) )
} }
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Card, Form, Input, Button, message, Typography, Space, Alert, Checkbox } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { useAccountStore } from '../store/accountStore'
import { useMediaQuery } from 'react-responsive'
const { Title } = Typography
const AccountEdit: React.FC = () => {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const isMobile = useMediaQuery({ maxWidth: 768 })
const accountId = searchParams.get('id')
const { fetchAccountDetail, updateAccount, loading } = useAccountStore()
const [form] = Form.useForm()
const [account, setAccount] = useState<any>(null)
const [loadingDetail, setLoadingDetail] = useState(true)
useEffect(() => {
if (accountId) {
loadAccountDetail()
} else {
message.error('账户ID不能为空')
navigate('/accounts')
}
}, [accountId])
const loadAccountDetail = async () => {
if (!accountId) return
setLoadingDetail(true)
try {
const accountData = await fetchAccountDetail(Number(accountId))
setAccount(accountData)
// 设置表单初始值
form.setFieldsValue({
accountName: accountData.accountName || '',
isDefault: accountData.isDefault || false
})
} catch (error: any) {
message.error(error.message || '获取账户详情失败')
navigate('/accounts')
} finally {
setLoadingDetail(false)
}
}
const handleSubmit = async (values: any) => {
if (!accountId) return
try {
// 构建更新请求
const updateData: any = {
accountId: Number(accountId),
accountName: values.accountName || undefined,
isDefault: values.isDefault || false
}
await updateAccount(updateData)
message.success('更新账户成功')
navigate(`/accounts/detail?id=${accountId}`)
} catch (error: any) {
message.error(error.message || '更新账户失败')
}
}
if (loadingDetail) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<div>...</div>
</div>
)
}
if (!account) {
return null
}
return (
<div style={{
padding: isMobile ? '0' : undefined,
margin: isMobile ? '0 -8px' : undefined
}}>
<div style={{
marginBottom: isMobile ? '12px' : '16px',
padding: isMobile ? '0 8px' : '0'
}}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate(`/accounts/detail?id=${accountId}`)}
style={{ marginBottom: '16px' }}
size={isMobile ? 'middle' : 'large'}
>
</Button>
<Title level={isMobile ? 4 : 2} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}>
</Title>
</div>
<Card style={{
margin: isMobile ? '0 -8px' : '0',
borderRadius: isMobile ? '0' : undefined
}}>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
size={isMobile ? 'middle' : 'large'}
>
<Form.Item
label="账户名称"
name="accountName"
>
<Input placeholder="账户名称(可选)" />
</Form.Item>
<Alert
message="API Key 管理"
description="API Key 由系统自动管理,无需手动更新。如需重新获取 API Key,请删除并重新导入账户。"
type="info"
showIcon
style={{ marginBottom: '24px' }}
/>
<Form.Item
name="isDefault"
valuePropName="checked"
>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
loading={loading}
size={isMobile ? 'middle' : 'large'}
style={isMobile ? { minHeight: '44px' } : undefined}
>
</Button>
<Button
onClick={() => navigate(`/accounts/detail?id=${accountId}`)}
size={isMobile ? 'middle' : 'large'}
style={isMobile ? { minHeight: '44px' } : undefined}
>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default AccountEdit
+11 -30
View File
@@ -13,7 +13,7 @@ import {
} from '../utils/ethers' } from '../utils/ethers'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
const { Title, Text } = Typography const { Title } = Typography
type ImportType = 'privateKey' | 'mnemonic' type ImportType = 'privateKey' | 'mnemonic'
@@ -27,7 +27,7 @@ const AccountImport: React.FC = () => {
const [addressError, setAddressError] = useState<string>('') const [addressError, setAddressError] = useState<string>('')
// 当私钥输入时,自动推导地址 // 当私钥输入时,自动推导地址
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const privateKey = e.target.value.trim() const privateKey = e.target.value.trim()
if (!privateKey) { if (!privateKey) {
setDerivedAddress('') setDerivedAddress('')
@@ -56,7 +56,7 @@ const AccountImport: React.FC = () => {
} }
// 当助记词输入时,自动推导地址 // 当助记词输入时,自动推导地址
const handleMnemonicChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleMnemonicChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const mnemonic = e.target.value.trim() const mnemonic = e.target.value.trim()
if (!mnemonic) { if (!mnemonic) {
setDerivedAddress('') setDerivedAddress('')
@@ -136,9 +136,6 @@ const AccountImport: React.FC = () => {
privateKey: privateKey, privateKey: privateKey,
walletAddress: walletAddress, walletAddress: walletAddress,
accountName: values.accountName, accountName: values.accountName,
apiKey: values.apiKey,
apiSecret: values.apiSecret,
apiPassphrase: values.apiPassphrase,
isDefault: values.isDefault || false isDefault: values.isDefault || false
}) })
@@ -165,7 +162,7 @@ const AccountImport: React.FC = () => {
<Card> <Card>
<Alert <Alert
message="安全提示" message="安全提示"
description="私钥将加密存储在后端,请确保网络连接安全。建议使用 HTTPS 连接。" description="私钥将存储在后端数据库中,请确保数据库访问安全。建议使用 HTTPS 连接。"
type="warning" type="warning"
showIcon showIcon
style={{ marginBottom: '24px' }} style={{ marginBottom: '24px' }}
@@ -305,29 +302,13 @@ const AccountImport: React.FC = () => {
<Input placeholder="可选,用于标识账户" /> <Input placeholder="可选,用于标识账户" />
</Form.Item> </Form.Item>
<Form.Item <Alert
label="API Key" message="API Key 自动获取"
name="apiKey" description="系统将自动从 Polymarket 获取或创建 API Key,无需手动输入。"
help="Polymarket API Key(可选,用于 L2 API 认证)" type="info"
> showIcon
<Input.Password placeholder="可选,Polymarket API Key" /> style={{ marginBottom: '24px' }}
</Form.Item> />
<Form.Item
label="API Secret"
name="apiSecret"
help="Polymarket API Secret(可选,用于 HMAC 签名)"
>
<Input.Password placeholder="可选,Polymarket API Secret" />
</Form.Item>
<Form.Item
label="API Passphrase"
name="apiPassphrase"
help="Polymarket API Passphrase(可选,用于加密/解密密钥)"
>
<Input.Password placeholder="可选,Polymarket API Passphrase" />
</Form.Item>
<Form.Item <Form.Item
name="isDefault" name="isDefault"
+250 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider } from 'antd' import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Checkbox, Alert } from 'antd'
import { PlusOutlined, StarOutlined, StarFilled, ReloadOutlined } from '@ant-design/icons' import { PlusOutlined, StarOutlined, StarFilled, ReloadOutlined, EditOutlined } from '@ant-design/icons'
import { useAccountStore } from '../store/accountStore' import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types' import type { Account } from '../types'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
@@ -11,13 +11,17 @@ const { Title } = Typography
const AccountList: React.FC = () => { const AccountList: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, loading, fetchAccounts, deleteAccount, setDefaultAccount, fetchAccountBalance, fetchAccountDetail } = useAccountStore() const { accounts, loading, fetchAccounts, deleteAccount, setDefaultAccount, fetchAccountBalance, fetchAccountDetail, updateAccount } = useAccountStore()
const [balanceMap, setBalanceMap] = useState<Record<number, { total: string; available: string; position: string }>>({}) const [balanceMap, setBalanceMap] = useState<Record<number, { total: string; available: string; position: string }>>({})
const [balanceLoading, setBalanceLoading] = useState<Record<number, boolean>>({}) const [balanceLoading, setBalanceLoading] = useState<Record<number, boolean>>({})
const [detailModalVisible, setDetailModalVisible] = useState(false) const [detailModalVisible, setDetailModalVisible] = useState(false)
const [detailAccount, setDetailAccount] = useState<Account | null>(null) const [detailAccount, setDetailAccount] = useState<Account | null>(null)
const [detailBalance, setDetailBalance] = useState<{ total: string; available: string; position: string; positions: any[] } | null>(null) const [detailBalance, setDetailBalance] = useState<{ total: string; available: string; position: string; positions: any[] } | null>(null)
const [detailBalanceLoading, setDetailBalanceLoading] = useState(false) const [detailBalanceLoading, setDetailBalanceLoading] = useState(false)
const [editModalVisible, setEditModalVisible] = useState(false)
const [editAccount, setEditAccount] = useState<Account | null>(null)
const [editForm] = Form.useForm()
const [editLoading, setEditLoading] = useState(false)
useEffect(() => { useEffect(() => {
fetchAccounts() fetchAccounts()
@@ -137,6 +141,75 @@ const AccountList: React.FC = () => {
} }
} }
const handleShowEdit = async (account: Account) => {
try {
setEditModalVisible(true)
setEditAccount(account)
// 加载账户详情并设置表单初始值
const accountDetail = await fetchAccountDetail(account.id)
setEditAccount(accountDetail)
editForm.setFieldsValue({
accountName: accountDetail.accountName || '',
apiKey: '', // 不显示实际值,留空表示不修改
apiSecret: '', // 不显示实际值,留空表示不修改
apiPassphrase: '', // 不显示实际值,留空表示不修改
isDefault: accountDetail.isDefault || false
})
} catch (error: any) {
console.error('打开编辑失败:', error)
message.error(error.message || '获取账户详情失败')
setEditModalVisible(false)
setEditAccount(null)
}
}
const handleEditSubmit = async (values: any) => {
if (!editAccount) return
setEditLoading(true)
try {
// 构建更新请求,空字符串转换为 undefined(不修改)
const updateData: any = {
accountId: editAccount.id,
accountName: values.accountName || undefined,
isDefault: values.isDefault || false
}
// 只有非空字符串才更新 API 凭证
if (values.apiKey && values.apiKey.trim()) {
updateData.apiKey = values.apiKey.trim()
}
if (values.apiSecret && values.apiSecret.trim()) {
updateData.apiSecret = values.apiSecret.trim()
}
if (values.apiPassphrase && values.apiPassphrase.trim()) {
updateData.apiPassphrase = values.apiPassphrase.trim()
}
await updateAccount(updateData)
message.success('更新账户成功')
setEditModalVisible(false)
setEditAccount(null)
editForm.resetFields()
// 刷新账户列表
await fetchAccounts()
// 如果详情 Modal 打开着,也刷新详情
if (detailModalVisible && detailAccount && detailAccount.id === editAccount.id) {
const accountDetail = await fetchAccountDetail(editAccount.id)
setDetailAccount(accountDetail)
}
} catch (error: any) {
message.error(error.message || '更新账户失败')
} finally {
setEditLoading(false)
}
}
const columns = [ const columns = [
{ {
title: '账户名称', title: '账户名称',
@@ -191,6 +264,17 @@ const AccountList: React.FC = () => {
return balance && balance !== '-' && typeof balance === 'string' ? `${balance} USDC` : '-' return balance && balance !== '-' && typeof balance === 'string' ? `${balance} USDC` : '-'
} }
}, },
{
title: '活跃订单',
dataIndex: 'activeOrders',
key: 'activeOrders',
render: (_: any, record: Account) => {
if (record.activeOrders !== undefined && record.activeOrders !== null) {
return <Tag color={record.activeOrders > 0 ? 'orange' : 'default'}>{record.activeOrders}</Tag>
}
return <span style={{ color: '#999' }}>-</span>
}
},
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
@@ -203,6 +287,14 @@ const AccountList: React.FC = () => {
> >
</Button> </Button>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleShowEdit(record)}
>
</Button>
<Popconfirm <Popconfirm
title="确定要删除这个账户吗?" title="确定要删除这个账户吗?"
description={ description={
@@ -281,6 +373,18 @@ const AccountList: React.FC = () => {
: {balanceMap[record.id].available} USDC | : {balanceMap[record.id].position} USDC : {balanceMap[record.id].available} USDC | : {balanceMap[record.id].position} USDC
</div> </div>
)} )}
{(record.activeOrders !== undefined && record.activeOrders !== null) && (
<div style={{
fontSize: '12px',
color: '#666',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
: <Tag color={record.activeOrders > 0 ? 'orange' : 'default'} style={{ margin: 0 }}>{record.activeOrders}</Tag>
</div>
)}
</div> </div>
) )
} }
@@ -300,6 +404,15 @@ const AccountList: React.FC = () => {
> >
</Button> </Button>
<Button
size="small"
block
icon={<EditOutlined />}
onClick={() => handleShowEdit(record)}
style={{ minHeight: '32px' }}
>
</Button>
{!record.isDefault && ( {!record.isDefault && (
<Button <Button
size="small" size="small"
@@ -419,6 +532,20 @@ const AccountList: React.FC = () => {
> >
</Button>, </Button>,
<Button
key="edit"
type="primary"
icon={<EditOutlined />}
onClick={() => {
if (detailAccount) {
setDetailModalVisible(false)
handleShowEdit(detailAccount)
}
}}
disabled={!detailAccount}
>
</Button>,
<Button <Button
key="close" key="close"
onClick={() => { onClick={() => {
@@ -532,7 +659,9 @@ const AccountList: React.FC = () => {
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined) && ( {(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined ||
detailAccount.activeOrders !== undefined ||
detailAccount.completedOrders !== undefined || detailAccount.positionCount !== undefined) && (
<> <>
<Divider /> <Divider />
<Descriptions <Descriptions
@@ -546,6 +675,21 @@ const AccountList: React.FC = () => {
{detailAccount.totalOrders} {detailAccount.totalOrders}
</Descriptions.Item> </Descriptions.Item>
)} )}
{detailAccount.activeOrders !== undefined && (
<Descriptions.Item label="活跃订单数">
<Tag color={detailAccount.activeOrders > 0 ? 'orange' : 'default'}>{detailAccount.activeOrders}</Tag>
</Descriptions.Item>
)}
{detailAccount.completedOrders !== undefined && (
<Descriptions.Item label="已完成订单数">
<Tag color="success">{detailAccount.completedOrders}</Tag>
</Descriptions.Item>
)}
{detailAccount.positionCount !== undefined && (
<Descriptions.Item label="持仓数量">
<Tag color={detailAccount.positionCount > 0 ? 'blue' : 'default'}>{detailAccount.positionCount}</Tag>
</Descriptions.Item>
)}
{detailAccount.totalPnl !== undefined && ( {detailAccount.totalPnl !== undefined && (
<Descriptions.Item label="总盈亏"> <Descriptions.Item label="总盈亏">
<span style={{ <span style={{
@@ -567,6 +711,108 @@ const AccountList: React.FC = () => {
</div> </div>
)} )}
</Modal> </Modal>
{/* 编辑账户 Modal */}
<Modal
title={editAccount ? `编辑账户 - ${editAccount.accountName || `账户 ${editAccount.id}`}` : '编辑账户'}
open={editModalVisible}
onCancel={() => {
setEditModalVisible(false)
setEditAccount(null)
editForm.resetFields()
}}
footer={null}
width={isMobile ? '95%' : 600}
style={{ top: isMobile ? 20 : 50 }}
destroyOnClose
maskClosable
closable
>
{editAccount ? (
<Form
form={editForm}
layout="vertical"
onFinish={handleEditSubmit}
size={isMobile ? 'middle' : 'large'}
>
<Alert
message="编辑提示"
description="API 凭证字段留空表示不修改。如需更新 API 凭证,请输入新值;如需保持原值不变,请留空。"
type="info"
showIcon
style={{ marginBottom: '24px' }}
/>
<Form.Item
label="账户名称"
name="accountName"
>
<Input placeholder="账户名称(可选)" />
</Form.Item>
<Form.Item
label="API Key"
name="apiKey"
help="留空表示不修改,输入新值将更新 API Key"
>
<Input.Password placeholder="留空表示不修改" />
</Form.Item>
<Form.Item
label="API Secret"
name="apiSecret"
help="留空表示不修改,输入新值将更新 API Secret"
>
<Input.Password placeholder="留空表示不修改" />
</Form.Item>
<Form.Item
label="API Passphrase"
name="apiPassphrase"
help="留空表示不修改,输入新值将更新 API Passphrase"
>
<Input.Password placeholder="留空表示不修改" />
</Form.Item>
<Form.Item
name="isDefault"
valuePropName="checked"
>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button
onClick={() => {
setEditModalVisible(false)
setEditAccount(null)
editForm.resetFields()
}}
size={isMobile ? 'middle' : 'large'}
style={isMobile ? { minHeight: '44px' } : undefined}
>
</Button>
<Button
type="primary"
htmlType="submit"
loading={editLoading}
size={isMobile ? 'middle' : 'large'}
style={isMobile ? { minHeight: '44px' } : undefined}
>
</Button>
</Space>
</Form.Item>
</Form>
) : (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin size="large" />
<div style={{ marginTop: '16px' }}>...</div>
</div>
)}
</Modal>
</div> </div>
) )
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Card, Form, Input, Button, Switch, Radio, InputNumber, message, Typography, Space } from 'antd' import { Card, Form, Button, Switch, Radio, InputNumber, message, Typography } from 'antd'
import { SaveOutlined } from '@ant-design/icons' import { SaveOutlined } from '@ant-design/icons'
import { apiService } from '../services/api' import { apiService } from '../services/api'
import type { CopyTradingConfig } from '../types' import type { CopyTradingConfig } from '../types'
@@ -11,7 +11,7 @@ const ConfigPage: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const [form] = Form.useForm() const [form] = Form.useForm()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [config, setConfig] = useState<CopyTradingConfig | null>(null) const [, setConfig] = useState<CopyTradingConfig | null>(null)
useEffect(() => { useEffect(() => {
fetchConfig() fetchConfig()
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Card, Form, Input, Button, Select, Switch, message, Typography } from 'antd' import { Card, Form, Input, Button, Select, Switch, message, Typography, Space } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons' import { ArrowLeftOutlined } from '@ant-design/icons'
import { apiService } from '../services/api' import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore' import { useAccountStore } from '../store/accountStore'
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Card, Table, Tag, Space, message } from 'antd' import { Card, Table, Tag, message } from 'antd'
import { apiService } from '../services/api' import { apiService } from '../services/api'
import type { CopyOrder } from '../types' import type { CopyOrder } from '../types'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
-2
View File
@@ -3,10 +3,8 @@ import { Card, Row, Col, Statistic, message } from 'antd'
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons' import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
import { apiService } from '../services/api' import { apiService } from '../services/api'
import type { Statistics as StatisticsType } from '../types' import type { Statistics as StatisticsType } from '../types'
import { useMediaQuery } from 'react-responsive'
const Statistics: React.FC = () => { const Statistics: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const [stats, setStats] = useState<StatisticsType | null>(null) const [stats, setStats] = useState<StatisticsType | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
+3 -6
View File
@@ -21,6 +21,9 @@ export interface Account {
balance?: string balance?: string
totalOrders?: number totalOrders?: number
totalPnl?: string totalPnl?: string
activeOrders?: number
completedOrders?: number
positionCount?: number
} }
/** /**
@@ -38,9 +41,6 @@ export interface AccountImportRequest {
privateKey: string privateKey: string
walletAddress: string walletAddress: string
accountName?: string accountName?: string
apiKey?: string
apiSecret?: string
apiPassphrase?: string
isDefault?: boolean isDefault?: boolean
} }
@@ -50,9 +50,6 @@ export interface AccountImportRequest {
export interface AccountUpdateRequest { export interface AccountUpdateRequest {
accountId: number accountId: number
accountName?: string accountName?: string
apiKey?: string
apiSecret?: string
apiPassphrase?: string
isDefault?: boolean isDefault?: boolean
} }
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.env
.DS_Store
+65
View File
@@ -0,0 +1,65 @@
# Polymarket JS SDK Demo
这是一个用于测试 Polymarket JS SDK 的演示目录。
## 安装依赖
```bash
npm install
```
或者使用 yarn:
```bash
yarn install
```
## 配置
在使用脚本之前,需要修改 `src/createOrder.ts` 文件中的以下配置:
1. **funder**: 你的 Polymarket 账户地址(在个人资料图片下方显示的地址)
2. **signer**: 你的私钥
- 如果使用邮箱登录,从 https://reveal.magic.link/polymarket 导出
- 如果使用 Web3 应用,从你的钱包导出
3. **tokenID**: 要交易的代币 ID
- 使用 https://docs.polymarket.com/developers/gamma-markets-api/get-markets 获取示例代币
- 示例代币: `114304586861386186441621124384163963092522056897081085884483958561365015034812` (Xi Jinping out in 2025, YES side)
4. **tickSize****negRisk**: 根据市场调整这些参数,从 get-markets API 获取
## 运行测试脚本
```bash
npm run test
```
或者直接使用 ts-node:
```bash
npx ts-node src/createOrder.ts
```
## 编译
```bash
npm run build
```
编译后的文件将输出到 `dist` 目录。
## 运行编译后的文件
```bash
npm start
```
## 注意事项
- **不要创建新的 API key**,始终使用 `createOrDerive` 方法
- **signatureType** 说明:
- `1`: Magic/Email 登录
- `2`: 浏览器钱包 (Metamask, Coinbase Wallet 等)
- `0`: EOA (如果你不知道这是什么,说明你不在使用它)
- 确保你的账户有足够的 USDC 余额
- 在生产环境中使用前,请确保妥善保管私钥
+1509
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "polymarket-demo",
"version": "1.0.0",
"description": "Polymarket JS SDK 测试脚本",
"main": "index.js",
"scripts": {
"test": "ts-node src/createOrder.ts",
"build": "tsc",
"start": "node dist/createOrder.js"
},
"keywords": [
"polymarket",
"demo",
"test"
],
"author": "",
"license": "MIT",
"dependencies": {
"@polymarket/clob-client": "^4.22.8",
"ethers": "^5.7.1"
},
"devDependencies": {
"@types/node": "^18.7.18",
"ts-node": "^10.9.1",
"typescript": "^4.8.3"
}
}
+40
View File
@@ -0,0 +1,40 @@
//npm install @polymarket/clob-client
//npm install ethers
//Client initialization example and dumping API Keys
import { ApiKeyCreds, ClobClient, OrderType, Side, } from "@polymarket/clob-client";
import { Wallet } from "@ethersproject/wallet";
const host = 'https://clob.polymarket.com';
const funder = ''; //This is the address listed below your profile picture when using the Polymarket site.
const signer = new Wallet("[PRIVATE_KEY_REMOVED]"); //This is your Private Key. If using email login export from https://reveal.magic.link/polymarket otherwise export from your Web3 Application
//In general don't create a new API key, always derive or createOrDerive
const creds = new ClobClient(host, 137, signer).createOrDeriveApiKey();
//1: Magic/Email Login
//2: Browser Wallet(Metamask, Coinbase Wallet, etc)
//0: EOA (If you don't know what this is you're not using it)
const signatureType = 1;
(async () => {
const clobClient = new ClobClient(host, 137, signer, await creds, signatureType, funder);
const resp2 = await clobClient.createAndPostOrder(
{
tokenID: "114304586861386186441621124384163963092522056897081085884483958561365015034812", //Use https://docs.polymarket.com/developers/gamma-markets-api/get-markets to grab a sample token
price: 0.01,
side: Side.BUY,
size: 5,
feeRateBps: 0,
},
{ tickSize: "0.01",negRisk: false }, //You'll need to adjust these based on the market. Get the tickSize and negRisk T/F from the get-markets above
//Refer to the API documentation to locate a tokenID: https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide
//Example token: 114304586861386186441621124384163963092522056897081085884483958561365015034812 ( Xi Jinping out in 2025, YES side )
//{ tickSize: "0.001",negRisk: true },
OrderType.GTC,
);
console.log(resp2)
})();
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}