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:
@@ -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 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<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 Relayer(Gasless),如果未配置则回退到手动发送交易
|
||||
*
|
||||
* @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 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<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 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<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 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("/", "_")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "首次使用系统",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user