Initial commit: Polymarket copy trading bot

- Backend: Spring Boot + Kotlin implementation
  - Account management with private key import
  - Leader management
  - Copy trading configuration
  - Order synchronization
  - Balance and position queries via Polymarket API
  - Ethereum RPC integration for USDC balance
  - Proxy address calculation

- Frontend: React + TypeScript
  - Account management UI
  - Mobile responsive design
  - Account import with private key/mnemonic support
  - Balance display and account details modal

- Database: MySQL with Flyway migrations
- API Integration: Polymarket CLOB API, Data API, Ethereum RPC
This commit is contained in:
WrBug
2025-11-21 04:32:08 +08:00
commit 4f7fef145f
70 changed files with 14618 additions and 0 deletions
@@ -0,0 +1,12 @@
package com.wrbug.polymarketbot
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class PolymarketBotApplication
fun main(args: Array<String>) {
runApplication<PolymarketBotApplication>(*args)
}
@@ -0,0 +1,48 @@
package com.wrbug.polymarketbot.api
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Ethereum RPC API 接口定义
* 用于调用 Ethereum JSON-RPC 接口
*/
interface EthereumRpcApi {
/**
* 调用 Ethereum JSON-RPC 方法
*/
@POST("/")
suspend fun call(@Body request: JsonRpcRequest): Response<JsonRpcResponse>
}
/**
* JSON-RPC 请求
*/
data class JsonRpcRequest(
val jsonrpc: String = "2.0",
val method: String,
val params: List<Any>,
val id: Int = 1
)
/**
* JSON-RPC 响应
*/
data class JsonRpcResponse(
val jsonrpc: String? = null,
val result: String? = null,
val error: JsonRpcError? = null,
val id: Int? = null
)
/**
* JSON-RPC 错误
*/
data class JsonRpcError(
val code: Int,
val message: String,
val data: Any? = null
)
@@ -0,0 +1,207 @@
package com.wrbug.polymarketbot.api
import retrofit2.Response
import retrofit2.http.*
/**
* Polymarket CLOB API 接口定义
* 用于程序化地管理市场订单
*/
interface PolymarketClobApi {
/**
* 获取订单簿
*/
@GET("/book")
suspend fun getOrderbook(
@Query("market") market: String
): Response<OrderbookResponse>
/**
* 获取价格信息
*/
@GET("/price")
suspend fun getPrice(
@Query("market") market: String
): Response<PriceResponse>
/**
* 获取中间价
*/
@GET("/midpoint")
suspend fun getMidpoint(
@Query("market") market: String
): Response<MidpointResponse>
/**
* 获取价差
*/
@GET("/spreads")
suspend fun getSpreads(
@Query("market") market: String
): Response<SpreadsResponse>
/**
* 创建单个订单
*/
@POST("/orders")
suspend fun createOrder(
@Body request: CreateOrderRequest
): Response<OrderResponse>
/**
* 批量创建订单
*/
@POST("/orders/batch")
suspend fun createOrdersBatch(
@Body request: CreateOrdersBatchRequest
): Response<List<OrderResponse>>
/**
* 获取订单信息
*/
@GET("/orders/{orderId}")
suspend fun getOrder(
@Path("orderId") orderId: String
): Response<OrderResponse>
/**
* 获取活跃订单
* 端点: /data/orders
* 注意:Polymarket CLOB API 使用 GET 方法,参数通过 query params 传递
* 虽然项目规范要求使用 POST,但这是外部 API,必须遵循 API 的实际要求
*/
@GET("/data/orders")
suspend fun getActiveOrders(
@Query("id") id: String? = null,
@Query("market") market: String? = null,
@Query("asset_id") asset_id: String? = null,
@Query("next_cursor") next_cursor: String? = null
): Response<GetActiveOrdersResponse>
/**
* 取消订单
*/
@DELETE("/orders/{orderId}")
suspend fun cancelOrder(
@Path("orderId") orderId: String
): Response<CancelOrderResponse>
/**
* 批量取消订单
*/
@DELETE("/orders/batch")
suspend fun cancelOrdersBatch(
@Body request: CancelOrdersBatchRequest
): Response<CancelOrdersBatchResponse>
/**
* 获取交易记录
* 端点: /data/trades
* 注意:Polymarket CLOB API 使用 GET 方法,参数通过 query params 传递
*/
@GET("/data/trades")
suspend fun getTrades(
@Query("id") id: String? = null,
@Query("maker_address") maker_address: String? = null,
@Query("market") market: String? = null,
@Query("asset_id") asset_id: String? = null,
@Query("before") before: String? = null,
@Query("after") after: String? = null,
@Query("next_cursor") next_cursor: String? = null
): Response<GetTradesResponse>
}
// 请求和响应数据类
data class CreateOrderRequest(
val market: String,
val side: String, // "BUY" or "SELL"
val price: String,
val size: String,
val type: String = "LIMIT",
val expiration: Long? = null
)
data class CreateOrdersBatchRequest(
val orders: List<CreateOrderRequest>
)
data class CancelOrdersBatchRequest(
val orderIds: List<String>
)
data class OrderbookResponse(
val bids: List<OrderbookEntry>,
val asks: List<OrderbookEntry>
)
data class OrderbookEntry(
val price: String,
val size: String
)
data class PriceResponse(
val market: String,
val lastPrice: String?,
val bestBid: String?,
val bestAsk: String?
)
data class MidpointResponse(
val market: String,
val midpoint: String
)
data class SpreadsResponse(
val market: String,
val spread: String
)
data class OrderResponse(
val id: String,
val market: String,
val side: String,
val price: String,
val size: String,
val filled: String,
val status: String,
val createdAt: String // ISO 8601 格式字符串
)
data class CancelOrderResponse(
val orderId: String,
val status: String
)
data class CancelOrdersBatchResponse(
val cancelled: List<String>,
val failed: List<String>
)
data class TradeResponse(
val id: String,
val market: String,
val side: String,
val price: String,
val size: String,
val timestamp: String, // ISO 8601 格式字符串
val user: String?
)
/**
* 获取活跃订单响应
* 注意:参数通过 @Query 传递,不需要单独的 Request 类
*/
data class GetActiveOrdersResponse(
val data: List<OrderResponse>,
val next_cursor: String? = null
)
/**
* 获取交易记录响应
*/
data class GetTradesResponse(
val data: List<TradeResponse>,
val next_cursor: String? = null
)
@@ -0,0 +1,65 @@
package com.wrbug.polymarketbot.api
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Polymarket Data API 接口定义
* 用于查询仓位信息
* Base URL: https://data-api.polymarket.com
*/
interface PolymarketDataApi {
/**
* 获取用户当前仓位
* 文档: https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user
*/
@GET("/positions")
suspend fun getPositions(
@Query("user") user: String,
@Query("market") market: String? = null,
@Query("eventId") eventId: String? = null,
@Query("sizeThreshold") sizeThreshold: Double? = null,
@Query("redeemable") redeemable: Boolean? = null,
@Query("mergeable") mergeable: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
@Query("sortBy") sortBy: String? = null,
@Query("sortDirection") sortDirection: String? = null,
@Query("title") title: String? = null
): Response<List<PositionResponse>>
}
/**
* 仓位响应(根据 Polymarket Data API 文档)
*/
data class PositionResponse(
val proxyWallet: String,
val asset: String? = null,
val conditionId: String? = null,
val size: Double? = null,
val avgPrice: Double? = null,
val initialValue: Double? = null,
val currentValue: Double? = null,
val cashPnl: Double? = null,
val percentPnl: Double? = null,
val totalBought: Double? = null,
val realizedPnl: Double? = null,
val percentRealizedPnl: Double? = null,
val curPrice: Double? = null,
val redeemable: Boolean? = null,
val mergeable: Boolean? = null,
val title: String? = null,
val slug: String? = null,
val icon: String? = null,
val eventSlug: String? = null,
val outcome: String? = null,
val outcomeIndex: Int? = null,
val oppositeOutcome: String? = null,
val oppositeAsset: String? = null,
val endDate: String? = null,
val negativeRisk: Boolean? = null
)
@@ -0,0 +1,45 @@
package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.util.createClient
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
/**
* Retrofit 配置类
* 用于创建 Polymarket CLOB API 客户端(跟单系统需要)
*
* 注意:
* - 查询类接口(如 /book, /price, /trades)不需要认证
* - 操作类接口(如 /orders)需要认证,应使用账户级别的 API Key
* - 账户 API Key 在调用时动态设置,不在此处配置
*/
@Configuration
class RetrofitConfig {
@Value("\${polymarket.clob.base-url}")
private lateinit var clobBaseUrl: String
/**
* 创建 CLOB API 客户端
* 用于跟单系统的订单操作和交易查询
*
* 注意:此客户端不包含全局认证拦截器
* 需要认证的请求应在调用时使用账户级别的 API Key 动态设置认证头
*/
@Bean
fun polymarketClobApi(): PolymarketClobApi {
val okHttpClient = createClient().build()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(PolymarketClobApi::class.java)
}
}
@@ -0,0 +1,24 @@
package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketHandler
import org.springframework.context.annotation.Configuration
import org.springframework.web.socket.config.annotation.EnableWebSocket
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
/**
* WebSocket 配置类
* 用于配置 WebSocket 端点
*/
@Configuration
@EnableWebSocket
class WebSocketConfig(
private val polymarketWebSocketHandler: PolymarketWebSocketHandler
) : WebSocketConfigurer {
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
}
}
@@ -0,0 +1,208 @@
package com.wrbug.polymarketbot.controller
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.service.AccountService
import org.slf4j.LoggerFactory
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* 账户管理控制器
*/
@RestController
@RequestMapping("/api/copy-trading/accounts")
class AccountController(
private val accountService: AccountService
) {
private val logger = LoggerFactory.getLogger(AccountController::class.java)
/**
* 通过私钥导入账户
*/
@PostMapping("/import")
fun importAccount(@RequestBody request: AccountImportRequest): ResponseEntity<ApiResponse<AccountDto>> {
return try {
// 参数验证
if (request.privateKey.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("私钥不能为空"))
}
if (request.walletAddress.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("钱包地址不能为空"))
}
val result = accountService.importAccount(request)
result.fold(
onSuccess = { account ->
logger.info("成功导入账户: ${account.id}")
ResponseEntity.ok(ApiResponse.success(account))
},
onFailure = { e ->
logger.error("导入账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("导入账户失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("导入账户异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("导入账户失败: ${e.message}"))
}
}
/**
* 更新账户信息
*/
@PostMapping("/update")
fun updateAccount(@RequestBody request: AccountUpdateRequest): ResponseEntity<ApiResponse<AccountDto>> {
return try {
val result = accountService.updateAccount(request)
result.fold(
onSuccess = { account ->
logger.info("成功更新账户: ${account.id}")
ResponseEntity.ok(ApiResponse.success(account))
},
onFailure = { e ->
logger.error("更新账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("更新账户失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("更新账户异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("更新账户失败: ${e.message}"))
}
}
/**
* 删除账户
*/
@PostMapping("/delete")
fun deleteAccount(@RequestBody request: AccountDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
val result = accountService.deleteAccount(request.accountId)
result.fold(
onSuccess = {
logger.info("成功删除账户: ${request.accountId}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("删除账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
is IllegalStateException -> ResponseEntity.ok(ApiResponse.businessError(e.message ?: "业务逻辑错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("删除账户失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("删除账户异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("删除账户失败: ${e.message}"))
}
}
/**
* 查询账户列表
*/
@PostMapping("/list")
fun getAccountList(): ResponseEntity<ApiResponse<AccountListResponse>> {
return try {
val result = accountService.getAccountList()
result.fold(
onSuccess = { response ->
logger.info("成功查询账户列表: ${response.total} 个账户")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询账户列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("查询账户列表失败: ${e.message}"))
}
)
} catch (e: Exception) {
logger.error("查询账户列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("查询账户列表失败: ${e.message}"))
}
}
/**
* 查询账户详情
*/
@PostMapping("/detail")
fun getAccountDetail(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountDto>> {
return try {
val result = accountService.getAccountDetail(request.accountId)
result.fold(
onSuccess = { account ->
logger.info("成功查询账户详情: ${account.id}")
ResponseEntity.ok(ApiResponse.success(account))
},
onFailure = { e ->
logger.error("查询账户详情失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("查询账户详情失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("查询账户详情异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("查询账户详情失败: ${e.message}"))
}
}
/**
* 查询账户余额
*/
@PostMapping("/balance")
fun getAccountBalance(@RequestBody request: AccountBalanceRequest): ResponseEntity<ApiResponse<AccountBalanceResponse>> {
return try {
val result = accountService.getAccountBalance(request.accountId)
result.fold(
onSuccess = { balance ->
logger.info("成功查询账户余额")
ResponseEntity.ok(ApiResponse.success(balance))
},
onFailure = { e ->
logger.error("查询账户余额失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("查询账户余额失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("查询账户余额异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("查询账户余额失败: ${e.message}"))
}
}
/**
* 设置默认账户
*/
@PostMapping("/set-default")
fun setDefaultAccount(@RequestBody request: SetDefaultAccountRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
val result = accountService.setDefaultAccount(request.accountId)
result.fold(
onSuccess = {
logger.info("成功设置默认账户: ${request.accountId}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("设置默认账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("设置默认账户失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("设置默认账户异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("设置默认账户失败: ${e.message}"))
}
}
}
@@ -0,0 +1,101 @@
package com.wrbug.polymarketbot.dto
/**
* 账户导入请求
*/
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
)
/**
* 账户更新请求
*/
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
)
/**
* 账户删除请求
*/
data class AccountDeleteRequest(
val accountId: Long
)
/**
* 账户详情请求
*/
data class AccountDetailRequest(
val accountId: Long? = null // 不提供则返回默认账户
)
/**
* 账户余额请求
*/
data class AccountBalanceRequest(
val accountId: Long? = null // 不提供则查询默认账户
)
/**
* 设置默认账户请求
*/
data class SetDefaultAccountRequest(
val accountId: Long
)
/**
* 账户信息响应
*/
data class AccountDto(
val id: Long,
val walletAddress: String,
val accountName: String?,
val isDefault: Boolean,
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
val apiSecretConfigured: Boolean, // API Secret 是否已配置
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
val balance: String? = null, // 账户余额(可选)
val totalOrders: Long? = null, // 总订单数(可选)
val totalPnl: String? = null // 总盈亏(可选)
)
/**
* 账户列表响应
*/
data class AccountListResponse(
val list: List<AccountDto>,
val total: Long
)
/**
* 账户余额响应
*/
data class AccountBalanceResponse(
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
/**
* 持仓信息
*/
data class PositionDto(
val marketId: String,
val side: String, // YES 或 NO
val quantity: String,
val avgPrice: String,
val currentValue: String,
val pnl: String? = null
)
@@ -0,0 +1,65 @@
package com.wrbug.polymarketbot.dto
/**
* 统一API响应格式
* @param code 响应码,0表示成功,非0表示失败
* @param data 响应数据,可以是任意类型(对象、数组、字符串、数字等)
* @param msg 响应消息,成功时通常为空,失败时包含错误提示
*/
data class ApiResponse<T>(
val code: Int,
val data: T?,
val msg: String
) {
companion object {
/**
* 创建成功响应
*/
fun <T> success(data: T?): ApiResponse<T> {
return ApiResponse(code = 0, data = data, msg = "")
}
/**
* 创建失败响应
*/
fun <T> error(code: Int, msg: String): ApiResponse<T> {
return ApiResponse(code = code, data = null, msg = msg)
}
/**
* 创建参数错误响应
*/
fun <T> paramError(msg: String): ApiResponse<T> {
return ApiResponse(code = 1001, data = null, msg = msg)
}
/**
* 创建认证错误响应
*/
fun <T> authError(msg: String): ApiResponse<T> {
return ApiResponse(code = 2001, data = null, msg = msg)
}
/**
* 创建资源不存在响应
*/
fun <T> notFound(msg: String): ApiResponse<T> {
return ApiResponse(code = 3001, data = null, msg = msg)
}
/**
* 创建业务逻辑错误响应
*/
fun <T> businessError(msg: String): ApiResponse<T> {
return ApiResponse(code = 4001, data = null, msg = msg)
}
/**
* 创建服务器内部错误响应
*/
fun <T> serverError(msg: String): ApiResponse<T> {
return ApiResponse(code = 5001, data = null, msg = msg)
}
}
}
@@ -0,0 +1,73 @@
package com.wrbug.polymarketbot.dto
/**
* 市场 DTO(用于返回给前端)
*/
data class MarketDto(
val id: String,
val question: String,
val slug: String,
val category: String,
val active: Boolean,
val volume: String?,
val liquidity: String?,
val endDate: Long?, // 时间戳(毫秒)
val createdAt: Long?, // 时间戳(毫秒)
val updatedAt: Long?, // 时间戳(毫秒)
val outcomes: List<OutcomeDto>?,
val conditionId: String?,
val description: String?,
val image: String?,
val icon: String?,
val closed: Boolean?,
val archived: Boolean?,
val volumeNum: Double?,
val liquidityNum: Double?,
val bestBid: Double?,
val bestAsk: Double?,
val lastTradePrice: Double?
)
/**
* 结果 DTO
*/
data class OutcomeDto(
val name: String, // 结果名称,如 "Yes" 或 "No"
val price: String // 价格
)
/**
* 事件 DTO
*/
data class EventDto(
val id: String,
val title: String,
val category: String,
val active: Boolean,
val markets: List<MarketDto>?,
val createdAt: Long? // 时间戳(毫秒)
)
/**
* 系列 DTO
*/
data class SeriesDto(
val id: String,
val title: String,
val category: String,
val events: List<EventDto>?,
val createdAt: Long? // 时间戳(毫秒)
)
/**
* 评论 DTO
*/
data class CommentDto(
val id: String,
val market: String,
val content: String,
val parent: String?,
val createdAt: Long, // 时间戳(毫秒)
val user: String?
)
@@ -0,0 +1,29 @@
package com.wrbug.polymarketbot.dto
/**
* 订单 DTO(用于返回给前端)
*/
data class OrderDto(
val id: String,
val market: String,
val side: String,
val price: String,
val size: String,
val filled: String,
val status: String,
val createdAt: Long // 时间戳(毫秒)
)
/**
* 交易 DTO
*/
data class TradeDto(
val id: String,
val market: String,
val side: String,
val price: String,
val size: String,
val timestamp: Long, // 时间戳(毫秒)
val user: String?
)
@@ -0,0 +1,46 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* 账户信息实体
* 用于存储跟单者的账户信息(支持多账户)
*/
@Entity
@Table(name = "copy_trading_accounts")
data class Account(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "private_key", nullable = false, length = 500)
val privateKey: String, // 私钥(加密存储)
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
val walletAddress: String, // 钱包地址(从私钥推导)
@Column(name = "proxy_address", nullable = false, length = 42)
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
@Column(name = "api_key", length = 500)
val apiKey: String? = null, // Polymarket API Key(可选,加密存储)
@Column(name = "api_secret", length = 500)
val apiSecret: String? = null, // Polymarket API Secret(可选,加密存储)
@Column(name = "api_passphrase", length = 500)
val apiPassphrase: String? = null, // Polymarket API Passphrase(可选,加密存储)
@Column(name = "account_name", length = 100)
val accountName: String? = null,
@Column(name = "is_default", nullable = false)
val isDefault: Boolean = false, // 是否默认账户
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,33 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.Account
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
/**
* 账户 Repository
*/
@Repository
interface AccountRepository : JpaRepository<Account, Long> {
/**
* 根据钱包地址查找账户
*/
fun findByWalletAddress(walletAddress: String): Account?
/**
* 查找默认账户
*/
fun findByIsDefaultTrue(): Account?
/**
* 查找所有账户,按创建时间排序
*/
fun findAllByOrderByCreatedAtAsc(): List<Account>
/**
* 检查钱包地址是否存在
*/
fun existsByWalletAddress(walletAddress: String): Boolean
}
@@ -0,0 +1,536 @@
package com.wrbug.polymarketbot.service
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
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
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 logger = LoggerFactory.getLogger(AccountService::class.java)
/**
* 通过私钥导入账户
*/
@Transactional
fun importAccount(request: AccountImportRequest): Result<AccountDto> {
return try {
// 1. 验证钱包地址格式
if (!isValidWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 检查地址是否已存在
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
}
// 3. 验证私钥和地址的对应关系
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
if (!isValidPrivateKey(request.privateKey)) {
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) }
// 5. 如果设置为默认账户,取消其他账户的默认状态
if (request.isDefault) {
accountRepository.findByIsDefaultTrue()?.let { defaultAccount ->
val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis())
accountRepository.save(updated)
}
}
// 6. 获取代理地址(必须成功,否则导入失败)
val proxyAddress = runBlocking {
val proxyResult = blockchainService.getProxyAddress(request.walletAddress)
if (proxyResult.isSuccess) {
val address = proxyResult.getOrNull()
if (address != null) {
logger.info("成功获取代理地址: ${request.walletAddress} -> $address")
address
} else {
logger.error("获取代理地址返回空值")
throw IllegalStateException("获取代理地址失败:返回值为空")
}
} else {
val error = proxyResult.exceptionOrNull()
logger.error("获取代理地址失败: ${error?.message}")
throw IllegalStateException("获取代理地址失败: ${error?.message}。请确保已配置 Ethereum RPC URL 且 RPC 节点可用")
}
}
// 7. 创建账户
val account = Account(
privateKey = encryptedPrivateKey,
walletAddress = request.walletAddress,
proxyAddress = proxyAddress,
apiKey = encryptedApiKey,
apiSecret = encryptedApiSecret,
apiPassphrase = encryptedApiPassphrase,
accountName = request.accountName,
isDefault = request.isDefault,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
val saved = accountRepository.save(account)
logger.info("成功导入账户: ${saved.id}, ${saved.walletAddress}, 代理地址: ${saved.proxyAddress}")
Result.success(toDto(saved))
} catch (e: Exception) {
logger.error("导入账户失败", e)
Result.failure(e)
}
}
/**
* 更新账户信息
*/
@Transactional
fun updateAccount(request: AccountUpdateRequest): Result<AccountDto> {
return try {
val account = accountRepository.findById(request.accountId)
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
// 更新账户名称
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) {
accountRepository.findByIsDefaultTrue()?.let { defaultAccount ->
val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis())
accountRepository.save(updated)
}
}
val updated = account.copy(
accountName = updatedAccountName,
apiKey = updatedApiKey,
apiSecret = updatedApiSecret,
apiPassphrase = updatedApiPassphrase,
isDefault = updatedIsDefault,
updatedAt = System.currentTimeMillis()
)
val saved = accountRepository.save(updated)
logger.info("成功更新账户: ${saved.id}")
Result.success(toDto(saved))
} catch (e: Exception) {
logger.error("更新账户失败", e)
Result.failure(e)
}
}
/**
* 删除账户
*/
@Transactional
fun deleteAccount(accountId: Long): Result<Unit> {
return try {
val account = accountRepository.findById(accountId)
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
// 注意:不再检查活跃订单,允许用户删除有活跃订单的账户
// 前端会显示确认提示框,由用户决定是否删除
// 如果删除的是默认账户,需要先设置其他账户为默认
if (account.isDefault) {
val otherAccounts = accountRepository.findAllByOrderByCreatedAtAsc()
.filter { it.id != accountId }
if (otherAccounts.isNotEmpty()) {
val newDefault = otherAccounts.first().copy(
isDefault = true,
updatedAt = System.currentTimeMillis()
)
accountRepository.save(newDefault)
} else {
return Result.failure(IllegalStateException("不能删除最后一个账户"))
}
}
accountRepository.delete(account)
logger.info("成功删除账户: $accountId")
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除账户失败", e)
Result.failure(e)
}
}
/**
* 查询账户列表
*/
fun getAccountList(): Result<AccountListResponse> {
return try {
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
val accountDtos = accounts.map { toDto(it) }
Result.success(AccountListResponse(
list = accountDtos,
total = accountDtos.size.toLong()
))
} catch (e: Exception) {
logger.error("查询账户列表失败", e)
Result.failure(e)
}
}
/**
* 查询账户详情
*/
fun getAccountDetail(accountId: Long?): Result<AccountDto> {
return try {
val account = if (accountId != null) {
accountRepository.findById(accountId).orElse(null)
} else {
accountRepository.findByIsDefaultTrue()
}
account ?: return Result.failure(IllegalArgumentException("账户不存在"))
Result.success(toDto(account))
} catch (e: Exception) {
logger.error("查询账户详情失败", e)
Result.failure(e)
}
}
/**
* 查询账户余额
* 通过链上 RPC 查询 USDC 余额,并通过 Subgraph API 查询持仓信息
*/
fun getAccountBalance(accountId: Long?): Result<AccountBalanceResponse> {
return try {
val account = if (accountId != null) {
accountRepository.findById(accountId).orElse(null)
} else {
accountRepository.findByIsDefaultTrue()
}
account ?: return Result.failure(IllegalArgumentException("账户不存在"))
// 检查代理地址是否存在
if (account.proxyAddress.isBlank()) {
logger.error("账户 ${account.id} 的代理地址为空,无法查询余额")
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
}
// 查询 USDC 余额和持仓信息
val balanceResult = runBlocking {
try {
// 先查询持仓信息(用于计算仓位余额和返回持仓列表)
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
val positionsResult = blockchainService.getPositions(account.proxyAddress)
val positions = if (positionsResult.isSuccess) {
positionsResult.getOrNull()?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 计算仓位余额(持仓总价值)
val positionBalance = positions.sumOf {
it.currentValue.toSafeBigDecimal()
}
// 查询可用余额(通过 RPC 查询 USDC 余额)
// 必须使用代理地址查询
val availableBalanceResult = blockchainService.getUsdcBalance(
walletAddress = account.walletAddress,
proxyAddress = account.proxyAddress
)
val availableBalance = if (availableBalanceResult.isSuccess) {
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
} else {
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
val error = availableBalanceResult.exceptionOrNull()
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
}
// 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance)
AccountBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance.toPlainString(),
totalBalance = totalBalance.toPlainString(),
positions = positions
)
} catch (e: Exception) {
logger.error("查询余额失败: ${e.message}", e)
throw e
}
}
Result.success(balanceResult)
} catch (e: Exception) {
logger.error("查询账户余额失败", e)
Result.failure(e)
}
}
/**
* 设置默认账户
*/
@Transactional
fun setDefaultAccount(accountId: Long): Result<Unit> {
return try {
val account = accountRepository.findById(accountId)
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
// 取消其他账户的默认状态
accountRepository.findByIsDefaultTrue()?.let { defaultAccount ->
if (defaultAccount.id != account.id) {
val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis())
accountRepository.save(updated)
}
}
// 设置当前账户为默认
val updated = account.copy(isDefault = true, updatedAt = System.currentTimeMillis())
accountRepository.save(updated)
logger.info("成功设置默认账户: $accountId")
Result.success(Unit)
} catch (e: Exception) {
logger.error("设置默认账户失败", e)
Result.failure(e)
}
}
/**
* 转换为 DTO
* 包含交易统计数据(总订单数和总盈亏)
*/
private fun toDto(account: Account): AccountDto {
return runBlocking {
val statistics = getAccountStatistics(account)
AccountDto(
id = account.id!!,
walletAddress = account.walletAddress,
accountName = account.accountName,
isDefault = account.isDefault,
apiKeyConfigured = account.apiKey != null,
apiSecretConfigured = account.apiSecret != null,
apiPassphraseConfigured = account.apiPassphrase != null,
totalOrders = statistics.totalOrders,
totalPnl = statistics.totalPnl
)
}
}
/**
* 获取账户交易统计数据
*/
private suspend fun getAccountStatistics(account: Account): AccountStatistics {
return try {
// 如果账户没有配置 API 凭证,无法查询统计数据
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
return AccountStatistics(totalOrders = null, totalPnl = null)
}
// 解密 API 凭证
val apiKey = cryptoUtils.decrypt(account.apiKey)
val apiSecret = cryptoUtils.decrypt(account.apiSecret)
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase)
// 创建带认证的 API 客户端
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
// 1. 查询交易记录数量(总订单数)
val tradesResult = runBlocking {
try {
// 使用代理地址查询交易记录
val response = clobApi.getTrades(
maker_address = account.proxyAddress,
next_cursor = null
)
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())
} else {
Result.failure(Exception("查询交易记录失败: ${response.code()} ${response.message()}"))
}
} 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("查询仓位信息失败"))
}
} catch (e: Exception) {
logger.warn("查询仓位盈亏失败: ${e.message}", e)
Result.failure(e)
}
}
AccountStatistics(
totalOrders = tradesResult.getOrNull(),
totalPnl = totalPnlResult.getOrNull()
)
} catch (e: Exception) {
logger.warn("获取账户统计数据失败: ${e.message}", e)
AccountStatistics(totalOrders = null, totalPnl = null)
}
}
/**
* 账户统计数据
*/
private data class AccountStatistics(
val totalOrders: Long?,
val totalPnl: String?
)
/**
* 验证钱包地址格式
*/
private fun isValidWalletAddress(address: String): Boolean {
// 以太坊地址格式:0x 开头,42 位字符
return address.startsWith("0x") && address.length == 42 && address.matches(Regex("^0x[0-9a-fA-F]{40}$"))
}
/**
* 验证私钥格式
*/
private fun isValidPrivateKey(privateKey: String): Boolean {
// 私钥格式:64 位十六进制字符(可选 0x 前缀)
val cleanKey = if (privateKey.startsWith("0x")) privateKey.substring(2) else privateKey
return cleanKey.length == 64 && cleanKey.matches(Regex("^[0-9a-fA-F]{64}$"))
}
/**
* 检查账户是否有活跃订单
* 使用账户的 API Key 查询该账户的活跃订单
*/
private suspend fun hasActiveOrders(account: Account): Boolean {
return try {
// 如果账户没有配置 API 凭证,无法查询活跃订单,允许删除
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
logger.debug("账户 ${account.id} 未配置 API 凭证,无法查询活跃订单,允许删除")
return false
}
// 解密 API 凭证(前面已检查不为 null)
val apiKey = cryptoUtils.decrypt(account.apiKey)
val apiSecret = cryptoUtils.decrypt(account.apiSecret)
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase)
// 创建带认证的 API 客户端
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
// 查询活跃订单(只查询第一条,用于判断是否有订单)
// 使用 next_cursor 参数进行分页,这里只查询第一页
val response = clobApi.getActiveOrders(
id = null,
market = null,
asset_id = null,
next_cursor = null // null 表示从第一页开始
)
if (response.isSuccessful && response.body() != null) {
val ordersResponse = response.body()!!
val hasOrders = ordersResponse.data.isNotEmpty()
logger.debug("账户 ${account.id} 活跃订单检查结果: $hasOrders (订单数: ${ordersResponse.data.size})")
hasOrders
} else {
// 如果查询失败(可能是认证失败或网络问题),记录警告但允许删除
// 因为无法确定是否有活跃订单,不应该阻止删除操作
logger.warn("查询活跃订单失败: ${response.code()} ${response.message()},允许删除账户")
false
}
} catch (e: Exception) {
// 如果查询异常(网络问题、API 错误等),记录警告但允许删除
// 因为无法确定是否有活跃订单,不应该阻止删除操作
logger.warn("检查活跃订单异常: ${e.message},允许删除账户", e)
false
}
}
}
@@ -0,0 +1,242 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.api.EthereumRpcApi
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.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.math.BigDecimal
import java.math.BigInteger
/**
* 区块链查询服务
* 用于查询链上余额和持仓信息
*/
@Service
class BlockchainService(
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
private val dataApiBaseUrl: String,
@Value("\${ethereum.rpc.url:}")
private val ethereumRpcUrl: String,
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
// USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// Polymarket 代理工厂合约地址(Polygon 主网)
// 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b
private val proxyFactoryContractAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
// 获取代理地址的函数签名
// 根据 Polygonscan 的 F4 方法,函数签名为: computeProxyAddress(address)
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
private val dataApi: PolymarketDataApi by lazy {
val baseUrl = if (dataApiBaseUrl.endsWith("/")) {
dataApiBaseUrl.dropLast(1)
} else {
dataApiBaseUrl
}
val okHttpClient = createClient()
.followRedirects(true)
.followSslRedirects(true)
.build()
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(PolymarketDataApi::class.java)
}
private val ethereumRpcApi: EthereumRpcApi? by lazy {
if (ethereumRpcUrl.isBlank()) {
null
} else {
retrofitFactory.createEthereumRpcApi(ethereumRpcUrl)
}
}
/**
* 获取 Polymarket 代理钱包地址
* 通过 RPC 调用代理工厂合约获取用户的代理钱包地址
* @param walletAddress 用户的钱包地址
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String): Result<String> {
return try {
// 如果未配置 RPC URL,返回错误
if (ethereumRpcUrl.isBlank()) {
logger.warn("未配置 Ethereum RPC URL,无法获取代理地址")
return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法获取代理地址。请在配置文件中设置 ethereum.rpc.url 环境变量"))
}
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
// 计算函数选择器
val functionSelector = EthereumUtils.getFunctionSelector(computeProxyAddressFunctionSignature)
// 编码地址参数
val encodedAddress = EthereumUtils.encodeAddress(walletAddress)
// 构建调用数据
val data = functionSelector + encodedAddress
// 构建 JSON-RPC 请求
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to proxyFactoryContractAddress,
"data" to data
),
"latest"
)
)
// 发送 RPC 请求
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
throw Exception("RPC 请求失败: ${response.code()} ${response.message()}")
}
val rpcResponse = response.body()!!
// 检查错误
if (rpcResponse.error != null) {
throw Exception("RPC 错误: ${rpcResponse.error.message}")
}
val hexResult = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空")
// 解析代理地址
val proxyAddress = EthereumUtils.decodeAddress(hexResult)
logger.debug("获取代理地址成功: 原始地址=$walletAddress, 代理地址=$proxyAddress")
Result.success(proxyAddress)
} catch (e: Exception) {
logger.error("获取代理地址失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 查询账户 USDC 余额
* 通过 Ethereum RPC 查询 ERC-20 代币余额
* @param walletAddress 钱包地址(用于日志记录)
* @param proxyAddress 代理地址(必须提供)
* 如果 RPC 未配置或代理地址为空,返回失败(不返回 mock 数据)
*/
suspend fun getUsdcBalance(walletAddress: String, proxyAddress: String): Result<String> {
return try {
// 如果未配置 RPC URL,返回错误
if (ethereumRpcUrl.isBlank()) {
logger.warn("未配置 Ethereum RPC URL,无法查询 USDC 余额")
return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法查询 USDC 余额。请在配置文件中设置 ethereum.rpc.url 环境变量"))
}
// 检查代理地址是否为空
if (proxyAddress.isBlank()) {
logger.error("代理地址为空,无法查询余额")
return Result.failure(IllegalArgumentException("代理地址不能为空"))
}
logger.debug("使用代理地址查询余额: $proxyAddress (原始地址: $walletAddress)")
// 使用 RPC 查询 USDC 余额(使用代理地址)
val balance = queryUsdcBalanceViaRpc(proxyAddress)
Result.success(balance)
} catch (e: Exception) {
logger.error("查询 USDC 余额失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 通过 RPC 查询 USDC 余额
*/
private suspend fun queryUsdcBalanceViaRpc(walletAddress: String): String {
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
// 构建 ERC-20 balanceOf 函数调用
// function signature: balanceOf(address) -> bytes4(0x70a08231)
// 参数编码: address (32 bytes, padded)
val functionSelector = "0x70a08231" // balanceOf(address)
val paddedAddress = walletAddress.removePrefix("0x").lowercase().padStart(64, '0')
val data = functionSelector + paddedAddress
// 构建 JSON-RPC 请求
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to usdcContractAddress,
"data" to data
),
"latest"
)
)
// 发送 RPC 请求(使用 Retrofit
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
throw Exception("RPC 请求失败: ${response.code()} ${response.message()}")
}
val rpcResponse = response.body()!!
// 检查错误
if (rpcResponse.error != null) {
throw Exception("RPC 错误: ${rpcResponse.error.message}")
}
val hexBalance = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空")
// 将十六进制转换为 BigDecimalUSDC 有 6 位小数)
val balanceWei = BigInteger(hexBalance.removePrefix("0x"), 16)
val balance = BigDecimal(balanceWei).divide(BigDecimal("1000000")) // USDC 有 6 位小数
return balance.toPlainString()
}
/**
* 查询账户持仓信息
* 通过 Polymarket Data API 查询
* 文档: https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user
*/
suspend fun getPositions(proxyWalletAddress: String): Result<List<PositionResponse>> {
return try {
// 使用代理钱包地址查询仓位
val response = dataApi.getPositions(
user = proxyWalletAddress,
limit = 500, // 最大限制
offset = 0
)
if (response.isSuccessful && response.body() != null) {
val positions = response.body()!!
logger.debug("查询到 ${positions.size} 个仓位")
Result.success(positions)
} 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,165 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.api.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
/**
* Polymarket CLOB API 服务封装
* 提供订单操作、市场数据、交易数据等功能
*/
@Service
class PolymarketClobService(
private val clobApi: PolymarketClobApi
) {
private val logger = LoggerFactory.getLogger(PolymarketClobService::class.java)
/**
* 获取订单簿
*/
suspend fun getOrderbook(market: String): Result<OrderbookResponse> {
return try {
val response = clobApi.getOrderbook(market)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("获取订单簿失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取订单簿异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取价格信息
*/
suspend fun getPrice(market: String): Result<PriceResponse> {
return try {
val response = clobApi.getPrice(market)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("获取价格失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取价格异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取中间价
*/
suspend fun getMidpoint(market: String): Result<MidpointResponse> {
return try {
val response = clobApi.getMidpoint(market)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("获取中间价失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取中间价异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 创建订单
*/
suspend fun createOrder(request: CreateOrderRequest): Result<OrderResponse> {
return try {
val response = clobApi.createOrder(request)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("创建订单失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("创建订单异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取活跃订单
*/
suspend fun getActiveOrders(
id: String? = null,
market: String? = null,
asset_id: String? = null,
next_cursor: String? = null
): Result<List<OrderResponse>> {
return try {
val response = clobApi.getActiveOrders(
id = id,
market = market,
asset_id = asset_id,
next_cursor = next_cursor
)
if (response.isSuccessful && response.body() != null) {
val ordersResponse = response.body()!!
Result.success(ordersResponse.data)
} else {
Result.failure(Exception("获取活跃订单失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取活跃订单异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 取消订单
*/
suspend fun cancelOrder(orderId: String): Result<CancelOrderResponse> {
return try {
val response = clobApi.cancelOrder(orderId)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("取消订单失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("取消订单异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取交易记录
*/
suspend fun getTrades(
id: String? = null,
maker_address: String? = null,
market: String? = null,
asset_id: String? = null,
before: String? = null,
after: String? = null,
next_cursor: String? = null
): Result<List<TradeResponse>> {
return try {
val response = clobApi.getTrades(
id = id,
maker_address = maker_address,
market = market,
asset_id = asset_id,
before = before,
after = after,
next_cursor = next_cursor
)
if (response.isSuccessful && response.body() != null) {
val tradesResponse = response.body()!!
Result.success(tradesResponse.data)
} else {
Result.failure(Exception("获取交易记录失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取交易记录异常: ${e.message}", e)
Result.failure(e)
}
}
}
@@ -0,0 +1,104 @@
package com.wrbug.polymarketbot.util
/**
* 分类验证工具类
* 用于验证分类参数是否符合项目要求(仅支持 sports 和 crypto
*/
object CategoryValidator {
/**
* 支持的分类列表
*/
private val SUPPORTED_CATEGORIES = setOf("sports", "crypto")
/**
* 分类名称映射(将 Polymarket API 返回的分类名称映射到标准分类)
*/
private val CATEGORY_MAPPING = mapOf(
"sports" to "sports",
"crypto" to "crypto",
"cryptocurrency" to "crypto",
"cryptocurrencies" to "crypto"
)
/**
* 验证分类是否有效(支持精确匹配和关键字匹配)
* @param category 分类名称
* @return 是否有效
*/
fun isValid(category: String?): Boolean {
if (category == null) {
return false
}
val categoryLower = category.lowercase()
// 精确匹配
if (categoryLower in SUPPORTED_CATEGORIES) {
return true
}
// 映射匹配
if (categoryLower in CATEGORY_MAPPING.keys) {
return true
}
// 关键字匹配
if (categoryLower.contains("sport")) {
return true
}
if (categoryLower.contains("crypto")) {
return true
}
return false
}
/**
* 标准化分类名称
* @param category 原始分类名称
* @return 标准化后的分类名称(sports 或 crypto
*/
fun normalizeCategory(category: String?): String? {
if (category == null) {
return null
}
val categoryLower = category.lowercase()
// 映射匹配
CATEGORY_MAPPING[categoryLower]?.let {
return it
}
// 关键字匹配
if (categoryLower.contains("sport")) {
return "sports"
}
if (categoryLower.contains("crypto")) {
return "crypto"
}
return null
}
/**
* 验证分类,如果无效则抛出异常
* @param category 分类名称
* @throws IllegalArgumentException 如果分类无效
*/
fun validate(category: String?) {
if (!isValid(category)) {
throw IllegalArgumentException("不支持的分类: $category,仅支持: ${SUPPORTED_CATEGORIES.joinToString(", ")}")
}
}
/**
* 获取所有支持的分类
* @return 支持的分类列表
*/
fun getSupportedCategories(): Set<String> {
return SUPPORTED_CATEGORIES
}
}
@@ -0,0 +1,80 @@
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,65 @@
package com.wrbug.polymarketbot.util
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
/**
* 日期工具类
* 用于处理日期字符串和时间戳之间的转换
*/
object DateUtils {
/**
* ISO 8601 日期时间格式化器
*/
private val isoFormatter = DateTimeFormatter.ISO_DATE_TIME
/**
* 将 ISO 8601 格式的日期字符串转换为时间戳(毫秒)
* @param dateString ISO 8601 格式的日期字符串,如 "2020-11-04T00:00:00Z"
* @return 时间戳(毫秒),如果转换失败返回 null
*/
fun parseToTimestamp(dateString: String?): Long? {
if (dateString.isNullOrBlank()) {
return null
}
return try {
// 尝试解析 ISO 8601 格式
val instant = Instant.parse(dateString)
instant.toEpochMilli()
} catch (e: DateTimeParseException) {
// 如果解析失败,尝试其他格式
try {
// 尝试使用 ISO_DATE_TIME 格式化器
val dateTime = java.time.ZonedDateTime.parse(dateString, isoFormatter)
dateTime.toInstant().toEpochMilli()
} catch (e2: Exception) {
// 所有解析都失败,返回 null
null
}
} catch (e: Exception) {
null
}
}
/**
* 将时间戳(毫秒)转换为 ISO 8601 格式的日期字符串
* @param timestamp 时间戳(毫秒)
* @return ISO 8601 格式的日期字符串
*/
fun formatFromTimestamp(timestamp: Long?): String? {
if (timestamp == null) {
return null
}
return try {
val instant = Instant.ofEpochMilli(timestamp)
instant.toString()
} catch (e: Exception) {
null
}
}
}
@@ -0,0 +1,56 @@
package com.wrbug.polymarketbot.util
import org.bouncycastle.crypto.digests.KeccakDigest
import java.math.BigInteger
/**
* Ethereum 工具类
* 用于计算函数签名、编码参数等
*/
object EthereumUtils {
/**
* 计算函数选择器(前4个字节)
* @param functionSignature 函数签名,例如 "computeProxyAddress(address)"
* @return 函数选择器,例如 "0x12345678"
*/
fun getFunctionSelector(functionSignature: String): String {
val hash = keccak256(functionSignature.toByteArray())
return "0x" + hash.substring(0, 8)
}
/**
* 编码地址参数(32字节,左对齐)
* @param address 地址,例如 "0x1234..."
* @return 编码后的地址,64个十六进制字符
*/
fun encodeAddress(address: String): String {
val cleanAddress = address.removePrefix("0x").lowercase()
return cleanAddress.padStart(64, '0')
}
/**
* 从合约调用结果中解析地址
* @param hexResult 十六进制结果
* @return 地址字符串
*/
fun decodeAddress(hexResult: String): String {
val cleanHex = hexResult.removePrefix("0x")
// 地址是最后20字节(40个十六进制字符)
val addressHex = cleanHex.takeLast(40)
return "0x$addressHex"
}
/**
* 计算 Keccak-256 哈希(Ethereum 标准)
* 使用 BouncyCastle 库实现真正的 Keccak-256
*/
private fun keccak256(data: ByteArray): String {
val digest = KeccakDigest(256)
digest.update(data, 0, data.size)
val hash = ByteArray(digest.digestSize)
digest.doFinal(hash, 0)
return hash.joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,32 @@
package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
/**
* JSON 工具类
* 用于解析 JSON 字符串
*/
object JsonUtils {
private val gson = Gson()
/**
* 解析 JSON 字符串数组
* @param jsonString JSON 字符串,如 "[\"Yes\", \"No\"]"
* @return 字符串列表,如果解析失败返回空列表
*/
fun parseStringArray(jsonString: String?): List<String> {
if (jsonString.isNullOrBlank()) {
return emptyList()
}
return try {
val listType = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(jsonString, listType) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
}
@@ -0,0 +1,138 @@
package com.wrbug.polymarketbot.util
import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode
/**
* BigDecimal乘法扩展函数
* 安全地将BigDecimal与任意数值类型相乘
* @param value 乘数,支持BigDecimal、BigInteger、Number类型或可转换为BigDecimal的字符串
* @return 乘法结果,如果转换失败返回BigDecimal.ZERO
*/
fun BigDecimal.multi(value: Any): BigDecimal {
kotlin.runCatching {
if (value is BigDecimal) {
return multiply(value)
}
if (value is BigInteger) {
return multiply(value.toBigDecimal())
}
if (value is Number) {
return multiply(value.toSafeBigDecimal())
}
return multiply(BigDecimal(value.toString()))
}
return BigDecimal.ZERO
}
/**
* BigDecimal除法扩展函数
* 安全地将BigDecimal与任意数值类型相除
* @param value 除数,支持BigDecimal、BigInteger类型或可转换为BigDecimal的字符串
* @return 除法结果,精度为18位小数,使用四舍五入模式,如果转换失败返回IllegalBigDecimal
*/
fun BigDecimal.div(value: Any): BigDecimal {
kotlin.runCatching {
if (value is BigDecimal) {
return divide(value, 18, RoundingMode.HALF_UP).stripTrailingZeros()
}
if (value is BigInteger) {
return divide(value.toSafeBigDecimal(), 18, RoundingMode.HALF_UP).stripTrailingZeros()
}
return divide(BigDecimal(value.toString()), 18, RoundingMode.HALF_UP).stripTrailingZeros()
}
return IllegalBigDecimal
}
/**
* BigInteger乘法扩展函数
* 将BigInteger转换为BigDecimal后与任意数值类型相乘
* @param value 乘数,支持任意可转换为BigDecimal的类型
* @return 乘法结果,如果转换失败返回IllegalBigDecimal
*/
fun BigInteger.multi(value: Any): BigDecimal {
val v = this.toBigDecimal()
return runCatching {
v.multi(value)
}.getOrDefault(IllegalBigDecimal)
}
/**
* 大于比较扩展函数
* 安全地比较两个任意类型的数值大小
* @param target 比较目标值
* @return 如果当前值大于目标值返回true,否则返回falsenull值返回false
*/
fun Any?.gt(target: Any?): Boolean {
if (this == null || target == null) {
return false
}
return this.toSafeBigDecimal() > target.toSafeBigDecimal()
}
/**
* 大于等于比较扩展函数
* 安全地比较两个任意类型的数值大小
* @param target 比较目标值
* @return 如果当前值大于等于目标值返回true,否则返回falsenull值返回false
*/
fun Any?.gte(target: Any?): Boolean {
if (this == null || target == null) {
return false
}
return this.toSafeBigDecimal() >= target.toSafeBigDecimal()
}
/**
* 小于比较扩展函数
* 安全地比较两个任意类型的数值大小
* @param target 比较目标值
* @return 如果当前值小于目标值返回true,否则返回falsenull值返回false
*/
fun Any?.lt(target: Any?): Boolean {
if (this == null || target == null) {
return false
}
return this.toSafeBigDecimal() < target.toSafeBigDecimal()
}
/**
* 小于等于比较扩展函数
* 安全地比较两个任意类型的数值大小
* @param target 比较目标值
* @return 如果当前值小于等于目标值返回true,否则返回falsenull值返回false
*/
fun Any?.lte(target: Any?): Boolean {
if (this == null || target == null) {
return false
}
return this.toSafeBigDecimal() <= target.toSafeBigDecimal()
}
/**
* 等于比较扩展函数
* 安全地比较两个任意类型的数值是否相等
* @param target 比较目标值
* @return 如果当前值等于目标值返回true,否则返回falsenull值返回false
*/
fun Any?.eq(target: Any?): Boolean {
if (this == null || target == null) {
return false
}
return this.toSafeBigDecimal() == target.toSafeBigDecimal()
}
/**
* 不等于比较扩展函数
* 安全地比较两个任意类型的数值是否不相等
* @param target 比较目标值
* @return 如果当前值不等于目标值返回true,否则返回falsenull值返回false
*/
fun Any?.neq(target: Any?): Boolean {
if (this == null || target == null) {
return false
}
return this.toSafeBigDecimal() != target.toSafeBigDecimal()
}
@@ -0,0 +1,85 @@
package com.wrbug.polymarketbot.util
import okhttp3.Credentials
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.net.ssl.*
/**
* 创建OkHttpClient客户端
* @return OkHttpClient.Builder
*/
fun createClient() = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.httpProxy("127.0.0.1", 8888)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
/**
* 为OkHttpClient添加HTTP代理支持
* @param hostname 代理服务器地址
* @param port 代理服务器端口
* @param user 代理用户名(可选)
* @param password 代理密码(可选)
* @return OkHttpClient.Builder
*/
fun OkHttpClient.Builder.httpProxy(
hostname: String, port: Int, user: String = "", password: String = ""
): OkHttpClient.Builder {
if (getEnv("ENABLE_PROXY") != "1") {
return this
}
return apply {
proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(hostname, port)))
createSSLSocketFactory()
if (user.isNotEmpty() && password.isNotEmpty()) {
proxyAuthenticator { _, res ->
val credential: String = Credentials.basic(user, password)
res.request.newBuilder().header("Proxy-Authorization", credential).build()
}
}
}
}
/**
* 为OkHttpClient创建信任所有证书的SSL工厂
* @return OkHttpClient.Builder
*/
fun OkHttpClient.Builder.createSSLSocketFactory(): OkHttpClient.Builder {
runCatching {
val sc: SSLContext = SSLContext.getInstance("TLS")
sc.init(null, arrayOf<TrustManager>(TrustAllManager()), SecureRandom())
this.sslSocketFactory(sc.socketFactory, TrustAllManager())
}
return this
}
/**
* 信任所有证书的TrustManager
*/
class TrustAllManager : X509TrustManager {
@Throws(CertificateException::class)
override fun checkClientTrusted(chain: Array<X509Certificate?>?, authType: String?) {
}
@Throws(CertificateException::class)
override fun checkServerTrusted(chain: Array<X509Certificate?>?, authType: String?) {
}
override fun getAcceptedIssuers() = arrayOfNulls<X509Certificate>(0)
}
/**
* 信任所有主机名的HostnameVerifier
*/
class TrustAllHostnameVerifier : HostnameVerifier {
override fun verify(hostname: String?, session: SSLSession?): Boolean {
return true
}
}
@@ -0,0 +1,77 @@
package com.wrbug.polymarketbot.util
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import java.io.IOException
import java.time.Instant
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.Base64
/**
* Polymarket API 认证拦截器
* 实现 L2 认证(使用 API Key、Secret、Passphrase
*
* 认证方式:
* 1. 生成时间戳(秒)
* 2. 使用 Secret 对 (timestamp + method + path + body) 进行 HMAC-SHA256 签名
* 3. 在请求头中添加:
* - X-API-KEY: API Key
* - X-API-SIGN: Base64 编码的签名
* - X-API-TIMESTAMP: 时间戳
* - X-API-PASSPHRASE: Passphrase
*/
class PolymarketAuthInterceptor(
private val apiKey: String,
private val apiSecret: String,
private val apiPassphrase: String
) : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
// 生成时间戳(秒)
val timestamp = Instant.now().epochSecond.toString()
// 构建签名字符串: timestamp + method + path + body
val method = originalRequest.method
val path = originalRequest.url.encodedPath + if (originalRequest.url.query != null) "?${originalRequest.url.query}" else ""
// 读取请求体(如果存在)
val body = originalRequest.body?.let { requestBody ->
val buffer = Buffer()
requestBody.writeTo(buffer)
buffer.readUtf8()
} ?: ""
val signString = "$timestamp$method$path$body"
// 使用 HMAC-SHA256 生成签名
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()
return chain.proceed(newRequest)
}
/**
* 使用 HMAC-SHA256 生成签名
*/
private fun generateSignature(message: String, secret: String): String {
val mac = Mac.getInstance("HmacSHA256")
val secretKeySpec = SecretKeySpec(secret.toByteArray(), "HmacSHA256")
mac.init(secretKeySpec)
val hash = mac.doFinal(message.toByteArray())
return Base64.getEncoder().encodeToString(hash)
}
}
@@ -0,0 +1,62 @@
package com.wrbug.polymarketbot.util
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.PolymarketClobApi
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
/**
* Retrofit 客户端工厂
* 用于创建带认证的 Polymarket CLOB API 客户端和 Ethereum RPC API 客户端
*/
@Component
class RetrofitFactory(
@Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String
) {
/**
* 创建带认证的 Polymarket CLOB API 客户端
* @param apiKey API Key
* @param apiSecret API Secret
* @param apiPassphrase API Passphrase
* @return PolymarketClobApi 客户端
*/
fun createClobApi(
apiKey: String,
apiSecret: String,
apiPassphrase: String
): PolymarketClobApi {
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase)
val okHttpClient = createClient()
.addInterceptor(authInterceptor)
.build()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(PolymarketClobApi::class.java)
}
/**
* 创建 Ethereum RPC API 客户端
* @param rpcUrl RPC 节点 URL
* @return EthereumRpcApi 客户端
*/
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
val okHttpClient = createClient().build()
return Retrofit.Builder()
.baseUrl(rpcUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(EthereumRpcApi::class.java)
}
}
@@ -0,0 +1,97 @@
package com.wrbug.polymarketbot.util
import java.math.BigDecimal
import java.math.BigInteger
/**
* 非法的BigDecimal常量,用于表示转换失败的情况
*/
val IllegalBigDecimal = BigDecimal("0")
/**
* 非法的BigInteger常量,用于表示转换失败的情况
*/
val IllegalBigInteger = BigInteger("0")
/**
* 安全转换为BigDecimal的扩展函数
* 将任意类型安全地转换为BigDecimal,转换失败时返回IllegalBigDecimal
* @return 转换后的BigDecimal值,失败时返回IllegalBigDecimal
*/
fun Any?.toSafeBigDecimal(): BigDecimal {
return try {
if (this is BigDecimal) {
return this
}
if (this is BigInteger) {
return this.toBigDecimal()
}
if (this is Number) {
return BigDecimal.valueOf(this.toDouble())
}
BigDecimal(this.toString())
} catch (t: Throwable) {
IllegalBigDecimal
}
}
/**
* 安全转换为BigInteger的扩展函数
* 将字符串安全地转换为BigInteger,转换失败时返回IllegalBigInteger
* @return 转换后的BigInteger值,失败时返回IllegalBigInteger
*/
fun String?.toSafeBigInteger(): BigInteger {
return try {
BigInteger(this.orEmpty())
} catch (t: Throwable) {
IllegalBigInteger
}
}
/**
* 安全转换为Long的扩展函数
* 将字符串安全地转换为Long,转换失败时返回0
* @return 转换后的Long值,失败时返回0
*/
fun String?.toSafeLong(): Long {
return try {
this?.toLong() ?: 0
} catch (t: Throwable) {
0
}
}
/**
* 安全转换为Int的扩展函数
* 将任意类型安全地转换为Int,转换失败时返回0
* @return 转换后的Int值,失败时返回0
*/
fun Any?.toSafeInt(): Int {
return try {
if (this is Number) {
this.toInt()
} else {
this?.toString().toSafeBigDecimal().toInt()
}
} catch (t: Throwable) {
0
}
}
/**
* 安全转换为Double的扩展函数
* 将任意类型安全地转换为Double,转换失败时返回0.0
* @return 转换后的Double值,失败时返回0.0
*/
fun Any?.toSafeDouble(): Double {
return try {
when (this) {
is Number -> this.toDouble()
is Boolean -> if (this) 1.0 else 0.0
else -> this?.toString().toSafeBigDecimal().toDouble()
}
} catch (t: Throwable) {
0.0
}
}
@@ -0,0 +1,9 @@
package com.wrbug.polymarketbot.util
/**
* 获取环境变量的扩展函数
* @param name 环境变量名称
* @return 环境变量值,不存在时返回空字符串
*/
fun getEnv(name: String) = System.getenv(name).orEmpty()
@@ -0,0 +1,77 @@
package com.wrbug.polymarketbot.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import org.java_websocket.client.WebSocketClient
import org.java_websocket.handshake.ServerHandshake
import org.slf4j.LoggerFactory
import java.net.URI
/**
* Polymarket WebSocket 客户端
* 用于连接到 Polymarket RTDS
*/
class PolymarketWebSocketClient(
serverUri: URI,
private val objectMapper: ObjectMapper,
private val sessionId: String,
private val onMessage: (String) -> Unit
) : WebSocketClient(serverUri) {
private val logger = LoggerFactory.getLogger(PolymarketWebSocketClient::class.java)
override fun onOpen(handshakedata: ServerHandshake?) {
logger.info("已成功连接到 Polymarket RTDS: $sessionId")
}
override fun onMessage(message: String?) {
if (message != null) {
logger.debug("收到 Polymarket 消息: $sessionId, $message")
onMessage(message)
}
}
override fun onClose(code: Int, reason: String?, remote: Boolean) {
logger.info("Polymarket 连接关闭: $sessionId, code: $code, reason: $reason, remote: $remote")
}
/**
* 关闭连接
*/
fun closeConnection() {
if (isOpen) {
try {
closeBlocking()
} catch (e: Exception) {
logger.error("关闭连接失败: $sessionId, ${e.message}", e)
}
}
}
override fun onError(ex: Exception?) {
logger.error("Polymarket WebSocket 错误: $sessionId, ${ex?.message}", ex)
}
/**
* 发送消息到 Polymarket
*/
fun sendMessage(message: String) {
if (isOpen) {
try {
send(message)
} catch (e: Exception) {
logger.error("发送消息失败: $sessionId, ${e.message}", e)
throw e
}
} else {
logger.warn("WebSocket 未连接,无法发送消息: $sessionId")
}
}
/**
* 检查连接状态
*/
fun isConnected(): Boolean {
return isOpen
}
}
@@ -0,0 +1,145 @@
package com.wrbug.polymarketbot.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.socket.*
import java.net.URI
import java.util.concurrent.ConcurrentHashMap
/**
* Polymarket WebSocket 处理器
* 转发前端 WebSocket 连接到 Polymarket RTDS
*/
@Component
class PolymarketWebSocketHandler(
private val objectMapper: ObjectMapper
) : WebSocketHandler {
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
@Value("\${polymarket.rtds.ws-url}")
private lateinit var polymarketWsUrl: String
// 存储客户端会话和对应的 Polymarket 连接的映射
private val clientSessions = ConcurrentHashMap<String, WebSocketSession>()
private val polymarketConnections = ConcurrentHashMap<String, PolymarketWebSocketClient>()
override fun afterConnectionEstablished(session: WebSocketSession) {
logger.info("客户端连接建立: ${session.id}")
clientSessions[session.id] = session
try {
// 创建到 Polymarket 的 WebSocket 连接
val polymarketClient = PolymarketWebSocketClient(
URI(polymarketWsUrl),
objectMapper,
session.id
) { message ->
// 当收到 Polymarket 消息时,转发给客户端
forwardToClient(session.id, message)
}
polymarketConnections[session.id] = polymarketClient
// 异步连接,不阻塞
try {
polymarketClient.connect()
logger.info("正在连接到 Polymarket RTDS: ${session.id}")
} catch (e: Exception) {
logger.error("启动 Polymarket 连接失败: ${e.message}", e)
// 连接失败时清理资源
cleanup(session.id)
try {
session.close(CloseStatus.SERVER_ERROR.withReason("无法连接到 Polymarket"))
} catch (ex: Exception) {
logger.error("关闭客户端连接失败: ${ex.message}", ex)
}
}
} catch (e: Exception) {
logger.error("创建 Polymarket 客户端失败: ${e.message}", e)
try {
session.close(CloseStatus.SERVER_ERROR.withReason("无法创建连接"))
} catch (ex: Exception) {
logger.error("关闭客户端连接失败: ${ex.message}", ex)
}
}
}
override fun handleMessage(session: WebSocketSession, message: WebSocketMessage<*>) {
logger.debug("收到客户端消息: ${session.id}, ${message.payload}")
val polymarketClient = polymarketConnections[session.id]
if (polymarketClient != null) {
if (polymarketClient.isConnected()) {
// 将客户端消息转发给 Polymarket
try {
polymarketClient.sendMessage(message.payload.toString())
} catch (e: Exception) {
logger.error("转发消息到 Polymarket 失败: ${e.message}", e)
}
} else {
logger.warn("Polymarket 连接未就绪,消息将被丢弃: ${session.id}")
}
} else {
logger.warn("Polymarket 连接不存在: ${session.id}")
}
}
override fun handleTransportError(session: WebSocketSession, exception: Throwable) {
logger.error("WebSocket 传输错误: ${session.id}, ${exception.message}", exception)
cleanup(session.id)
}
override fun afterConnectionClosed(session: WebSocketSession, closeStatus: CloseStatus) {
logger.info("客户端连接关闭: ${session.id}, 状态: $closeStatus")
cleanup(session.id)
}
override fun supportsPartialMessages(): Boolean {
return false
}
/**
* 转发消息给客户端
*/
private fun forwardToClient(sessionId: String, message: String) {
val session = clientSessions[sessionId]
if (session != null && session.isOpen) {
try {
session.sendMessage(TextMessage(message))
} catch (e: Exception) {
logger.error("转发消息给客户端失败: ${sessionId}, ${e.message}", e)
}
} else {
logger.warn("客户端会话不存在或已关闭: $sessionId")
}
}
/**
* 清理资源
*/
private fun cleanup(sessionId: String) {
try {
// 关闭 Polymarket 连接
val polymarketClient = polymarketConnections.remove(sessionId)
if (polymarketClient != null) {
try {
if (polymarketClient.isConnected()) {
polymarketClient.closeConnection()
}
} catch (e: Exception) {
logger.error("关闭 Polymarket 连接失败: ${sessionId}, ${e.message}", e)
}
}
// 移除客户端会话
clientSessions.remove(sessionId)
logger.debug("已清理资源: $sessionId")
} catch (e: Exception) {
logger.error("清理资源时发生错误: ${sessionId}, ${e.message}", e)
}
}
}
@@ -0,0 +1,45 @@
# 应用配置
spring.application.name=polymarket-bot-backend
# 数据源配置
spring.datasource.url=jdbc:mysql://localhost:3306/polymarket_bot?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
spring.datasource.username=${DB_USERNAME:root}
spring.datasource.password=${DB_PASSWORD:11111111}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# HikariCP 连接池配置
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.connection-timeout=30000
# JPA 配置
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
# Flyway 配置
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true
# 服务器配置
server.port=${SERVER_PORT:8000}
# 日志配置
logging.level.root=INFO
logging.level.com.wrbug.polymarketbot=DEBUG
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
# Polymarket API 配置
polymarket.clob.base-url=https://clob.polymarket.com
polymarket.rtds.ws-url=wss://ws-live-data.polymarket.com
polymarket.data-api.base-url=https://data-api.polymarket.com
# Ethereum RPC 配置(用于查询链上余额)
# 可选:如果未配置,将无法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓
# 示例: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}
@@ -0,0 +1,14 @@
-- 创建账户表
CREATE TABLE IF NOT EXISTS copy_trading_accounts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
private_key VARCHAR(500) NOT NULL COMMENT '私钥(加密存储)',
wallet_address VARCHAR(42) NOT NULL UNIQUE COMMENT '钱包地址(从私钥推导)',
api_key VARCHAR(500) NULL COMMENT 'Polymarket API Key(可选,加密存储)',
account_name VARCHAR(100) NULL COMMENT '账户名称',
is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户',
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
INDEX idx_wallet_address (wallet_address),
INDEX idx_is_default (is_default)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单系统账户表';
@@ -0,0 +1,5 @@
-- 添加 API Secret 和 Passphrase 字段
ALTER TABLE copy_trading_accounts
ADD COLUMN api_secret VARCHAR(500) NULL COMMENT 'Polymarket API Secret(可选,加密存储)' AFTER api_key,
ADD COLUMN api_passphrase VARCHAR(500) NULL COMMENT 'Polymarket API Passphrase(可选,加密存储)' AFTER api_secret;
@@ -0,0 +1,4 @@
-- 添加代理地址字段到账户表
ALTER TABLE copy_trading_accounts
ADD COLUMN proxy_address VARCHAR(42) NOT NULL COMMENT 'Polymarket 代理钱包地址(从合约获取,必须)' AFTER wallet_address;