diff --git a/.gitignore b/.gitignore index 107c6b0..e5c1907 100644 --- a/.gitignore +++ b/.gitignore @@ -101,4 +101,5 @@ test-results/ # Submodules and external dependencies clob-client/ +builder-relayer-client/ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/BuilderRelayerApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/BuilderRelayerApi.kt new file mode 100644 index 0000000..d69fac0 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/BuilderRelayerApi.kt @@ -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 + + /** + * 获取 nonce + * GET /nonce?address={address}&type={type} + */ + @GET("/nonce") + suspend fun getNonce( + @Query("address") address: String, + @Query("type") type: String + ): Response + + /** + * 获取交易状态 + * GET /transaction?id={transactionId} + */ + @GET("/transaction") + suspend fun getTransaction( + @Query("id") transactionId: String + ): Response> + + /** + * 检查 Safe 是否已部署 + * GET /deployed?address={address} + */ + @GET("/deployed") + suspend fun getDeployed( + @Query("address") address: String + ): Response + + /** + * 交易请求 + * 参考: 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 + ) +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt index 5c17044..80500a4 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt @@ -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( diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/SystemConfigController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/SystemConfigController.kt new file mode 100644 index 0000000..2bca386 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/SystemConfigController.kt @@ -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> { + 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> { + 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)) + } + } + +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt index b26238f..d691a8d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt @@ -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 是否已配置 +) + /** * 账户删除请求 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SystemConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SystemConfig.kt new file mode 100644 index 0000000..967a410 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SystemConfig.kt @@ -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() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt index bfb3dcc..52cf5ff 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt @@ -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"), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SystemConfigRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SystemConfigRepository.kt new file mode 100644 index 0000000..4a211b2 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SystemConfigRepository.kt @@ -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 { + fun findByConfigKey(configKey: String): SystemConfig? +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt index ae13c0b..3731825 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt @@ -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 { 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("赎回仓位列表不能为空")) } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ApiHealthCheckService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ApiHealthCheckService.kt index 6de2ece..b4b119a 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ApiHealthCheckService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/ApiHealthCheckService.kt @@ -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>( 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 ?: "连接失败" + ) + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt index 2a03ff7..3edd188 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt @@ -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 ): Result { 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) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/RelayClientService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/RelayClientService.kt new file mode 100644 index 0000000..5b72f65 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/RelayClientService.kt @@ -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 Relayer(Gasless 交易) + */ + 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 { + 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): 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 Relayer(Gasless),如果未配置则回退到手动发送交易 + * + * @param privateKey 私钥 + * @param proxyAddress 代理钱包地址 + * @param safeTx Safe 交易对象 + * @return 交易哈希 + */ + suspend fun execute( + privateKey: String, + proxyAddress: String, + safeTx: SafeTransaction + ): Result { + 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 Relayer(Gasless) + 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 { + 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 + // 我们需要模拟这个行为以匹配 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 { + return try { + // 如果未配置 RPC URL,返回错误 + if (polygonRpcUrl.isBlank()) { + logger.warn("未配置 Polygon RPC URL,无法执行交易") + return Result.failure(IllegalStateException("未配置 Polygon RPC URL,无法执行交易。请配置 polygon.rpc.url 或启用 Builder Relayer(Gasless)")) + } + + 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 + // 我们需要模拟这个行为以匹配 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 { + 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 { + 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 { + 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 { + 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 + ): Result { + 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 + ): Result { + // 批量执行:将多个交易合并为一个 execTransaction 调用 + // 当前实现:委托给 BlockchainService + return Result.failure( + UnsupportedOperationException( + "批量 Gasless 执行暂未实现。请使用 BlockchainService.redeemPositions() 方法。" + ) + ) + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/SystemConfigService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/SystemConfigService.kt new file mode 100644 index 0000000..2bb7fc1 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/SystemConfigService.kt @@ -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 { + 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) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/BuilderAuthInterceptor.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/BuilderAuthInterceptor.kt new file mode 100644 index 0000000..232f35f --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/BuilderAuthInterceptor.kt @@ -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 Secret(base64) + 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("/", "_") + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt index 6f7af25..aaf3646 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt @@ -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) } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt index 124310d..97423b7 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt @@ -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) + } } /** diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 7309443..c473d9f 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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} diff --git a/backend/src/main/resources/db/migration/V7__create_system_config.sql b/backend/src/main/resources/db/migration/V7__create_system_config.sql new file mode 100644 index 0000000..a3656e8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__create_system_config.sql @@ -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); + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dc05001..a4f3ad0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* 默认重定向到登录页 */} } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 4a0aeb0..d97f26d 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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 = ({ children }) => { icon: , label: t('menu.systemSettings'), children: [ + { + key: '/system-settings', + icon: , + label: t('menu.systemOverview') || '概览' + }, { key: '/system-settings/language', icon: , @@ -136,6 +142,11 @@ const Layout: React.FC = ({ children }) => { icon: , label: t('menu.proxy') }, + { + key: '/system-settings/builder-api-key', + icon: , + label: t('menu.builderApiKey') || 'Builder API Key' + }, { key: '/system-settings/notifications', icon: , @@ -173,8 +184,8 @@ const Layout: React.FC = ({ children }) => { } const handleMenuClick = ({ key }: { key: string }) => { - // 如果是父菜单,不导航 - if (key === '/copy-trading-management' || key === '/system-settings') { + // 如果是父菜单,不导航(但 /system-settings 作为子菜单项时可以导航) + if (key === '/copy-trading-management') { return } diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 5bc3def..b9a7587 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -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", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index a7f3570..4839d69 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -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": "首次使用系统", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index a4325a3..6514a48 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -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": "首次使用系統", diff --git a/frontend/src/pages/BuilderApiKeySettings.tsx b/frontend/src/pages/BuilderApiKeySettings.tsx new file mode 100644 index 0000000..376ad2d --- /dev/null +++ b/frontend/src/pages/BuilderApiKeySettings.tsx @@ -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(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 ( +
+
+ {t('builderApiKey.title')} +
+ + + + + {t('builderApiKey.description')} + + + {t('builderApiKey.purposeTitle')} +
    +
  • {t('builderApiKey.purpose1')}
  • +
  • {t('builderApiKey.purpose2')}
  • +
  • {t('builderApiKey.purpose3')}
  • +
+
+ + {t('builderApiKey.getApiKey')} + + + {t('builderApiKey.openSettings')} + + + +
+ } + type="info" + showIcon + style={{ marginBottom: '24px' }} + /> + +
+ + + + + + + + + + + + + + + +
+ + + ) +} + +export default BuilderApiKeySettings + diff --git a/frontend/src/pages/PositionList.tsx b/frontend/src/pages/PositionList.tsx index 93a2bd8..273a916 100644 --- a/frontend/src/pages/PositionList.tsx +++ b/frontend/src/pages/PositionList.tsx @@ -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([]) const [historyPositions, setHistoryPositions] = useState([]) @@ -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) } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 69b4fe1..a4e875c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -578,6 +578,23 @@ export const apiService = { */ getTelegramChatIds: (data: { botToken: string }) => apiClient.post>('/notifications/telegram/get-chat-ids', data) + }, + + /** + * 系统配置 API + */ + systemConfig: { + /** + * 获取系统配置 + */ + get: () => + apiClient.post>('/system/config/get', {}), + + /** + * 更新 Builder API Key 配置 + */ + updateBuilderApiKey: (data: import('../types').BuilderApiKeyUpdateRequest) => + apiClient.post>('/system/config/builder-api-key/update', data) } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c9d5075..64100f7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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