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