feat: 实现 Builder API Key 系统级配置和 Gasless 交易支持

- 新增系统配置表(SystemConfig)用于存储 Builder API Key
- 实现 Builder API Key 的前端配置页面
- 集成 Builder Relayer API 支持 Gasless 交易
- 在 API 健康检查中添加 Builder Relayer API 检测
- 移除前端 API 测试功能,简化配置页面
- 修复赎回仓位时的 Builder API Key 检查逻辑
- 更新错误处理,引导用户配置 Builder API Key
This commit is contained in:
WrBug
2025-12-06 23:10:26 +08:00
parent 3369dbb248
commit bfbbbdd1de
27 changed files with 1971 additions and 233 deletions
+1
View File
@@ -101,4 +101,5 @@ test-results/
# Submodules and external dependencies
clob-client/
builder-relayer-client/
@@ -0,0 +1,198 @@
package com.wrbug.polymarketbot.api
import com.google.gson.annotations.SerializedName
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Query
/**
* Builder Relayer API 接口
* 用于执行 Gasless 交易
* 参考: https://docs.polymarket.com/developers/builders/relayer-client
* 参考: builder-relayer-client/src/endpoints.ts
*/
interface BuilderRelayerApi {
/**
* 提交 Safe 交易(Gasless
* POST /submit
*
* 参考: builder-relayer-client/src/client.ts 的 execute 方法
* 需要 Builder 认证头(通过拦截器添加):
* - POLY_BUILDER_SIGNATURE: HMAC 签名
* - POLY_BUILDER_TIMESTAMP: 时间戳
* - POLY_BUILDER_API_KEY: API Key
* - POLY_BUILDER_PASSPHRASE: Passphrase
*/
@POST("/submit")
suspend fun submitTransaction(
@Body request: TransactionRequest
): Response<RelayerTransactionResponse>
/**
* 获取 nonce
* GET /nonce?address={address}&type={type}
*/
@GET("/nonce")
suspend fun getNonce(
@Query("address") address: String,
@Query("type") type: String
): Response<NoncePayload>
/**
* 获取交易状态
* GET /transaction?id={transactionId}
*/
@GET("/transaction")
suspend fun getTransaction(
@Query("id") transactionId: String
): Response<List<RelayerTransaction>>
/**
* 检查 Safe 是否已部署
* GET /deployed?address={address}
*/
@GET("/deployed")
suspend fun getDeployed(
@Query("address") address: String
): Response<GetDeployedResponse>
/**
* 交易请求
* 参考: builder-relayer-client/src/types.ts 的 TransactionRequest
*/
data class TransactionRequest(
@SerializedName("type")
val type: String, // "SAFE" 或 "SAFE-CREATE"
@SerializedName("from")
val from: String, // 用户地址(EOA
@SerializedName("to")
val to: String, // 目标合约地址
@SerializedName("proxyWallet")
val proxyWallet: String, // Safe 地址(proxyAddress
@SerializedName("data")
val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
@SerializedName("nonce")
val nonce: String, // Safe nonce(字符串)
@SerializedName("signature")
val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀)
@SerializedName("signatureParams")
val signatureParams: SignatureParams, // 签名参数
@SerializedName("metadata")
val metadata: String? = null // 元数据(可选,最多 500 字符)
)
/**
* 签名参数
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
*/
data class SignatureParams(
@SerializedName("gasPrice")
val gasPrice: String? = null,
@SerializedName("operation")
val operation: String? = null, // "0" 或 "1"
@SerializedName("safeTxnGas")
val safeTxnGas: String? = null,
@SerializedName("baseGas")
val baseGas: String? = null,
@SerializedName("gasToken")
val gasToken: String? = null,
@SerializedName("refundReceiver")
val refundReceiver: String? = null
)
/**
* Relayer 交易响应
* 参考: builder-relayer-client/src/types.ts 的 RelayerTransactionResponse
*/
data class RelayerTransactionResponse(
@SerializedName("transactionID")
val transactionID: String,
@SerializedName("state")
val state: String, // STATE_NEW, STATE_EXECUTED, STATE_MINED, STATE_CONFIRMED, STATE_FAILED, STATE_INVALID
@SerializedName("transactionHash")
val transactionHash: String?,
@SerializedName("hash")
val hash: String? // 同 transactionHash
)
/**
* Nonce 响应
*/
data class NoncePayload(
@SerializedName("nonce")
val nonce: String
)
/**
* Relayer 交易详情
*/
data class RelayerTransaction(
@SerializedName("transactionID")
val transactionID: String,
@SerializedName("transactionHash")
val transactionHash: String,
@SerializedName("from")
val from: String,
@SerializedName("to")
val to: String,
@SerializedName("proxyAddress")
val proxyAddress: String,
@SerializedName("data")
val data: String,
@SerializedName("nonce")
val nonce: String,
@SerializedName("value")
val value: String,
@SerializedName("state")
val state: String,
@SerializedName("type")
val type: String,
@SerializedName("metadata")
val metadata: String,
@SerializedName("createdAt")
val createdAt: String,
@SerializedName("updatedAt")
val updatedAt: String
)
/**
* 部署状态响应
*/
data class GetDeployedResponse(
@SerializedName("deployed")
val deployed: Boolean
)
}
@@ -412,13 +412,26 @@ class AccountController(
)
)
is IllegalStateException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.BUSINESS_ERROR,
e.message,
messageSource
)
)
is IllegalStateException -> {
// 检查是否是 Builder API Key 未配置的错误
if (e.message?.contains("Builder API Key 未配置") == true) {
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.BUILDER_API_KEY_NOT_CONFIGURED,
"${e.message} 请前往系统设置页面配置:/system-settings",
messageSource
)
)
} else {
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.BUSINESS_ERROR,
e.message,
messageSource
)
)
}
}
else -> ResponseEntity.ok(
ApiResponse.error(
@@ -0,0 +1,69 @@
package com.wrbug.polymarketbot.controller
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.SystemConfigService
import com.wrbug.polymarketbot.service.RelayClientService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* 系统配置控制器
*/
@RestController
@RequestMapping("/api/system/config")
class SystemConfigController(
private val systemConfigService: SystemConfigService,
private val relayClientService: RelayClientService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(SystemConfigController::class.java)
/**
* 获取系统配置
*/
@PostMapping("/get")
fun getSystemConfig(): ResponseEntity<ApiResponse<SystemConfigDto>> {
return try {
val config = systemConfigService.getSystemConfig()
ResponseEntity.ok(ApiResponse.success(config))
} catch (e: Exception) {
logger.error("获取系统配置失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取系统配置失败: ${e.message}", messageSource))
}
}
/**
* 更新 Builder API Key 配置
*/
@PostMapping("/builder-api-key/update")
fun updateBuilderApiKey(@RequestBody request: SystemConfigUpdateRequest): ResponseEntity<ApiResponse<SystemConfigDto>> {
return try {
val result = systemConfigService.updateBuilderApiKey(request)
result.fold(
onSuccess = { config ->
ResponseEntity.ok(ApiResponse.success(config))
},
onFailure = { e ->
logger.error("更新 Builder API Key 配置失败: ${e.message}", e)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_ERROR,
"更新 Builder API Key 配置失败: ${e.message}",
messageSource
)
)
}
)
} catch (e: Exception) {
logger.error("更新 Builder API Key 配置异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "更新 Builder API Key 配置失败: ${e.message}", messageSource))
}
}
}
@@ -19,6 +19,24 @@ data class AccountUpdateRequest(
val isEnabled: Boolean? = null // 是否启用(用于订单推送等功能的开关)
)
/**
* 系统配置更新请求
*/
data class SystemConfigUpdateRequest(
val builderApiKey: String? = null, // Builder API Key(前端加密后传输)
val builderSecret: String? = null, // Builder Secret(前端加密后传输)
val builderPassphrase: String? = null // Builder Passphrase(前端加密后传输)
)
/**
* 系统配置响应
*/
data class SystemConfigDto(
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
val builderPassphraseConfigured: Boolean // Builder Passphrase 是否已配置
)
/**
* 账户删除请求
*/
@@ -0,0 +1,31 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* 系统配置实体
* 用于存储系统级别的配置(如 Builder API Key
*/
@Entity
@Table(name = "system_config")
data class SystemConfig(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "config_key", unique = true, nullable = false, length = 100)
val configKey: String, // 配置键(唯一)
@Column(name = "config_value", columnDefinition = "TEXT")
val configValue: String? = null, // 配置值(加密存储)
@Column(name = "description", length = 255)
val description: String? = null, // 配置描述
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -81,6 +81,7 @@ enum class ErrorCode(
AUTH_RESET_PASSWORD_RATE_LIMIT(2011, "频率限制:1分钟内最多尝试3次,请稍后再试", "error.auth.reset_password_rate_limit"),
AUTH_USER_NOT_FOUND(2012, "用户不存在", "error.auth.user_not_found"),
AUTH_PASSWORD_WEAK(2013, "密码长度不符合要求,至少6位", "error.auth.password_weak"),
BUILDER_API_KEY_NOT_CONFIGURED(2014, "Builder API Key 未配置", "error.builder_api_key_not_configured"),
// ==================== 资源不存在 (3001-3999) ====================
NOT_FOUND(3001, "资源不存在", "error.not_found"),
@@ -0,0 +1,11 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.SystemConfig
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface SystemConfigRepository : JpaRepository<SystemConfig, Long> {
fun findByConfigKey(configKey: String): SystemConfig?
}
@@ -28,7 +28,8 @@ class AccountService(
private val orderPushService: OrderPushService,
private val orderSigningService: OrderSigningService,
private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils,
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
private val telegramNotificationService: TelegramNotificationService? = null, // 可选,避免循环依赖
private val relayClientService: RelayClientService
) {
private val logger = LoggerFactory.getLogger(AccountService::class.java)
@@ -1224,6 +1225,13 @@ class AccountService(
*/
suspend fun redeemPositions(request: com.wrbug.polymarketbot.dto.PositionRedeemRequest): Result<com.wrbug.polymarketbot.dto.PositionRedeemResponse> {
return try {
// 检查 Builder API Key 是否已配置
if (!relayClientService.isBuilderApiKeyConfigured()) {
return Result.failure(
IllegalStateException("Builder API Key 未配置,无法执行 Gasless 交易。请前往系统设置页面配置 Builder API Key。")
)
}
if (request.positions.isEmpty()) {
return Result.failure(IllegalArgumentException("赎回仓位列表不能为空"))
}
@@ -29,7 +29,9 @@ class ApiHealthCheckService(
@Value("\${polygon.rpc.url:}")
private val polygonRpcUrl: String,
@Value("\${polymarket.rtds.ws-url}")
private val polymarketWsUrl: String
private val polymarketWsUrl: String,
@Value("\${polymarket.builder.relayer-url:}")
private val builderRelayerUrl: String
) : ApplicationContextAware {
private var applicationContext: ApplicationContext? = null
@@ -59,6 +61,17 @@ class ApiHealthCheckService(
null
}
}
/**
* 获取 RelayClientService(通过 ApplicationContext 避免循环依赖)
*/
private fun getRelayClientService(): RelayClientService? {
return try {
applicationContext?.getBean(RelayClientService::class.java)
} catch (e: BeansException) {
null
}
}
private val logger = LoggerFactory.getLogger(ApiHealthCheckService::class.java)
@@ -70,12 +83,13 @@ class ApiHealthCheckService(
// 并行检查所有 API
coroutineScope {
val jobs = listOf(
val jobs = mutableListOf<Deferred<ApiHealthCheckDto>>(
async { checkClobApi() },
async { checkDataApi() },
async { checkGammaApi() },
async { checkPolygonRpc() },
async { checkPolymarketWebSocket() }
async { checkPolymarketWebSocket() },
async { checkBuilderRelayerApi() }
)
jobs.awaitAll().forEach { result ->
@@ -374,5 +388,70 @@ class ApiHealthCheckService(
)
}
}
/**
* 检查 Builder Relayer API
*/
private suspend fun checkBuilderRelayerApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
val relayClientService = getRelayClientService()
if (builderRelayerUrl.isBlank()) {
return@withContext ApiHealthCheckDto(
name = "Builder Relayer API",
url = "未配置",
status = "skipped",
message = "未配置 Builder Relayer URL"
)
}
if (relayClientService == null) {
return@withContext ApiHealthCheckDto(
name = "Builder Relayer API",
url = builderRelayerUrl,
status = "error",
message = "服务未初始化"
)
}
if (!relayClientService.isBuilderApiKeyConfigured()) {
return@withContext ApiHealthCheckDto(
name = "Builder Relayer API",
url = builderRelayerUrl,
status = "skipped",
message = "Builder API Key 未配置"
)
}
return@withContext try {
val result = relayClientService.checkBuilderRelayerApiHealth()
result.fold(
onSuccess = { responseTime ->
ApiHealthCheckDto(
name = "Builder Relayer API",
url = builderRelayerUrl,
status = "success",
message = "连接成功",
responseTime = responseTime
)
},
onFailure = { e ->
ApiHealthCheckDto(
name = "Builder Relayer API",
url = builderRelayerUrl,
status = "error",
message = e.message ?: "连接失败"
)
}
)
} catch (e: Exception) {
logger.warn("检查 Builder Relayer API 失败", e)
ApiHealthCheckDto(
name = "Builder Relayer API",
url = builderRelayerUrl,
status = "error",
message = e.message ?: "连接失败"
)
}
}
}
@@ -27,7 +27,8 @@ class BlockchainService(
private val dataApiBaseUrl: String,
@Value("\${polygon.rpc.url:}")
private val polygonRpcUrl: String,
private val retrofitFactory: RetrofitFactory
private val retrofitFactory: RetrofitFactory,
private val relayClientService: RelayClientService
) {
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
@@ -406,12 +407,7 @@ class BlockchainService(
* 赎回仓位
* 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数
*
* 重要说明(基于实际链上交易分析):
* - 仓位在 proxyAddress 上,需要通过代理钱包的 execTransaction 执行赎回
* - 交易流程:EOA → Proxy.execTransaction → ConditionalTokens.redeemPositions
* - execTransaction 是 Gnosis Safe 标准函数,需要构建 Safe 交易并签名
*
* 参考交易: https://polygonscan.com/tx/0xb3b4cbab668c4c764aa2e7ff6f17f56f372921fc4852ef616ce65238d73eca4e
* 使用 RelayClientService 实现,完全参考 TypeScript 项目的实现方式
*
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
* @param proxyAddress 代理地址(Gnosis Safe 代理钱包地址)
@@ -426,14 +422,6 @@ class BlockchainService(
indexSets: List<BigInteger>
): Result<String> {
return try {
// 如果未配置 RPC URL,返回错误
if (polygonRpcUrl.isBlank()) {
logger.warn("未配置 Polygon RPC URL,无法赎回仓位")
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法赎回仓位"))
}
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
// 验证参数
if (indexSets.isEmpty()) {
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
@@ -447,204 +435,9 @@ class BlockchainService(
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 从私钥推导实际签名地址(交易真正的 from 地址)
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
val fromAddress = credentials.address
// 1. 构建 ConditionalTokens.redeemPositions 的调用数据
val redeemFunctionSelector = EthereumUtils.getFunctionSelector("redeemPositions(address,bytes32,bytes32,uint256[])")
// 编码 redeemPositions 参数
val encodedCollateral = EthereumUtils.encodeAddress(usdcContractAddress)
val encodedParentCollection = EthereumUtils.encodeBytes32(EMPTY_SET) // parentCollectionId 通常为全0
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
// 编码数组:offset (32字节) + length (32字节) + 每个元素 (32字节)
val arrayOffset = BigInteger.valueOf(128)
val arrayLength = BigInteger.valueOf(indexSets.size.toLong())
val encodedArrayOffset = EthereumUtils.encodeUint256(arrayOffset)
val encodedArrayLength = EthereumUtils.encodeUint256(arrayLength)
val encodedArrayElements = indexSets.joinToString("") { EthereumUtils.encodeUint256(it) }
// ConditionalTokens.redeemPositions 的调用数据
val redeemCallData = redeemFunctionSelector +
encodedCollateral +
encodedParentCollection +
encodedConditionId +
encodedArrayOffset +
encodedArrayLength +
encodedArrayElements
// 2. 构建 Proxy.execTransaction 的调用数据
// 函数签名: execTransaction(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,bytes signatures)
// 根据实际交易分析,参数如下:
// - to: ConditionalTokens 合约地址
// - value: 0
// - data: redeemPositions 的调用数据
// - operation: 0 (CALL)
// - safeTxGas: 0 (使用所有可用 gas)
// - baseGas: 0
// - gasPrice: 0 (使用当前 gas price)
// - gasToken: 0x0000...0000 (使用原生代币)
// - refundReceiver: 0x0000...0000
// - signatures: EIP-712 签名的 Safe 交易
// 获取 Proxy 的 nonce(用于构建 Safe 交易哈希)
val proxyNonceResult = getProxyNonce(proxyAddress)
val proxyNonce = proxyNonceResult.getOrElse {
logger.warn("获取 Proxy nonce 失败,使用 0: ${it.message}")
BigInteger.ZERO
}
// 构建 Safe 交易哈希(用于 EIP-712 签名)
// 使用 Gnosis Safe 的 EIP-712 签名标准
val safeTxGas = BigInteger.ZERO
val baseGas = BigInteger.ZERO
val safeGasPrice = BigInteger.ZERO // 使用不同的变量名避免冲突
val gasToken = "0x0000000000000000000000000000000000000000"
val refundReceiver = "0x0000000000000000000000000000000000000000"
// 1. 编码 Safe 域分隔符
val safeDomainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeSafeDomain(
verifyingContract = proxyAddress
)
// 2. 编码 SafeTx 消息哈希
val safeTxHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeSafeTx(
to = conditionalTokensAddress,
value = BigInteger.ZERO,
data = redeemCallData,
operation = 0, // CALL
safeTxGas = safeTxGas,
baseGas = baseGas,
gasPrice = safeGasPrice,
gasToken = gasToken,
refundReceiver = refundReceiver,
nonce = proxyNonce
)
// 3. 计算完整的结构化数据哈希
val safeTxStructuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
domainSeparator = safeDomainSeparator,
messageHash = safeTxHash
)
// 4. 使用私钥签名 Safe 交易
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val safeSignature = org.web3j.crypto.Sign.signMessage(safeTxStructuredHash, ecKeyPair, false)
// 5. 编码签名数据(Gnosis Safe 签名格式:r + s + v,每个 32 字节,共 96 字节)
// Safe 签名格式:r (32 bytes) + s (32 bytes) + v (1 byte,但需要编码为 32 字节)
val vBytes = safeSignature.v as ByteArray
val vInt = if (vBytes.isNotEmpty()) {
vBytes[0].toInt() and 0xff
} else {
0
}
// 编码为十六进制字符串(每个字段 32 字节 = 64 个十六进制字符)
val rHex = org.web3j.utils.Numeric.toHexString(safeSignature.r).removePrefix("0x").padStart(64, '0')
val sHex = org.web3j.utils.Numeric.toHexString(safeSignature.s).removePrefix("0x").padStart(64, '0')
val vHex = String.format("%064x", vInt)
// Safe 签名格式:r + s + v(每个 32 字节,共 96 字节 = 192 个十六进制字符)
val safeSignatureHex = rHex + sHex + vHex
// 3. 构建 execTransaction 的调用数据
// 函数签名: execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)
val execFunctionSelector = EthereumUtils.getFunctionSelector("execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)")
// 编码 execTransaction 参数
val encodedTo = EthereumUtils.encodeAddress(conditionalTokensAddress)
val encodedValue = EthereumUtils.encodeUint256(BigInteger.ZERO)
// data 参数(bytes 类型):offset (32字节) + length (32字节) + data (按长度,不足32字节的倍数需要padding)
// 固定参数部分:to(32) + value(32) + data_offset(32) + operation(32) + safeTxGas(32) + baseGas(32) + gasPrice(32) + gasToken(32) + refundReceiver(32) + signatures_offset(32) = 320字节
val dataOffset = BigInteger.valueOf(320L)
val redeemCallDataHex = redeemCallData.removePrefix("0x")
val dataLengthBytes = BigInteger.valueOf((redeemCallDataHex.length / 2).toLong()) // 字节数
val encodedDataOffset = EthereumUtils.encodeUint256(dataOffset)
val encodedDataLength = EthereumUtils.encodeUint256(dataLengthBytes)
// data 需要按 32 字节对齐(不足的补0)
val dataPaddedLength = ((dataLengthBytes.toInt() + 31) / 32) * 32 * 2 // 十六进制字符数
val encodedData = redeemCallDataHex.padEnd(dataPaddedLength, '0')
val encodedOperation = EthereumUtils.encodeUint256(BigInteger.ZERO) // 0 = CALL
val encodedSafeTxGas = EthereumUtils.encodeUint256(BigInteger.ZERO)
val encodedBaseGas = EthereumUtils.encodeUint256(BigInteger.ZERO)
val encodedGasPrice = EthereumUtils.encodeUint256(BigInteger.ZERO)
val encodedGasToken = EthereumUtils.encodeAddress("0x0000000000000000000000000000000000000000")
val encodedRefundReceiver = EthereumUtils.encodeAddress("0x0000000000000000000000000000000000000000")
// signatures 参数(bytes 类型):offset + length + signatures
// signatures 的 offset = dataOffset + dataLength 的 32字节对齐后的位置
val dataPaddedBytes = dataPaddedLength / 2
val signaturesOffset = BigInteger.valueOf((320 + dataPaddedBytes).toLong())
// Safe 签名长度:r (32) + s (32) + v (32) = 96 字节
val signaturesLength = BigInteger.valueOf(96L)
val encodedSignaturesOffset = EthereumUtils.encodeUint256(signaturesOffset)
val encodedSignaturesLength = EthereumUtils.encodeUint256(signaturesLength)
// signatures 需要按 32 字节对齐(96 字节已经是 32 的倍数,不需要 padding
val encodedSignatures = safeSignatureHex
// 组合 execTransaction 调用数据
val execCallData = "0x" + execFunctionSelector.removePrefix("0x") +
encodedTo +
encodedValue +
encodedDataOffset +
encodedDataLength +
encodedData +
encodedOperation +
encodedSafeTxGas +
encodedBaseGas +
encodedGasPrice +
encodedGasToken +
encodedRefundReceiver +
encodedSignaturesOffset +
encodedSignaturesLength +
encodedSignatures
// 4. 获取 EOA 的 nonce(用于发送交易)
val nonceResult = getTransactionCount(fromAddress)
val nonce = nonceResult.getOrElse {
return Result.failure(Exception("获取 nonce 失败: ${it.message}"))
}
// 5. 获取 gas price
val gasPriceResult = getGasPrice()
val gasPrice = gasPriceResult.getOrElse {
return Result.failure(Exception("获取 gas price 失败: ${it.message}"))
}
// 6. Gas limit(通过 Proxy 执行需要更多 gas,给 240 万,参考实际交易)
val gasLimit = BigInteger.valueOf(2400000)
// 7. 构建并签名交易
// 调用 Proxy 的 execTransaction
val transaction = buildTransaction(
privateKey = privateKey,
from = fromAddress,
to = proxyAddress, // 调用代理钱包
data = execCallData,
nonce = nonce,
gasLimit = gasLimit,
gasPrice = gasPrice
)
// 8. 发送交易
val txHashResult = sendTransaction(rpcApi, transaction)
txHashResult.fold(
onSuccess = { txHash ->
Result.success(txHash)
},
onFailure = { e ->
logger.error("发送赎回交易失败: ${e.message}", e)
Result.failure(e)
}
)
// 使用 RelayClientService 创建赎回交易并执行
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
relayClientService.execute(privateKey, proxyAddress, redeemTx)
} catch (e: Exception) {
logger.error("赎回仓位失败: ${e.message}", e)
Result.failure(e)
@@ -0,0 +1,813 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.JsonRpcRequest
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 java.math.BigInteger
/**
* RelayClient 服务
* 参考 TypeScript 项目的实现方式,提供 Gasless 交易支持
*
* 注意:当前实现使用手动构建交易的方式(需要支付 gas)
* 如果需要真正的 Gasless 功能,需要集成 Builder Relayer API
*
* 参考:
* - TypeScript: @polymarket/builder-relayer-client
* - TypeScript: utils/redeem.ts
*/
@Service
class RelayClientService(
@Value("\${polygon.rpc.url:}")
private val polygonRpcUrl: String,
@Value("\${polymarket.builder.relayer-url:}")
private val builderRelayerUrl: String,
private val retrofitFactory: RetrofitFactory,
private val systemConfigService: SystemConfigService
) {
private val logger = LoggerFactory.getLogger(RelayClientService::class.java)
// ConditionalTokens 合约地址
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
// USDC.e 合约地址
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// 空集合ID
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
private val polygonRpcApi: EthereumRpcApi? by lazy {
if (polygonRpcUrl.isBlank()) {
null
} else {
retrofitFactory.createEthereumRpcApi(polygonRpcUrl)
}
}
/**
* 获取 Builder Relayer API 客户端(动态获取,因为配置可能更新)
*/
private fun getBuilderRelayerApi(): BuilderRelayerApi? {
val builderApiKey = systemConfigService.getBuilderApiKey()
val builderSecret = systemConfigService.getBuilderSecret()
val builderPassphrase = systemConfigService.getBuilderPassphrase()
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
return retrofitFactory.createBuilderRelayerApi(
relayerUrl = builderRelayerUrl,
apiKey = builderApiKey!!,
secret = builderSecret!!,
passphrase = builderPassphrase!!
)
}
return null
}
/**
* 检查是否启用了 Builder RelayerGasless 交易)
*/
private fun isBuilderRelayerEnabled(
builderApiKey: String?,
builderSecret: String?,
builderPassphrase: String?
): Boolean {
return builderRelayerUrl.isNotBlank() &&
builderApiKey != null && builderApiKey.isNotBlank() &&
builderSecret != null && builderSecret.isNotBlank() &&
builderPassphrase != null && builderPassphrase.isNotBlank()
}
/**
* 检查 Builder API Key 是否已配置
*/
fun isBuilderApiKeyConfigured(): Boolean {
return systemConfigService.isBuilderApiKeyConfigured()
}
/**
* 检查 Builder Relayer API 健康状态(用于 API 健康检查)
*/
suspend fun checkBuilderRelayerApiHealth(): Result<Long> {
return try {
val builderApiKey = systemConfigService.getBuilderApiKey()
val builderSecret = systemConfigService.getBuilderSecret()
val builderPassphrase = systemConfigService.getBuilderPassphrase()
if (builderApiKey == null || builderSecret == null || builderPassphrase == null) {
return Result.failure(IllegalStateException("Builder API Key 未配置"))
}
val relayerApi = retrofitFactory.createBuilderRelayerApi(
relayerUrl = builderRelayerUrl,
apiKey = builderApiKey,
secret = builderSecret,
passphrase = builderPassphrase
)
// 使用一个测试地址来检查 API 是否可用(使用一个已知的地址,如零地址)
val testAddress = "0x0000000000000000000000000000000000000000"
val startTime = System.currentTimeMillis()
val response = relayerApi.getDeployed(testAddress)
val responseTime = System.currentTimeMillis() - startTime
if (response.isSuccessful) {
Result.success(responseTime)
} else {
val errorBody = response.errorBody()?.string() ?: "未知错误"
Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
}
} catch (e: Exception) {
logger.error("检查 Builder Relayer API 健康状态失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 赎回仓位参数
*/
data class RedeemParams(
val conditionId: String, // 市场条件ID
val outcomeIndex: Int // 结果索引(0, 1, 2...
)
/**
* Safe 交易结构
* 参考 TypeScript: @polymarket/builder-relayer-client 的 SafeTransaction
*/
data class SafeTransaction(
val to: String, // 目标合约地址
val operation: Int = 0, // 0 = CALL, 1 = DELEGATE_CALL
val data: String, // 调用数据
val value: String = "0" // 发送的 ETH 数量
)
/**
* 创建赎回交易(单个 outcomeIndex
* 参考 TypeScript: utils/redeem.ts 的 createRedeemTx
*
* @param params 赎回参数
* @return Safe 交易对象
*/
fun createRedeemTx(params: RedeemParams): SafeTransaction {
val (conditionId, outcomeIndex) = params
// 计算 indexSet = 2^outcomeIndex
val indexSet = BigInteger.TWO.pow(outcomeIndex)
return createRedeemTx(conditionId, listOf(indexSet))
}
/**
* 创建赎回交易(支持多个 indexSets,用于批量赎回)
* 参考 TypeScript: utils/redeem.ts 的 createRedeemTx
*
* @param conditionId 市场条件ID
* @param indexSets 索引集合列表(每个元素是 2^outcomeIndex
* @return Safe 交易对象
*/
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>): SafeTransaction {
// 编码 redeemPositions 函数调用
val functionSelector = EthereumUtils.getFunctionSelector(
"redeemPositions(address,bytes32,bytes32,uint256[])"
)
// 编码参数
val encodedCollateral = EthereumUtils.encodeAddress(usdcContractAddress)
val encodedParentCollection = EthereumUtils.encodeBytes32(EMPTY_SET)
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
// 编码数组:offset (32字节) + length (32字节) + 每个元素 (32字节)
val arrayOffset = BigInteger.valueOf(128)
val arrayLength = BigInteger.valueOf(indexSets.size.toLong())
val encodedArrayOffset = EthereumUtils.encodeUint256(arrayOffset)
val encodedArrayLength = EthereumUtils.encodeUint256(arrayLength)
val encodedArrayElements = indexSets.joinToString("") { EthereumUtils.encodeUint256(it) }
// 组合调用数据
val callData = "0x" + functionSelector.removePrefix("0x") +
encodedCollateral +
encodedParentCollection +
encodedConditionId +
encodedArrayOffset +
encodedArrayLength +
encodedArrayElements
return SafeTransaction(
to = conditionalTokensAddress,
operation = 0, // CALL
data = callData,
value = "0"
)
}
/**
* 执行 Safe 交易(通过 Proxy.execTransaction
* 参考 TypeScript: RelayClient.execute()
*
* 优先使用 Builder RelayerGasless),如果未配置则回退到手动发送交易
*
* @param privateKey 私钥
* @param proxyAddress 代理钱包地址
* @param safeTx Safe 交易对象
* @return 交易哈希
*/
suspend fun execute(
privateKey: String,
proxyAddress: String,
safeTx: SafeTransaction
): Result<String> {
return try {
// 验证参数
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 检查 Builder API Key 是否已配置
val builderApiKey = systemConfigService.getBuilderApiKey()
val builderSecret = systemConfigService.getBuilderSecret()
val builderPassphrase = systemConfigService.getBuilderPassphrase()
// 优先使用 Builder RelayerGasless
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
logger.info("使用 Builder Relayer 执行 Gasless 交易")
return executeViaBuilderRelayer(privateKey, proxyAddress, safeTx, builderApiKey!!, builderSecret!!, builderPassphrase!!)
}
// 回退到手动发送交易(需要用户支付 gas)
logger.info("Builder Relayer 未配置,使用手动发送交易(需要用户支付 gas)")
return executeManually(privateKey, proxyAddress, safeTx)
} catch (e: Exception) {
logger.error("执行 Safe 交易失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 通过 Builder Relayer 执行交易(Gasless
* 参考: builder-relayer-client/src/client.ts 的 execute 方法
*/
private suspend fun executeViaBuilderRelayer(
privateKey: String,
proxyAddress: String,
safeTx: SafeTransaction,
builderApiKey: String,
builderSecret: String,
builderPassphrase: String
): Result<String> {
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
val relayerApi = retrofitFactory.createBuilderRelayerApi(
relayerUrl = builderRelayerUrl,
apiKey = builderApiKey,
secret = builderSecret,
passphrase = builderPassphrase
)
// 从私钥推导实际签名地址(EOA)
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
val fromAddress = credentials.address
// safeTx.data 已经是带 0x 前缀的完整调用数据
val redeemCallData = safeTx.data
// 获取 Proxy 的 nonce(通过 Builder Relayer API
val nonceResponse = relayerApi.getNonce(fromAddress, "SAFE")
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
return Result.failure(Exception("获取 nonce 失败: ${nonceResponse.code()} - $errorBody"))
}
val proxyNonce = BigInteger(nonceResponse.body()!!.nonce)
// 构建 Safe 交易哈希并签名
// 注意:encodeSafeTx 需要 data 带 0x 前缀
val safeTxGas = BigInteger.ZERO
val baseGas = BigInteger.ZERO
val safeGasPrice = BigInteger.ZERO
val gasToken = "0x0000000000000000000000000000000000000000"
val refundReceiver = "0x0000000000000000000000000000000000000000"
val safeDomainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeSafeDomain(
chainId = 137L, // Polygon 主网
verifyingContract = proxyAddress
)
val safeTxHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeSafeTx(
to = safeTx.to,
value = BigInteger.ZERO,
data = redeemCallData, // 带 0x 前缀
operation = safeTx.operation,
safeTxGas = safeTxGas,
baseGas = baseGas,
gasPrice = safeGasPrice,
gasToken = gasToken,
refundReceiver = refundReceiver,
nonce = proxyNonce
)
val safeTxStructuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
domainSeparator = safeDomainSeparator,
messageHash = safeTxHash
)
// 注意:ethers.js 的 signMessage 会添加 EIP-191 前缀
// 格式:\x19Ethereum Signed Message:\n<length><message>
// 我们需要模拟这个行为以匹配 TypeScript 实现
val prefix = "\u0019Ethereum Signed Message:\n${safeTxStructuredHash.size}".toByteArray(Charsets.UTF_8)
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
// 对带前缀的消息进行 keccak256 哈希
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
val hashWithPrefix = ByteArray(keccak256.digestSize)
keccak256.doFinal(hashWithPrefix, 0)
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig
val packedSignature = splitAndPackSig(safeSignature)
// 调试日志
logger.debug("=== Builder Relayer 签名调试 ===")
logger.debug("Safe Address: $proxyAddress")
logger.debug("From Address: $fromAddress")
logger.debug("To: ${safeTx.to}")
logger.debug("Data: $redeemCallData")
logger.debug("Nonce: $proxyNonce")
logger.debug("Packed Signature: $packedSignature")
logger.debug("Signature Length: ${packedSignature.length} (expected: 132 with 0x)")
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
val request = BuilderRelayerApi.TransactionRequest(
type = "SAFE",
from = fromAddress,
to = safeTx.to,
proxyWallet = proxyAddress,
data = redeemCallData, // 带 0x 前缀
nonce = proxyNonce.toString(),
signature = packedSignature, // 带 0x 前缀
signatureParams = BuilderRelayerApi.SignatureParams(
gasPrice = "0",
operation = safeTx.operation.toString(),
safeTxnGas = "0",
baseGas = "0",
gasToken = gasToken,
refundReceiver = refundReceiver
),
metadata = "Redeem positions via Builder Relayer"
)
logger.debug("Request Type: ${request.type}")
logger.debug("Request From: ${request.from}")
logger.debug("Request To: ${request.to}")
logger.debug("Request ProxyWallet: ${request.proxyWallet}")
logger.debug("Request Data Length: ${request.data.length}")
logger.debug("Request Signature Length: ${request.signature.length}")
logger.debug("Request Nonce: ${request.nonce}")
// 调用 Builder Relayer API(认证头通过拦截器添加)
val response = relayerApi.submitTransaction(request)
if (!response.isSuccessful || response.body() == null) {
val errorBody = response.errorBody()?.string() ?: "未知错误"
logger.error("Builder Relayer API 调用失败: code=${response.code()}, body=$errorBody")
return Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
}
val relayerResponse = response.body()!!
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
logger.info("Builder Relayer 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
return Result.success(txHash)
}
/**
* 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig
* 将签名打包成 Gnosis Safe 接受的格式:encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
*
* TypeScript 实现流程:
* 1. 从签名字符串中提取 v(最后 2 个字符)
* 2. 调整 v 值(0,1 -> +31; 27,28 -> +4
* 3. 修改签名字符串(替换最后 2 个字符)
* 4. 从修改后的签名字符串中提取 r, s, v(作为十进制字符串)
* 5. 使用 encodePacked 打包:uint256(BigInt(r)) + uint256(BigInt(s)) + uint8(parseInt(v))
*
* 关键:encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
*/
private fun splitAndPackSig(signature: org.web3j.crypto.Sign.SignatureData): String {
// 1. 先将 SignatureData 转换为签名字符串(r + s + v)
val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0')
val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0')
val vBytes = signature.v as ByteArray
val originalV = if (vBytes.isNotEmpty()) {
vBytes[0].toInt() and 0xff
} else {
throw IllegalArgumentException("Signature v is empty")
}
val originalVHex = String.format("%02x", originalV)
val sigString = "0x$rHex$sHex$originalVHex" // 130 个十六进制字符(65 字节)
// 2. 从签名字符串中提取 v(最后 2 个字符,作为十六进制)
val sigV = sigString.substring(sigString.length - 2).toInt(16)
// 3. 调整 v 值(参考 TypeScript 实现)
val adjustedV = when (sigV) {
0, 1 -> sigV + 31
27, 28 -> sigV + 4
else -> throw IllegalArgumentException("Invalid signature v value: $sigV")
}
// 4. 修改签名字符串(替换最后 2 个字符)
val modifiedSigString = sigString.substring(0, sigString.length - 2) + String.format("%02x", adjustedV)
// 5. 从修改后的签名字符串中提取 r, s, v(作为十六进制字符串)
// modifiedSigString 格式:0x + r(64) + s(64) + v(2) = 132 个字符
val rHexStr = modifiedSigString.substring(2, 66) // 64 个字符(十六进制)
val sHexStr = modifiedSigString.substring(66, 130) // 64 个字符(十六进制)
val vHexStr = modifiedSigString.substring(130, 132) // 2 个字符(十六进制)
// 6. 转换为 BigInteger 和 Int(模拟 TypeScript 的 BigInt 和 parseInt
val rBigInt = BigInteger(rHexStr, 16)
val sBigInt = BigInteger(sHexStr, 16)
val vInt = vHexStr.toInt(16)
// 7. 使用 encodePacked 打包:uint256(r) + uint256(s) + uint8(v)
// encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
val rEncoded = EthereumUtils.encodeUint256(rBigInt) // 64 个十六进制字符
val sEncoded = EthereumUtils.encodeUint256(sBigInt) // 64 个十六进制字符
val vEncoded = String.format("%02x", vInt) // 2 个十六进制字符
return "0x$rEncoded$sEncoded$vEncoded"
}
/**
* 手动执行交易(需要用户支付 gas)
*/
private suspend fun executeManually(
privateKey: String,
proxyAddress: String,
safeTx: SafeTransaction
): Result<String> {
return try {
// 如果未配置 RPC URL,返回错误
if (polygonRpcUrl.isBlank()) {
logger.warn("未配置 Polygon RPC URL,无法执行交易")
return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法执行交易。请配置 polygon.rpc.url 或启用 Builder RelayerGasless"))
}
val rpcApi = polygonRpcApi ?: throw IllegalStateException("Polygon RPC URL 未配置")
// 从私钥推导实际签名地址(交易真正的 from 地址)
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
val fromAddress = credentials.address
val redeemCallData = safeTx.data.removePrefix("0x") // 移除 0x 前缀,后续编码需要
// 获取 Proxy 的 nonce(用于构建 Safe 交易哈希)
val proxyNonceResult = getProxyNonce(proxyAddress, rpcApi)
val proxyNonce = proxyNonceResult.getOrElse {
logger.warn("获取 Proxy nonce 失败,使用 0: ${it.message}")
BigInteger.ZERO
}
// 构建 Safe 交易哈希(用于 EIP-712 签名)
val safeTxGas = BigInteger.ZERO
val baseGas = BigInteger.ZERO
val safeGasPrice = BigInteger.ZERO
val gasToken = "0x0000000000000000000000000000000000000000"
val refundReceiver = "0x0000000000000000000000000000000000000000"
// 1. 编码 Safe 域分隔符
val safeDomainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeSafeDomain(
chainId = 137L, // Polygon 主网
verifyingContract = proxyAddress
)
// 2. 编码 SafeTx 消息哈希
val safeTxHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeSafeTx(
to = safeTx.to,
value = BigInteger.ZERO,
data = redeemCallData,
operation = safeTx.operation,
safeTxGas = safeTxGas,
baseGas = baseGas,
gasPrice = safeGasPrice,
gasToken = gasToken,
refundReceiver = refundReceiver,
nonce = proxyNonce
)
// 3. 计算完整的结构化数据哈希
val safeTxStructuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
domainSeparator = safeDomainSeparator,
messageHash = safeTxHash
)
// 4. 使用私钥签名 Safe 交易
// 注意:ethers.js 的 signMessage 会添加 EIP-191 前缀
// 格式:\x19Ethereum Signed Message:\n<length><message>
// 我们需要模拟这个行为以匹配 TypeScript 实现
val prefix = "\u0019Ethereum Signed Message:\n${safeTxStructuredHash.size}".toByteArray(Charsets.UTF_8)
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
// 对带前缀的消息进行 keccak256 哈希
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
val hashWithPrefix = ByteArray(keccak256.digestSize)
keccak256.doFinal(hashWithPrefix, 0)
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
// 5. 编码签名数据(Gnosis Safe 签名格式:r + s + v,每个 32 字节,共 96 字节)
val vBytes = safeSignature.v as ByteArray
val vInt = if (vBytes.isNotEmpty()) {
vBytes[0].toInt() and 0xff
} else {
0
}
val rHex = org.web3j.utils.Numeric.toHexString(safeSignature.r).removePrefix("0x").padStart(64, '0')
val sHex = org.web3j.utils.Numeric.toHexString(safeSignature.s).removePrefix("0x").padStart(64, '0')
val vHex = String.format("%064x", vInt)
val safeSignatureHex = rHex + sHex + vHex
// 6. 构建 execTransaction 调用数据
val execCallData = buildExecTransactionCallData(safeTx, redeemCallData, safeSignatureHex)
// 7. 获取 EOA 的 nonce(用于发送交易)
val nonceResult = getTransactionCount(fromAddress, rpcApi)
val nonce = nonceResult.getOrElse {
return Result.failure(Exception("获取 nonce 失败: ${it.message}"))
}
// 8. 获取 gas price
val gasPriceResult = getGasPrice(rpcApi)
val gasPrice = gasPriceResult.getOrElse {
return Result.failure(Exception("获取 gas price 失败: ${it.message}"))
}
// 9. Gas limit(通过 Proxy 执行需要更多 gas,给 240 万,参考实际交易)
val gasLimit = BigInteger.valueOf(2400000)
// 10. 构建并签名交易
val transaction = buildTransaction(
privateKey = privateKey,
from = fromAddress,
to = proxyAddress,
data = execCallData,
nonce = nonce,
gasLimit = gasLimit,
gasPrice = gasPrice
)
// 11. 发送交易
sendTransaction(rpcApi, transaction)
} catch (e: Exception) {
logger.error("手动执行 Safe 交易失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 构建 execTransaction 调用数据
*/
private fun buildExecTransactionCallData(
safeTx: SafeTransaction,
redeemCallData: String,
safeSignatureHex: String
): String {
val execFunctionSelector = EthereumUtils.getFunctionSelector("execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)")
val encodedTo = EthereumUtils.encodeAddress(safeTx.to)
val encodedValue = EthereumUtils.encodeUint256(BigInteger.ZERO)
val dataOffset = BigInteger.valueOf(320L)
val redeemCallDataHex = redeemCallData.removePrefix("0x")
val dataLengthBytes = BigInteger.valueOf((redeemCallDataHex.length / 2).toLong())
val encodedDataOffset = EthereumUtils.encodeUint256(dataOffset)
val encodedDataLength = EthereumUtils.encodeUint256(dataLengthBytes)
val dataPaddedLength = ((dataLengthBytes.toInt() + 31) / 32) * 32 * 2
val encodedData = redeemCallDataHex.padEnd(dataPaddedLength, '0')
val encodedOperation = EthereumUtils.encodeUint256(BigInteger.valueOf(safeTx.operation.toLong()))
val encodedSafeTxGas = EthereumUtils.encodeUint256(BigInteger.ZERO)
val encodedBaseGas = EthereumUtils.encodeUint256(BigInteger.ZERO)
val encodedGasPrice = EthereumUtils.encodeUint256(BigInteger.ZERO)
val encodedGasToken = EthereumUtils.encodeAddress("0x0000000000000000000000000000000000000000")
val encodedRefundReceiver = EthereumUtils.encodeAddress("0x0000000000000000000000000000000000000000")
val dataPaddedBytes = dataPaddedLength / 2
val signaturesOffset = BigInteger.valueOf((320 + dataPaddedBytes).toLong())
val signaturesLength = BigInteger.valueOf(96L)
val encodedSignaturesOffset = EthereumUtils.encodeUint256(signaturesOffset)
val encodedSignaturesLength = EthereumUtils.encodeUint256(signaturesLength)
val encodedSignatures = safeSignatureHex
return "0x" + execFunctionSelector.removePrefix("0x") +
encodedTo +
encodedValue +
encodedDataOffset +
encodedDataLength +
encodedData +
encodedOperation +
encodedSafeTxGas +
encodedBaseGas +
encodedGasPrice +
encodedGasToken +
encodedRefundReceiver +
encodedSignaturesOffset +
encodedSignaturesLength +
encodedSignatures
}
/**
* 获取代理钱包的 nonce(用于构建 Safe 交易)
*/
private suspend fun getProxyNonce(proxyAddress: String, rpcApi: EthereumRpcApi): Result<BigInteger> {
val nonceFunctionSelector = EthereumUtils.getFunctionSelector("nonce()")
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to proxyAddress,
"data" to nonceFunctionSelector
),
"latest"
)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("获取 Proxy nonce 失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("获取 Proxy nonce 失败: ${rpcResponse.error.message}"))
}
val hexNonce = rpcResponse.result ?: return Result.failure(Exception("Proxy nonce 结果为空"))
val nonce = EthereumUtils.decodeUint256(hexNonce)
return Result.success(nonce)
}
/**
* 获取交易 nonce
*/
private suspend fun getTransactionCount(address: String, rpcApi: EthereumRpcApi): Result<BigInteger> {
val rpcRequest = JsonRpcRequest(
method = "eth_getTransactionCount",
params = listOf(address, "pending")
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("获取 nonce 失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("获取 nonce 失败: ${rpcResponse.error.message}"))
}
val hexNonce = rpcResponse.result ?: return Result.failure(Exception("nonce 结果为空"))
val nonce = EthereumUtils.decodeUint256(hexNonce)
return Result.success(nonce)
}
/**
* 获取 gas price
*/
private suspend fun getGasPrice(rpcApi: EthereumRpcApi): Result<BigInteger> {
val rpcRequest = JsonRpcRequest(
method = "eth_gasPrice",
params = emptyList()
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("获取 gas price 失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("获取 gas price 失败: ${rpcResponse.error.message}"))
}
val hexGasPrice = rpcResponse.result ?: return Result.failure(Exception("gas price 结果为空"))
val gasPrice = EthereumUtils.decodeUint256(hexGasPrice)
return Result.success(gasPrice)
}
/**
* 构建并签名交易
*/
private fun buildTransaction(
privateKey: String,
from: String,
to: String,
data: String,
nonce: BigInteger,
gasLimit: BigInteger,
gasPrice: BigInteger
): Map<String, Any> {
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
val rawTransaction = org.web3j.crypto.RawTransaction.createTransaction(
nonce,
gasPrice,
gasLimit,
to,
data
)
val chainId: Long = 137L
val signedTransaction = org.web3j.crypto.TransactionEncoder.signMessage(rawTransaction, chainId, credentials)
val hexValue = org.web3j.utils.Numeric.toHexString(signedTransaction)
return mapOf(
"from" to from,
"to" to to,
"data" to data,
"nonce" to "0x${nonce.toString(16)}",
"gas" to "0x${gasLimit.toString(16)}",
"gasPrice" to "0x${gasPrice.toString(16)}",
"value" to "0x0",
"chainId" to "0x89",
"rawTransaction" to hexValue
)
}
/**
* 发送交易
*/
private suspend fun sendTransaction(
rpcApi: EthereumRpcApi,
transaction: Map<String, Any>
): Result<String> {
val rawTransaction = transaction["rawTransaction"] as? String
?: return Result.failure(IllegalArgumentException("rawTransaction 不能为空"))
val rpcRequest = JsonRpcRequest(
method = "eth_sendRawTransaction",
params = listOf(rawTransaction)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("发送交易失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("发送交易失败: ${rpcResponse.error.message}"))
}
val txHash = rpcResponse.result ?: return Result.failure(Exception("交易哈希为空"))
return Result.success(txHash)
}
/**
* 批量执行 Safe 交易
* 参考 TypeScript: RelayClient.execute() 支持批量交易
*
* @param privateKey 私钥
* @param proxyAddress 代理钱包地址
* @param safeTxs Safe 交易列表
* @return 交易哈希
*/
suspend fun executeBatch(
privateKey: String,
proxyAddress: String,
safeTxs: List<SafeTransaction>
): Result<String> {
// 批量执行:将多个交易合并为一个 execTransaction 调用
// 当前实现:委托给 BlockchainService
return Result.failure(
UnsupportedOperationException(
"批量 Gasless 执行暂未实现。请使用 BlockchainService.redeemPositions() 方法。"
)
)
}
}
@@ -0,0 +1,151 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.dto.SystemConfigDto
import com.wrbug.polymarketbot.dto.SystemConfigUpdateRequest
import com.wrbug.polymarketbot.entity.SystemConfig
import com.wrbug.polymarketbot.repository.SystemConfigRepository
import com.wrbug.polymarketbot.util.CryptoUtils
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 系统配置服务
*/
@Service
class SystemConfigService(
private val systemConfigRepository: SystemConfigRepository,
private val cryptoUtils: CryptoUtils
) {
private val logger = LoggerFactory.getLogger(SystemConfigService::class.java)
companion object {
const val CONFIG_KEY_BUILDER_API_KEY = "builder.api_key"
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
}
/**
* 获取系统配置
*/
fun getSystemConfig(): SystemConfigDto {
val builderApiKey = getConfigValue(CONFIG_KEY_BUILDER_API_KEY)
val builderSecret = getConfigValue(CONFIG_KEY_BUILDER_SECRET)
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
return SystemConfigDto(
builderApiKeyConfigured = builderApiKey != null,
builderSecretConfigured = builderSecret != null,
builderPassphraseConfigured = builderPassphrase != null
)
}
/**
* 更新 Builder API Key 配置
*/
@Transactional
fun updateBuilderApiKey(request: SystemConfigUpdateRequest): Result<SystemConfigDto> {
return try {
// 更新 Builder API Key
if (request.builderApiKey != null) {
updateConfigValue(
CONFIG_KEY_BUILDER_API_KEY,
if (request.builderApiKey.isNotBlank()) {
cryptoUtils.encrypt(request.builderApiKey)
} else {
null // 清空配置
}
)
}
// 更新 Builder Secret
if (request.builderSecret != null) {
updateConfigValue(
CONFIG_KEY_BUILDER_SECRET,
if (request.builderSecret.isNotBlank()) {
cryptoUtils.encrypt(request.builderSecret)
} else {
null // 清空配置
}
)
}
// 更新 Builder Passphrase
if (request.builderPassphrase != null) {
updateConfigValue(
CONFIG_KEY_BUILDER_PASSPHRASE,
if (request.builderPassphrase.isNotBlank()) {
cryptoUtils.encrypt(request.builderPassphrase)
} else {
null // 清空配置
}
)
}
Result.success(getSystemConfig())
} catch (e: Exception) {
logger.error("更新 Builder API Key 配置失败", e)
Result.failure(e)
}
}
/**
* 获取配置值(解密)
*/
fun getBuilderApiKey(): String? {
return getConfigValue(CONFIG_KEY_BUILDER_API_KEY)?.let { cryptoUtils.decrypt(it) }
}
fun getBuilderSecret(): String? {
return getConfigValue(CONFIG_KEY_BUILDER_SECRET)?.let { cryptoUtils.decrypt(it) }
}
fun getBuilderPassphrase(): String? {
return getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)?.let { cryptoUtils.decrypt(it) }
}
/**
* 检查 Builder API Key 是否已配置
*/
fun isBuilderApiKeyConfigured(): Boolean {
val apiKey = getConfigValue(CONFIG_KEY_BUILDER_API_KEY)
val secret = getConfigValue(CONFIG_KEY_BUILDER_SECRET)
val passphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
return apiKey != null && secret != null && passphrase != null
}
/**
* 获取配置值(原始值,加密存储)
*/
private fun getConfigValue(configKey: String): String? {
return systemConfigRepository.findByConfigKey(configKey)?.configValue
}
/**
* 更新配置值
*/
private fun updateConfigValue(configKey: String, configValue: String?) {
val existing = systemConfigRepository.findByConfigKey(configKey)
if (existing != null) {
val updated = existing.copy(
configValue = configValue,
updatedAt = System.currentTimeMillis()
)
systemConfigRepository.save(updated)
} else {
val newConfig = SystemConfig(
configKey = configKey,
configValue = configValue,
description = when (configKey) {
CONFIG_KEY_BUILDER_API_KEY -> "Builder API Key(用于 Gasless 交易)"
CONFIG_KEY_BUILDER_SECRET -> "Builder Secret(用于 Gasless 交易)"
CONFIG_KEY_BUILDER_PASSPHRASE -> "Builder Passphrase(用于 Gasless 交易)"
else -> null
}
)
systemConfigRepository.save(newConfig)
}
}
}
@@ -0,0 +1,104 @@
package com.wrbug.polymarketbot.util
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.slf4j.LoggerFactory
import java.io.IOException
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Builder API 认证拦截器
* 用于 Builder Relayer API 的认证
*
* 参考: @polymarket/builder-signing-sdk 的 buildHmacSignature
*
* 认证方式:
* 1. 使用 HMAC-SHA256 对请求进行签名
* 2. 在请求头中添加:
* - POLY_BUILDER_SIGNATURE: HMAC 签名(URL-safe base64
* - POLY_BUILDER_TIMESTAMP: 时间戳(毫秒,字符串)
* - POLY_BUILDER_API_KEY: API Key
* - POLY_BUILDER_PASSPHRASE: Passphrase
*/
class BuilderAuthInterceptor(
private val apiKey: String,
private val secret: String,
private val passphrase: String
) : Interceptor {
private val logger = LoggerFactory.getLogger(BuilderAuthInterceptor::class.java)
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
// 获取请求体
val requestBody = originalRequest.body
val bodyString = if (requestBody != null) {
val buffer = okio.Buffer()
requestBody.writeTo(buffer)
buffer.readUtf8()
} else {
""
}
// 获取时间戳(毫秒)
val timestamp = System.currentTimeMillis().toString()
// 构建签名字符串: timestamp + method + path + body
val method = originalRequest.method
val path = originalRequest.url.encodedPath
val signString = "$timestamp$method$path$bodyString"
// 生成 HMAC 签名
val signature = try {
buildHmacSignature(signString, secret)
} catch (e: Exception) {
logger.error("生成 Builder HMAC 签名失败", e)
throw IOException("生成签名失败: ${e.message}", e)
}
// 构建新的请求,添加 Builder 认证头
val newRequest = originalRequest.newBuilder()
.header("POLY_BUILDER_SIGNATURE", signature)
.header("POLY_BUILDER_TIMESTAMP", timestamp)
.header("POLY_BUILDER_API_KEY", apiKey)
.header("POLY_BUILDER_PASSPHRASE", passphrase)
.build()
return chain.proceed(newRequest)
}
/**
* 构建 HMAC 签名
* 参考: @polymarket/builder-signing-sdk 的 buildHmacSignature
*/
private fun buildHmacSignature(message: String, secret: String): String {
// 解码 Builder Secretbase64
val decodedSecret = try {
Base64.getDecoder().decode(secret)
} catch (e: Exception) {
try {
Base64.getUrlDecoder().decode(secret)
} catch (e2: Exception) {
secret.toByteArray()
}
}
// 使用 HMAC-SHA256 生成签名
val mac = Mac.getInstance("HmacSHA256")
val secretKeySpec = SecretKeySpec(decodedSecret, "HmacSHA256")
mac.init(secretKeySpec)
val hash = mac.doFinal(message.toByteArray())
// Base64 编码
val base64Signature = Base64.getEncoder().encodeToString(hash)
// URL-safe base64 编码(+ -> -, / -> _
return base64Signature.replace("+", "-").replace("/", "_")
}
}
@@ -281,24 +281,29 @@ object Eip712Encoder {
/**
* 编码 Gnosis Safe 域分隔符
* Domain: { verifyingContract: address }
* 参考: Gnosis Safe 合约的 EIP-712 域定义
* Domain: { chainId: uint256, verifyingContract: address }
* 参考: builder-relayer-client/src/builder/safe.ts 的 createStructHash
* 注意:TypeScript 的 domain 包含 chainId 和 verifyingContract
*/
fun encodeSafeDomain(
chainId: Long,
verifyingContract: String
): ByteArray {
val domainTypeHash = encodeType(
"EIP712Domain",
listOf(
"chainId" to "uint256",
"verifyingContract" to "address"
)
)
val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId))
val contractBytes = encodeAddress(verifyingContract)
val encoded = ByteArray(32 + 32)
val encoded = ByteArray(32 + 32 + 32)
System.arraycopy(domainTypeHash, 0, encoded, 0, 32)
System.arraycopy(contractBytes, 0, encoded, 32, 32)
System.arraycopy(chainIdBytes, 0, encoded, 32, 32)
System.arraycopy(contractBytes, 0, encoded, 64, 32)
return keccak256(encoded)
}
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.api.PolymarketDataApi
@@ -162,6 +163,44 @@ class RetrofitFactory(
.build()
.create(PolymarketDataApi::class.java)
}
/**
* 创建 Builder Relayer API 客户端
* @param relayerUrl Builder Relayer URL
* @param apiKey Builder API Key
* @param secret Builder Secret
* @param passphrase Builder Passphrase
* @return BuilderRelayerApi 客户端
*/
fun createBuilderRelayerApi(
relayerUrl: String,
apiKey: String,
secret: String,
passphrase: String
): BuilderRelayerApi {
val baseUrl = if (relayerUrl.endsWith("/")) {
relayerUrl.dropLast(1)
} else {
relayerUrl
}
// 添加 Builder 认证拦截器
val builderAuthInterceptor = BuilderAuthInterceptor(apiKey, secret, passphrase)
val okHttpClient = createClient()
.addInterceptor(builderAuthInterceptor)
.build()
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(BuilderRelayerApi::class.java)
}
}
/**
@@ -45,6 +45,14 @@ polymarket.gamma.base-url=https://gamma-api.polymarket.com
# 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
# Builder Relayer 配置(用于 Gasless 交易)
# 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证
# 如果未配置,将使用手动发送交易的方式(需要用户支付 gas)
polymarket.builder.relayer-url=${POLYMARKET_BUILDER_RELAYER_URL:https://relayer-v2.polymarket.com/}
polymarket.builder.api-key=${POLYMARKET_BUILDER_API_KEY:}
polymarket.builder.secret=${POLYMARKET_BUILDER_SECRET:}
polymarket.builder.passphrase=${POLYMARKET_BUILDER_PASSPHRASE:}
# 仓位推送配置
# 轮询间隔(毫秒),默认3秒
position.push.polling-interval=${POSITION_PUSH_POLLING_INTERVAL:3000}
@@ -0,0 +1,22 @@
-- ============================================
-- V7: 创建系统配置表
-- 用于存储系统级别的配置(如 Builder API Key
-- ============================================
CREATE TABLE IF NOT EXISTS system_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
config_key VARCHAR(100) NOT NULL UNIQUE COMMENT '配置键(唯一)',
config_value TEXT NULL COMMENT '配置值(加密存储)',
description VARCHAR(255) NULL COMMENT '配置描述',
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
INDEX idx_config_key (config_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表';
-- 初始化 Builder API Key 配置项(如果不存在)
INSERT IGNORE INTO system_config (config_key, config_value, description, created_at, updated_at)
VALUES
('builder.api_key', NULL, 'Builder API Key(用于 Gasless 交易)', UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
('builder.secret', NULL, 'Builder Secret(用于 Gasless 交易)', UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
('builder.passphrase', NULL, 'Builder Passphrase(用于 Gasless 交易)', UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000);
+2
View File
@@ -35,6 +35,7 @@ import LanguageSettings from './pages/LanguageSettings'
import ApiHealthStatus from './pages/ApiHealthStatus'
import ProxySettings from './pages/ProxySettings'
import NotificationSettings from './pages/NotificationSettings'
import BuilderApiKeySettings from './pages/BuilderApiKeySettings'
import { wsManager } from './services/websocket'
import type { OrderPushMessage } from './types'
import { apiService } from './services/api'
@@ -253,6 +254,7 @@ function App() {
<Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
<Route path="/system-settings/proxy" element={<ProtectedRoute><ProxySettings /></ProtectedRoute>} />
<Route path="/system-settings/notifications" element={<ProtectedRoute><NotificationSettings /></ProtectedRoute>} />
<Route path="/system-settings/builder-api-key" element={<ProtectedRoute><BuilderApiKeySettings /></ProtectedRoute>} />
{/* 默认重定向到登录页 */}
<Route path="*" element={<Navigate to="/login" replace />} />
+14 -3
View File
@@ -19,7 +19,8 @@ import {
TwitterOutlined,
GlobalOutlined,
CheckCircleOutlined,
NotificationOutlined
NotificationOutlined,
KeyOutlined
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import type { ReactNode } from 'react'
@@ -121,6 +122,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
icon: <SettingOutlined />,
label: t('menu.systemSettings'),
children: [
{
key: '/system-settings',
icon: <SettingOutlined />,
label: t('menu.systemOverview') || '概览'
},
{
key: '/system-settings/language',
icon: <GlobalOutlined />,
@@ -136,6 +142,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
icon: <LinkOutlined />,
label: t('menu.proxy')
},
{
key: '/system-settings/builder-api-key',
icon: <KeyOutlined />,
label: t('menu.builderApiKey') || 'Builder API Key'
},
{
key: '/system-settings/notifications',
icon: <NotificationOutlined />,
@@ -173,8 +184,8 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
}
const handleMenuClick = ({ key }: { key: string }) => {
// 如果是父菜单,不导航
if (key === '/copy-trading-management' || key === '/system-settings') {
// 如果是父菜单,不导航(但 /system-settings 作为子菜单项时可以导航)
if (key === '/copy-trading-management') {
return
}
+45
View File
@@ -233,8 +233,10 @@
"statistics": "Statistics",
"users": "User Management",
"systemSettings": "System",
"systemOverview": "Overview",
"language": "Language",
"apiHealth": "API Health",
"builderApiKey": "Builder API Key",
"proxy": "Proxy",
"notifications": "Notifications",
"logout": "Logout",
@@ -302,6 +304,49 @@
"systemSettingsDesc": "Configure proxy, view API health status",
"footer": "Please use the above pages for configuration management."
},
"builderApiKey": {
"title": "Builder API Key Configuration",
"alertTitle": "What is Builder API Key?",
"description": "Builder API Key is a system-level configuration, not related to your account. It is an API credential for Polymarket's Builder service, used to execute Gasless (no gas fee) transactions.",
"purposeTitle": "Main purposes:",
"purpose1": "Execute Gasless transactions: Operations like redeeming positions and creating orders don't require gas fees",
"purpose2": "Improve user experience: No need to prepare MATIC tokens in wallet for gas fees",
"purpose3": "Reduce transaction costs: Gas fees are covered by Builder service",
"getApiKey": "Get API Key:",
"openSettings": "Go to Polymarket Settings",
"apiKey": "Builder API Key",
"secret": "Builder Secret",
"passphrase": "Builder Passphrase",
"apiKeyPlaceholder": "Please enter Builder API Key",
"secretPlaceholder": "Please enter Builder Secret",
"passphrasePlaceholder": "Please enter Builder Passphrase",
"apiKeyHelp": "Configured, enter new value to update",
"secretHelp": "Configured, enter new value to update",
"passphraseHelp": "Configured, enter new value to update",
"saveSuccess": "Builder API Key configuration saved successfully",
"saveFailed": "Failed to save Builder API Key configuration",
"getFailed": "Failed to get Builder API Key configuration",
"notConfigured": "Builder API Key not configured, please go to System Settings to configure",
"notConfiguredError": "Builder API Key not configured, cannot execute Gasless transactions. Please go to System Settings to configure Builder API Key.",
"apiReference": "API Reference Documentation",
"apiReferenceDesc": "Learn more about Builder API usage:",
"relayerApiDocs": "Builder Relayer API Documentation",
"builderDocs": "Builder Development Documentation",
"apiTest": "API Test",
"apiTestDesc": "Test if Builder API is working correctly",
"testNonce": "Test Get Nonce",
"testNonceHelp": "Enter Safe address (proxyAddress)",
"testDeployed": "Test Check Deployed Status",
"testDeployedHelp": "Enter Safe address (proxyAddress)",
"testTransaction": "Test Get Transaction Status",
"testTransactionHelp": "Enter transaction ID",
"testAddressRequired": "Please enter address",
"testTransactionIdRequired": "Please enter transaction ID",
"testAddressPlaceholder": "Enter address, e.g., 0x...",
"testTransactionIdPlaceholder": "Enter transaction ID",
"testSuccess": "Test successful",
"testFailed": "Test failed"
},
"resetPassword": {
"title": "Reset Password",
"firstUse": "First Time Using System",
+45
View File
@@ -150,8 +150,10 @@
"statistics": "统计信息",
"users": "用户管理",
"systemSettings": "系统管理",
"systemOverview": "概览",
"language": "语言",
"apiHealth": "API健康",
"builderApiKey": "Builder API Key",
"proxy": "代理",
"notifications": "消息推送",
"logout": "退出登录",
@@ -214,6 +216,49 @@
"systemSettingsDesc": "配置代理、查看 API 健康状态",
"footer": "请使用上述页面进行配置管理。"
},
"builderApiKey": {
"title": "Builder API Key 配置",
"alertTitle": "什么是 Builder API Key",
"description": "Builder API Key 是系统级别的配置,与您的账户无关。它是 Polymarket 提供的 Builder 服务的 API 凭证,用于执行 Gasless(免 gas 费)交易。",
"purposeTitle": "主要用途:",
"purpose1": "执行 Gasless 交易:赎回仓位、创建订单等操作无需支付 gas 费用",
"purpose2": "提升用户体验:无需在钱包中准备 MATIC 代币作为 gas 费",
"purpose3": "降低交易成本:由 Builder 服务承担 gas 费用",
"getApiKey": "获取 API Key",
"openSettings": "前往 Polymarket 设置页面",
"apiKey": "Builder API Key",
"secret": "Builder Secret",
"passphrase": "Builder Passphrase",
"apiKeyPlaceholder": "请输入 Builder API Key",
"secretPlaceholder": "请输入 Builder Secret",
"passphrasePlaceholder": "请输入 Builder Passphrase",
"apiKeyHelp": "已配置,输入新值以更新",
"secretHelp": "已配置,输入新值以更新",
"passphraseHelp": "已配置,输入新值以更新",
"saveSuccess": "保存 Builder API Key 配置成功",
"saveFailed": "保存 Builder API Key 配置失败",
"getFailed": "获取 Builder API Key 配置失败",
"notConfigured": "Builder API Key 未配置,请前往系统设置页面配置",
"notConfiguredError": "Builder API Key 未配置,无法执行 Gasless 交易。请前往系统设置页面配置 Builder API Key。",
"apiReference": "API 参考文档",
"apiReferenceDesc": "了解更多关于 Builder API 的使用方法:",
"relayerApiDocs": "Builder Relayer API 文档",
"builderDocs": "Builder 开发文档",
"apiTest": "API 测试",
"apiTestDesc": "测试 Builder API 是否正常工作",
"testNonce": "测试获取 Nonce",
"testNonceHelp": "输入 Safe 地址(proxyAddress",
"testDeployed": "测试检查部署状态",
"testDeployedHelp": "输入 Safe 地址(proxyAddress",
"testTransaction": "测试获取交易状态",
"testTransactionHelp": "输入交易ID",
"testAddressRequired": "请输入地址",
"testTransactionIdRequired": "请输入交易ID",
"testAddressPlaceholder": "输入地址,例如:0x...",
"testTransactionIdPlaceholder": "输入交易ID",
"testSuccess": "测试成功",
"testFailed": "测试失败"
},
"resetPassword": {
"title": "重置密码",
"firstUse": "首次使用系统",
+45
View File
@@ -233,8 +233,10 @@
"statistics": "統計信息",
"users": "用戶管理",
"systemSettings": "系統管理",
"systemOverview": "概覽",
"language": "語言",
"apiHealth": "API健康",
"builderApiKey": "Builder API Key",
"proxy": "代理",
"notifications": "消息推送",
"logout": "退出登錄",
@@ -302,6 +304,49 @@
"systemSettingsDesc": "配置代理、查看 API 健康狀態",
"footer": "請使用上述頁面進行配置管理。"
},
"builderApiKey": {
"title": "Builder API Key 配置",
"alertTitle": "什麼是 Builder API Key",
"description": "Builder API Key 是系統級別的配置,與您的賬戶無關。它是 Polymarket 提供的 Builder 服務的 API 憑證,用於執行 Gasless(免 gas 費)交易。",
"purposeTitle": "主要用途:",
"purpose1": "執行 Gasless 交易:贖回倉位、創建訂單等操作無需支付 gas 費用",
"purpose2": "提升用戶體驗:無需在錢包中準備 MATIC 代幣作為 gas 費",
"purpose3": "降低交易成本:由 Builder 服務承擔 gas 費用",
"getApiKey": "獲取 API Key",
"openSettings": "前往 Polymarket 設置頁面",
"apiKey": "Builder API Key",
"secret": "Builder Secret",
"passphrase": "Builder Passphrase",
"apiKeyPlaceholder": "請輸入 Builder API Key",
"secretPlaceholder": "請輸入 Builder Secret",
"passphrasePlaceholder": "請輸入 Builder Passphrase",
"apiKeyHelp": "已配置,輸入新值以更新",
"secretHelp": "已配置,輸入新值以更新",
"passphraseHelp": "已配置,輸入新值以更新",
"saveSuccess": "保存 Builder API Key 配置成功",
"saveFailed": "保存 Builder API Key 配置失敗",
"getFailed": "獲取 Builder API Key 配置失敗",
"notConfigured": "Builder API Key 未配置,請前往系統設置頁面配置",
"notConfiguredError": "Builder API Key 未配置,無法執行 Gasless 交易。請前往系統設置頁面配置 Builder API Key。",
"apiReference": "API 參考文檔",
"apiReferenceDesc": "了解更多關於 Builder API 的使用方法:",
"relayerApiDocs": "Builder Relayer API 文檔",
"builderDocs": "Builder 開發文檔",
"apiTest": "API 測試",
"apiTestDesc": "測試 Builder API 是否正常工作",
"testNonce": "測試獲取 Nonce",
"testNonceHelp": "輸入 Safe 地址(proxyAddress",
"testDeployed": "測試檢查部署狀態",
"testDeployedHelp": "輸入 Safe 地址(proxyAddress",
"testTransaction": "測試獲取交易狀態",
"testTransactionHelp": "輸入交易ID",
"testAddressRequired": "請輸入地址",
"testTransactionIdRequired": "請輸入交易ID",
"testAddressPlaceholder": "輸入地址,例如:0x...",
"testTransactionIdPlaceholder": "輸入交易ID",
"testSuccess": "測試成功",
"testFailed": "測試失敗"
},
"resetPassword": {
"title": "重置密碼",
"firstUse": "首次使用系統",
@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react'
import { Card, Form, Button, Input, message, Typography, Space, Alert } from 'antd'
import { SaveOutlined, LinkOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { SystemConfig, BuilderApiKeyUpdateRequest } from '../types'
const { Title, Paragraph, Text } = Typography
const BuilderApiKeySettings: React.FC = () => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [builderApiKeyForm] = Form.useForm()
const [builderApiKeyConfig, setBuilderApiKeyConfig] = useState<SystemConfig | null>(null)
const [builderApiKeyLoading, setBuilderApiKeyLoading] = useState(false)
useEffect(() => {
fetchBuilderApiKeyConfig()
}, [])
const fetchBuilderApiKeyConfig = async () => {
try {
const response = await apiService.systemConfig.get()
if (response.data.code === 0 && response.data.data) {
const config = response.data.data
setBuilderApiKeyConfig(config)
// 预填充字段(如果已配置,显示占位符)
builderApiKeyForm.setFieldsValue({
builderApiKey: config.builderApiKeyConfigured ? '***' : '',
builderSecret: config.builderSecretConfigured ? '***' : '',
builderPassphrase: config.builderPassphraseConfigured ? '***' : '',
})
} else {
message.error(response.data.msg || t('builderApiKey.getFailed'))
}
} catch (error: any) {
message.error(error.message || t('builderApiKey.getFailed'))
}
}
const handleBuilderApiKeySubmit = async (values: BuilderApiKeyUpdateRequest) => {
setBuilderApiKeyLoading(true)
try {
// 如果值是 '***',表示已配置但未修改,不发送
const updateData: BuilderApiKeyUpdateRequest = {}
if (values.builderApiKey && values.builderApiKey !== '***') {
updateData.builderApiKey = values.builderApiKey
}
if (values.builderSecret && values.builderSecret !== '***') {
updateData.builderSecret = values.builderSecret
}
if (values.builderPassphrase && values.builderPassphrase !== '***') {
updateData.builderPassphrase = values.builderPassphrase
}
const response = await apiService.systemConfig.updateBuilderApiKey(updateData)
if (response.data.code === 0) {
message.success(t('builderApiKey.saveSuccess'))
fetchBuilderApiKeyConfig()
} else {
message.error(response.data.msg || t('builderApiKey.saveFailed'))
}
} catch (error: any) {
message.error(error.message || t('builderApiKey.saveFailed'))
} finally {
setBuilderApiKeyLoading(false)
}
}
return (
<div>
<div style={{ marginBottom: '16px' }}>
<Title level={2} style={{ margin: 0 }}>{t('builderApiKey.title')}</Title>
</div>
<Card>
<Alert
message={t('builderApiKey.alertTitle')}
description={
<div>
<Paragraph style={{ marginBottom: '8px' }}>
{t('builderApiKey.description')}
</Paragraph>
<Paragraph style={{ marginBottom: '8px' }}>
<Text strong>{t('builderApiKey.purposeTitle')}</Text>
<ul style={{ marginTop: '8px', marginBottom: 0, paddingLeft: '20px' }}>
<li>{t('builderApiKey.purpose1')}</li>
<li>{t('builderApiKey.purpose2')}</li>
<li>{t('builderApiKey.purpose3')}</li>
</ul>
</Paragraph>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>{t('builderApiKey.getApiKey')}</Text>
<Space style={{ marginLeft: '8px' }}>
<a
href="https://polymarket.com/settings?tab=builder"
target="_blank"
rel="noopener noreferrer"
>
<LinkOutlined /> {t('builderApiKey.openSettings')}
</a>
</Space>
</Paragraph>
</div>
}
type="info"
showIcon
style={{ marginBottom: '24px' }}
/>
<Form
form={builderApiKeyForm}
layout="vertical"
onFinish={handleBuilderApiKeySubmit}
size={isMobile ? 'middle' : 'large'}
>
<Form.Item
label={t('builderApiKey.apiKey')}
name="builderApiKey"
help={builderApiKeyConfig?.builderApiKeyConfigured ? t('builderApiKey.apiKeyHelp') : t('builderApiKey.apiKeyPlaceholder')}
>
<Input
placeholder={builderApiKeyConfig?.builderApiKeyConfigured ? t('builderApiKey.apiKeyHelp') : t('builderApiKey.apiKeyPlaceholder')}
/>
</Form.Item>
<Form.Item
label={t('builderApiKey.secret')}
name="builderSecret"
help={builderApiKeyConfig?.builderSecretConfigured ? t('builderApiKey.secretHelp') : t('builderApiKey.secretPlaceholder')}
>
<Input
placeholder={builderApiKeyConfig?.builderSecretConfigured ? t('builderApiKey.secretHelp') : t('builderApiKey.secretPlaceholder')}
/>
</Form.Item>
<Form.Item
label={t('builderApiKey.passphrase')}
name="builderPassphrase"
help={builderApiKeyConfig?.builderPassphraseConfigured ? t('builderApiKey.passphraseHelp') : t('builderApiKey.passphrasePlaceholder')}
>
<Input
placeholder={builderApiKeyConfig?.builderPassphraseConfigured ? t('builderApiKey.passphraseHelp') : t('builderApiKey.passphrasePlaceholder')}
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={builderApiKeyLoading}
>
{t('common.save') || '保存配置'}
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default BuilderApiKeySettings
+28 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useState, useMemo } from 'react'
import { Card, Table, Tag, message, Space, Input, Radio, Select, Button, Row, Col, Empty, Modal, Form, Descriptions } from 'antd'
import { SearchOutlined, AppstoreOutlined, UnorderedListOutlined, UpOutlined, DownOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { apiService } from '../services/api'
import type { AccountPosition, Account, PositionPushMessage, PositionSellRequest, MarketPriceResponse, RedeemablePositionsSummary, PositionRedeemRequest } from '../types'
import { getPositionKey } from '../types'
@@ -13,6 +14,7 @@ type PositionFilter = 'current' | 'historical'
type ViewMode = 'card' | 'list'
const PositionList: React.FC = () => {
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [currentPositions, setCurrentPositions] = useState<AccountPosition[]>([])
const [historyPositions, setHistoryPositions] = useState<AccountPosition[]>([])
@@ -113,10 +115,34 @@ const PositionList: React.FC = () => {
// 刷新可赎回统计
await fetchRedeemableSummary()
} else {
message.error(response.data.msg || '赎回失败')
// 检查是否是 Builder API Key 未配置的错误
if (response.data.code === 2014 || response.data.msg?.includes('Builder API Key 未配置')) {
message.error({
content: response.data.msg || 'Builder API Key 未配置',
duration: 5,
})
// 延迟跳转,让用户看到错误消息
setTimeout(() => {
navigate('/system-settings/builder-api-key')
}, 1500)
} else {
message.error(response.data.msg || '赎回失败')
}
}
} catch (error: any) {
message.error('赎回失败: ' + (error.message || '未知错误'))
// 检查是否是 Builder API Key 未配置的错误
if (error.response?.data?.code === 2014 || error.message?.includes('Builder API Key 未配置')) {
message.error({
content: error.response?.data?.msg || error.message || 'Builder API Key 未配置,请前往系统设置页面配置',
duration: 5,
})
// 延迟跳转,让用户看到错误消息
setTimeout(() => {
navigate('/system-settings/builder-api-key')
}, 1500)
} else {
message.error('赎回失败: ' + (error.message || '未知错误'))
}
} finally {
setRedeeming(false)
}
+17
View File
@@ -578,6 +578,23 @@ export const apiService = {
*/
getTelegramChatIds: (data: { botToken: string }) =>
apiClient.post<ApiResponse<string[]>>('/notifications/telegram/get-chat-ids', data)
},
/**
* 系统配置 API
*/
systemConfig: {
/**
* 获取系统配置
*/
get: () =>
apiClient.post<ApiResponse<import('../types').SystemConfig>>('/system/config/get', {}),
/**
* 更新 Builder API Key 配置
*/
updateBuilderApiKey: (data: import('../types').BuilderApiKeyUpdateRequest) =>
apiClient.post<ApiResponse<import('../types').SystemConfig>>('/system/config/builder-api-key/update', data)
}
}
+18
View File
@@ -753,6 +753,24 @@ export interface NotificationConfigRequest {
/**
* 通知配置更新请求
*/
/**
* 系统配置响应
*/
export interface SystemConfig {
builderApiKeyConfigured: boolean
builderSecretConfigured: boolean
builderPassphraseConfigured: boolean
}
/**
* Builder API Key 更新请求
*/
export interface BuilderApiKeyUpdateRequest {
builderApiKey?: string
builderSecret?: string
builderPassphrase?: string
}
export interface NotificationConfigUpdateRequest {
id: number
type: string