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*
lerna-debug.log*
.pnpm-store/
polymarket-trading-bot/
# Frontend build
frontend/dist/
frontend/.vite/
+3
View File
@@ -54,6 +54,9 @@ dependencies {
// Keccak-256 for Ethereum function selector
implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
// Web3j for Ethereum wallet and EIP-712 signing
implementation("org.web3j:core:5.0.0")
// Logging
implementation("org.slf4j:slf4j-api")
@@ -110,6 +110,29 @@ interface PolymarketClobApi {
@Query("after") after: String? = null,
@Query("next_cursor") next_cursor: String? = null
): 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
)
/**
* 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(默认 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
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. 使用 Secretbase64 解码后)对 (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}
+2
View File
@@ -5,6 +5,7 @@ import Layout from './components/Layout'
import AccountList from './pages/AccountList'
import AccountImport from './pages/AccountImport'
import AccountDetail from './pages/AccountDetail'
import AccountEdit from './pages/AccountEdit'
import LeaderList from './pages/LeaderList'
import LeaderAdd from './pages/LeaderAdd'
import ConfigPage from './pages/ConfigPage'
@@ -21,6 +22,7 @@ function App() {
<Route path="/accounts" element={<AccountList />} />
<Route path="/accounts/import" element={<AccountImport />} />
<Route path="/accounts/detail" element={<AccountDetail />} />
<Route path="/accounts/edit" element={<AccountEdit />} />
<Route path="/leaders" element={<LeaderList />} />
<Route path="/leaders/add" element={<LeaderAdd />} />
<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 { Layout as AntLayout, Menu, Drawer, Button } from 'antd'
import { useMediaQuery } from 'react-responsive'
+174 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
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 { useAccountStore } from '../store/accountStore'
import type { Account } from '../types'
@@ -14,11 +14,14 @@ const AccountDetail: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const accountId = searchParams.get('id')
const { fetchAccountDetail, fetchAccountBalance } = useAccountStore()
const { fetchAccountDetail, fetchAccountBalance, updateAccount } = useAccountStore()
const [account, setAccount] = useState<Account | null>(null)
const [balance, setBalance] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [balanceLoading, setBalanceLoading] = useState(false)
const [editModalVisible, setEditModalVisible] = useState(false)
const [editForm] = Form.useForm()
const [editLoading, setEditLoading] = useState(false)
useEffect(() => {
if (accountId) {
@@ -51,7 +54,7 @@ const AccountDetail: React.FC = () => {
setBalanceLoading(true)
try {
const balanceData = await fetchAccountBalance(Number(accountId))
setBalance(balanceData.balance || null)
setBalance(balanceData.totalBalance || null)
} catch (error: any) {
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) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
@@ -113,7 +156,16 @@ const AccountDetail: React.FC = () => {
<Button
type="primary"
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'}
block={isMobile}
style={isMobile ? { minHeight: '44px' } : undefined}
@@ -210,7 +262,9 @@ const AccountDetail: React.FC = () => {
</Descriptions>
</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' }} />
<Card
@@ -232,6 +286,21 @@ const AccountDetail: React.FC = () => {
{account.totalOrders}
</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 && (
<Descriptions.Item label="总盈亏">
<span style={{
@@ -246,6 +315,106 @@ const AccountDetail: React.FC = () => {
</Card>
</>
) : 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>
)
}
+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'
import { useMediaQuery } from 'react-responsive'
const { Title, Text } = Typography
const { Title } = Typography
type ImportType = 'privateKey' | 'mnemonic'
@@ -27,7 +27,7 @@ const AccountImport: React.FC = () => {
const [addressError, setAddressError] = useState<string>('')
// 当私钥输入时,自动推导地址
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const privateKey = e.target.value.trim()
if (!privateKey) {
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()
if (!mnemonic) {
setDerivedAddress('')
@@ -136,9 +136,6 @@ const AccountImport: React.FC = () => {
privateKey: privateKey,
walletAddress: walletAddress,
accountName: values.accountName,
apiKey: values.apiKey,
apiSecret: values.apiSecret,
apiPassphrase: values.apiPassphrase,
isDefault: values.isDefault || false
})
@@ -165,7 +162,7 @@ const AccountImport: React.FC = () => {
<Card>
<Alert
message="安全提示"
description="私钥将加密存储在后端,请确保网络连接安全。建议使用 HTTPS 连接。"
description="私钥将存储在后端数据库中,请确保数据库访问安全。建议使用 HTTPS 连接。"
type="warning"
showIcon
style={{ marginBottom: '24px' }}
@@ -305,29 +302,13 @@ const AccountImport: React.FC = () => {
<Input placeholder="可选,用于标识账户" />
</Form.Item>
<Form.Item
label="API Key"
name="apiKey"
help="Polymarket API Key(可选,用于 L2 API 认证)"
>
<Input.Password placeholder="可选,Polymarket API Key" />
</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>
<Alert
message="API Key 自动获取"
description="系统将自动从 Polymarket 获取或创建 API Key,无需手动输入。"
type="info"
showIcon
style={{ marginBottom: '24px' }}
/>
<Form.Item
name="isDefault"
+250 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider } from 'antd'
import { PlusOutlined, StarOutlined, StarFilled, ReloadOutlined } from '@ant-design/icons'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Checkbox, Alert } from 'antd'
import { PlusOutlined, StarOutlined, StarFilled, ReloadOutlined, EditOutlined } from '@ant-design/icons'
import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types'
import { useMediaQuery } from 'react-responsive'
@@ -11,13 +11,17 @@ const { Title } = Typography
const AccountList: React.FC = () => {
const navigate = useNavigate()
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 [balanceLoading, setBalanceLoading] = useState<Record<number, boolean>>({})
const [detailModalVisible, setDetailModalVisible] = useState(false)
const [detailAccount, setDetailAccount] = useState<Account | null>(null)
const [detailBalance, setDetailBalance] = useState<{ total: string; available: string; position: string; positions: any[] } | null>(null)
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(() => {
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 = [
{
title: '账户名称',
@@ -191,6 +264,17 @@ const AccountList: React.FC = () => {
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: '操作',
key: 'action',
@@ -203,6 +287,14 @@ const AccountList: React.FC = () => {
>
</Button>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleShowEdit(record)}
>
</Button>
<Popconfirm
title="确定要删除这个账户吗?"
description={
@@ -281,6 +373,18 @@ const AccountList: React.FC = () => {
: {balanceMap[record.id].available} USDC | : {balanceMap[record.id].position} USDC
</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>
)
}
@@ -300,6 +404,15 @@ const AccountList: React.FC = () => {
>
</Button>
<Button
size="small"
block
icon={<EditOutlined />}
onClick={() => handleShowEdit(record)}
style={{ minHeight: '32px' }}
>
</Button>
{!record.isDefault && (
<Button
size="small"
@@ -419,6 +532,20 @@ const AccountList: React.FC = () => {
>
</Button>,
<Button
key="edit"
type="primary"
icon={<EditOutlined />}
onClick={() => {
if (detailAccount) {
setDetailModalVisible(false)
handleShowEdit(detailAccount)
}
}}
disabled={!detailAccount}
>
</Button>,
<Button
key="close"
onClick={() => {
@@ -532,7 +659,9 @@ const AccountList: React.FC = () => {
</Descriptions.Item>
</Descriptions>
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined) && (
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined ||
detailAccount.activeOrders !== undefined ||
detailAccount.completedOrders !== undefined || detailAccount.positionCount !== undefined) && (
<>
<Divider />
<Descriptions
@@ -546,6 +675,21 @@ const AccountList: React.FC = () => {
{detailAccount.totalOrders}
</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 && (
<Descriptions.Item label="总盈亏">
<span style={{
@@ -567,6 +711,108 @@ const AccountList: React.FC = () => {
</div>
)}
</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>
)
}
+2 -2
View File
@@ -1,5 +1,5 @@
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 { apiService } from '../services/api'
import type { CopyTradingConfig } from '../types'
@@ -11,7 +11,7 @@ const ConfigPage: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [config, setConfig] = useState<CopyTradingConfig | null>(null)
const [, setConfig] = useState<CopyTradingConfig | null>(null)
useEffect(() => {
fetchConfig()
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
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 { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
+1 -1
View File
@@ -1,5 +1,5 @@
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 type { CopyOrder } from '../types'
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 { apiService } from '../services/api'
import type { Statistics as StatisticsType } from '../types'
import { useMediaQuery } from 'react-responsive'
const Statistics: React.FC = () => {
const isMobile = useMediaQuery({ maxWidth: 768 })
const [stats, setStats] = useState<StatisticsType | null>(null)
const [loading, setLoading] = useState(false)
+3 -6
View File
@@ -21,6 +21,9 @@ export interface Account {
balance?: string
totalOrders?: number
totalPnl?: string
activeOrders?: number
completedOrders?: number
positionCount?: number
}
/**
@@ -38,9 +41,6 @@ export interface AccountImportRequest {
privateKey: string
walletAddress: string
accountName?: string
apiKey?: string
apiSecret?: string
apiPassphrase?: string
isDefault?: boolean
}
@@ -50,9 +50,6 @@ export interface AccountImportRequest {
export interface AccountUpdateRequest {
accountId: number
accountName?: string
apiKey?: string
apiSecret?: string
apiPassphrase?: string
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"]
}