feat: 账户设置后端 action、Safe 部署与设置引导弹窗复用
账户设置: - 后端: execute-setup-step API(步骤1 Safe 一键部署/步骤2 启用交易/步骤3 代币授权) - Safe 部署: Eip712Encoder SafeCreate/CreateProxy,RelayClientService.deploySafeViaBuilderRelayer - BuilderRelayerApi: SignatureParams 增加 paymentToken/payment/paymentReceiver,TransactionRequest.nonce 可选 - 导入成功有未完成步骤时先弹设置引导再关导入弹窗;设置弹窗复用 AccountSetupStatusBlock(embedded) - AccountSetupStatusBlock: 5s 轮询、embedded 模式、onAllCompleted、移除全部完成 Tag、授权信息始终展示且右对齐额度列 - 多语言: 步骤1/2/3 文案与 actionSuccess/actionFailed CryptoTail: - SpreadDirection/SpreadMode 枚举及 Converter,V38 迁移,策略列表与 api/types 调整 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -92,7 +92,7 @@ interface BuilderRelayerApi {
|
||||
val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
|
||||
|
||||
@SerializedName("nonce")
|
||||
val nonce: String, // Safe nonce(字符串)
|
||||
val nonce: String? = null, // Safe nonce(SAFE 必填,SAFE-CREATE 不传)
|
||||
|
||||
@SerializedName("signature")
|
||||
val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀)
|
||||
@@ -138,7 +138,17 @@ interface BuilderRelayerApi {
|
||||
val relayHub: String? = null,
|
||||
|
||||
@SerializedName("relay")
|
||||
val relay: String? = null
|
||||
val relay: String? = null,
|
||||
|
||||
/** SAFE-CREATE 签名参数 */
|
||||
@SerializedName("paymentToken")
|
||||
val paymentToken: String? = null,
|
||||
|
||||
@SerializedName("payment")
|
||||
val payment: String? = null,
|
||||
|
||||
@SerializedName("paymentReceiver")
|
||||
val paymentReceiver: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,5 +44,14 @@ object PolymarketConstants {
|
||||
* 用于 Gasless 交易
|
||||
*/
|
||||
const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/"
|
||||
|
||||
/**
|
||||
* Polymarket Safe 代理工厂合约地址(Polygon 主网)
|
||||
* 用于 Safe 类型账户的代理部署(SAFE-CREATE)
|
||||
*/
|
||||
const val SAFE_PROXY_FACTORY_ADDRESS = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
|
||||
|
||||
/** SafeCreate 用 EIP-712 domain name,与 builder-relayer-client 一致 */
|
||||
const val SAFE_FACTORY_EIP712_NAME = "Polymarket Contract Proxy Factory"
|
||||
}
|
||||
|
||||
|
||||
+76
@@ -204,6 +204,82 @@ class AccountController(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户设置状态(代理部署、交易启用、代币批准)
|
||||
*/
|
||||
@PostMapping("/check-setup-status")
|
||||
fun checkSetupStatus(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountSetupStatusDto>> {
|
||||
return try {
|
||||
if (request.accountId == null || request.accountId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
val result = runBlocking { accountService.checkAccountSetupStatus(request.accountId) }
|
||||
result.fold(
|
||||
onSuccess = { status ->
|
||||
ResponseEntity.ok(ApiResponse.success(status))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("检查账户设置状态失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
else -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查账户设置状态异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行设置步骤(步骤1 返回跳转 URL,步骤2/3 由后端执行)
|
||||
*/
|
||||
@PostMapping("/execute-setup-step")
|
||||
fun executeSetupStep(@RequestBody request: ExecuteSetupStepRequest): ResponseEntity<ApiResponse<ExecuteSetupStepResponse>> {
|
||||
return try {
|
||||
if (request.accountId == null || request.accountId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
val step = request.step ?: 0
|
||||
if (step !in 1..3) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "步骤必须为 1、2 或 3", messageSource))
|
||||
}
|
||||
val result = runBlocking { accountService.executeSetupStep(request.accountId, step) }
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("执行设置步骤失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(
|
||||
ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource)
|
||||
)
|
||||
else -> ResponseEntity.ok(
|
||||
ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("执行设置步骤异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户详情
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 账户设置状态检查结果
|
||||
*/
|
||||
data class AccountSetupStatusDto(
|
||||
/**
|
||||
* 步骤1:代理钱包是否已部署
|
||||
*/
|
||||
val proxyDeployed: Boolean,
|
||||
|
||||
/**
|
||||
* 步骤2:交易是否已启用(API Key 是否已配置)
|
||||
*/
|
||||
val tradingEnabled: Boolean,
|
||||
|
||||
/**
|
||||
* 步骤3:代币是否已批准
|
||||
*/
|
||||
val tokensApproved: Boolean,
|
||||
|
||||
/**
|
||||
* 代币批准详情(各合约的授权额度)
|
||||
* Key: 合约名称(CTF_CONTRACT, CTF_EXCHANGE, NEG_RISK_EXCHANGE, NEG_RISK_ADAPTER)
|
||||
* Value: 授权额度(USDC,6位小数)
|
||||
*/
|
||||
val approvalDetails: Map<String, String>? = null,
|
||||
|
||||
/**
|
||||
* 检查错误信息(如果有)
|
||||
*/
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 执行设置步骤请求
|
||||
*/
|
||||
data class ExecuteSetupStepRequest(
|
||||
/** 账户 ID */
|
||||
val accountId: Long? = null,
|
||||
/** 步骤:1=部署代理, 2=启用交易, 3=批准代币 */
|
||||
val step: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 执行设置步骤响应
|
||||
*/
|
||||
data class ExecuteSetupStepResponse(
|
||||
/** 是否由后端执行成功(步骤1 仅返回跳转链接,为 false) */
|
||||
val success: Boolean = false,
|
||||
/** 需跳转时由后端提供的 URL(步骤1 使用) */
|
||||
val redirectUrl: String? = null,
|
||||
/** 链上交易哈希(步骤3 批准代币成功时返回) */
|
||||
val transactionHash: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户导入响应(扩展,包含设置状态)
|
||||
*/
|
||||
data class AccountImportResponse(
|
||||
val account: AccountDto,
|
||||
val setupStatus: AccountSetupStatusDto? = null // 设置状态检查结果(可选)
|
||||
)
|
||||
@@ -15,8 +15,12 @@ data class CryptoTailStrategyCreateRequest(
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
val minSpreadMode: String = "NONE",
|
||||
val minSpreadValue: String? = null,
|
||||
/** 价差模式: NONE, FIXED, AUTO */
|
||||
val spreadMode: String = "NONE",
|
||||
/** 价差数值 */
|
||||
val spreadValue: String? = null,
|
||||
/** 价差方向: MIN=最小价差, MAX=最大价差 */
|
||||
val spreadDirection: String = "MIN",
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
||||
@@ -32,8 +36,12 @@ data class CryptoTailStrategyUpdateRequest(
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String? = null,
|
||||
val amountValue: String? = null,
|
||||
val minSpreadMode: String? = null,
|
||||
val minSpreadValue: String? = null,
|
||||
/** 价差模式: NONE, FIXED, AUTO */
|
||||
val spreadMode: String? = null,
|
||||
/** 价差数值 */
|
||||
val spreadValue: String? = null,
|
||||
/** 价差方向: MIN=最小价差, MAX=最大价差 */
|
||||
val spreadDirection: String? = null,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
@@ -61,8 +69,12 @@ data class CryptoTailStrategyDto(
|
||||
val maxPrice: String = "1",
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
val minSpreadMode: String = "NONE",
|
||||
val minSpreadValue: String? = null,
|
||||
/** 价差模式: NONE, FIXED, AUTO */
|
||||
val spreadMode: String = "NONE",
|
||||
/** 价差数值 */
|
||||
val spreadValue: String? = null,
|
||||
/** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */
|
||||
val spreadDirection: String = "MIN",
|
||||
val enabled: Boolean = true,
|
||||
val lastTriggerAt: Long? = null,
|
||||
/** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */
|
||||
@@ -138,7 +150,7 @@ data class CryptoTailStrategyTriggerListResponse(
|
||||
)
|
||||
|
||||
/**
|
||||
* 自动最小价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7)
|
||||
* 自动价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7)
|
||||
*/
|
||||
data class CryptoTailAutoMinSpreadResponse(
|
||||
val minSpreadUp: String = "0",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import com.wrbug.polymarketbot.enums.SpreadDirection
|
||||
import com.wrbug.polymarketbot.enums.SpreadDirectionConverter
|
||||
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||
import com.wrbug.polymarketbot.enums.SpreadModeConverter
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
|
||||
/**
|
||||
* 加密市场尾盘策略实体
|
||||
@@ -45,11 +48,19 @@ data class CryptoTailStrategy(
|
||||
@Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
|
||||
val amountValue: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "min_spread_mode", nullable = false, length = 16)
|
||||
val minSpreadMode: String = "NONE",
|
||||
/** 价差模式: NONE=不校验, FIXED=固定值, AUTO=历史计算 */
|
||||
@Convert(converter = SpreadModeConverter::class)
|
||||
@Column(name = "spread_mode", nullable = false, columnDefinition = "TINYINT")
|
||||
val spreadMode: SpreadMode = SpreadMode.NONE,
|
||||
|
||||
@Column(name = "min_spread_value", precision = 20, scale = 8)
|
||||
val minSpreadValue: BigDecimal? = null,
|
||||
/** 价差数值(FIXED 时必填;AUTO 时可存计算值) */
|
||||
@Column(name = "spread_value", precision = 20, scale = 8)
|
||||
val spreadValue: BigDecimal? = null,
|
||||
|
||||
/** 价差方向: MIN=最小价差(价差>=配置值触发),MAX=最大价差(价差<=配置值触发) */
|
||||
@Convert(converter = SpreadDirectionConverter::class)
|
||||
@Column(name = "spread_direction", nullable = false, columnDefinition = "TINYINT")
|
||||
val spreadDirection: SpreadDirection = SpreadDirection.MIN,
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
val enabled: Boolean = true,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
/**
|
||||
* 价差方向枚举
|
||||
*/
|
||||
enum class SpreadDirection(val value: Int, val description: String) {
|
||||
/**
|
||||
* 最小价差:价差 >= 配置值时触发,买入价固定 0.99
|
||||
*/
|
||||
MIN(0, "最小价差"),
|
||||
|
||||
/**
|
||||
* 最大价差:价差 <= 配置值时触发,买入价 = 触发价 + 0.02
|
||||
*/
|
||||
MAX(1, "最大价差");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 从数值解析价差方向
|
||||
*/
|
||||
fun fromValue(value: Int?): SpreadDirection {
|
||||
if (value == null) {
|
||||
return MIN // 默认返回 MIN
|
||||
}
|
||||
return values().find { it.value == value }
|
||||
?: throw IllegalArgumentException("未知的价差方向: $value")
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地从数值解析价差方向,解析失败返回默认值
|
||||
*/
|
||||
fun fromValueOrDefault(value: Int?, default: SpreadDirection = MIN): SpreadDirection {
|
||||
if (value == null) {
|
||||
return default
|
||||
}
|
||||
return values().find { it.value == value } ?: default
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字符串解析价差方向(兼容旧逻辑)
|
||||
*/
|
||||
fun fromString(value: String?): SpreadDirection {
|
||||
if (value.isNullOrBlank()) {
|
||||
return MIN
|
||||
}
|
||||
return values().find { it.name.equals(value, ignoreCase = true) }
|
||||
?: throw IllegalArgumentException("未知的价差方向: $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
import jakarta.persistence.AttributeConverter
|
||||
import jakarta.persistence.Converter
|
||||
|
||||
/**
|
||||
* SpreadDirection 枚举的 JPA 转换器
|
||||
* 数据库存储为 TINYINT (0 = MIN, 1 = MAX)
|
||||
*/
|
||||
@Converter(autoApply = false)
|
||||
class SpreadDirectionConverter : AttributeConverter<SpreadDirection, Int> {
|
||||
|
||||
override fun convertToDatabaseColumn(attribute: SpreadDirection?): Int {
|
||||
return attribute?.value ?: SpreadDirection.MIN.value
|
||||
}
|
||||
|
||||
override fun convertToEntityAttribute(dbData: Int?): SpreadDirection {
|
||||
return SpreadDirection.fromValueOrDefault(dbData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
/**
|
||||
* 价差模式枚举
|
||||
*/
|
||||
enum class SpreadMode(val value: Int, val description: String) {
|
||||
/**
|
||||
* 不校验价差
|
||||
*/
|
||||
NONE(0, "无"),
|
||||
|
||||
/**
|
||||
* 固定值:用户输入一个数值
|
||||
*/
|
||||
FIXED(1, "固定"),
|
||||
|
||||
/**
|
||||
* 自动:系统按历史 K 线计算建议价差
|
||||
*/
|
||||
AUTO(2, "自动");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 从数值解析价差模式
|
||||
*/
|
||||
fun fromValue(value: Int?): SpreadMode {
|
||||
if (value == null) {
|
||||
return NONE // 默认返回 NONE
|
||||
}
|
||||
return values().find { it.value == value }
|
||||
?: throw IllegalArgumentException("未知的价差模式: $value")
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地从数值解析价差模式,解析失败返回默认值
|
||||
*/
|
||||
fun fromValueOrDefault(value: Int?, default: SpreadMode = NONE): SpreadMode {
|
||||
if (value == null) {
|
||||
return default
|
||||
}
|
||||
return values().find { it.value == value } ?: default
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字符串解析价差模式(兼容旧逻辑)
|
||||
*/
|
||||
fun fromString(value: String?): SpreadMode {
|
||||
if (value.isNullOrBlank()) {
|
||||
return NONE
|
||||
}
|
||||
return values().find { it.name.equals(value, ignoreCase = true) }
|
||||
?: throw IllegalArgumentException("未知的价差模式: $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
import jakarta.persistence.AttributeConverter
|
||||
import jakarta.persistence.Converter
|
||||
|
||||
/**
|
||||
* SpreadMode 枚举的 JPA 转换器
|
||||
* 数据库存储为 TINYINT (0 = NONE, 1 = FIXED, 2 = AUTO)
|
||||
*/
|
||||
@Converter(autoApply = false)
|
||||
class SpreadModeConverter : AttributeConverter<SpreadMode, Int> {
|
||||
|
||||
override fun convertToDatabaseColumn(attribute: SpreadMode?): Int {
|
||||
return attribute?.value ?: SpreadMode.NONE.value
|
||||
}
|
||||
|
||||
override fun convertToEntityAttribute(dbData: Int?): SpreadMode {
|
||||
return SpreadMode.fromValueOrDefault(dbData)
|
||||
}
|
||||
}
|
||||
@@ -361,6 +361,215 @@ class AccountService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polymarket 代币批准检查:USDC.e 需授权的 spender 合约地址(Polygon 主网)
|
||||
* 来源:Polymarket/magic-safe-builder-example README §6 Token Approvals
|
||||
* 及 neg-risk-ctf-adapter 仓库 addresses.json (chainId 137)
|
||||
*/
|
||||
private val setupApprovalSpenders = mapOf(
|
||||
"CTF_CONTRACT" to "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", // Conditional Tokens
|
||||
"CTF_EXCHANGE" to "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E", // 普通市场交易所
|
||||
"NEG_RISK_EXCHANGE" to "0xC5d563A36AE78145C45a50134d48A1215220f80a", // 负风险市场交易所
|
||||
"NEG_RISK_ADAPTER" to "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" // 负风险适配器(非 WCOL 地址)
|
||||
)
|
||||
|
||||
/** USDC 精度(6 位小数) */
|
||||
private val usdcDecimals = java.math.BigDecimal("1000000")
|
||||
|
||||
/** ERC20 无限授权额度(type(uint256).max),Polymarket 默认使用无限授权 */
|
||||
private val unlimitedAllowance = BigInteger("115792089237316195423570985008687907853269984665640564039457584007913129639935")
|
||||
|
||||
/**
|
||||
* 检查账户设置状态(代理部署、交易启用、代币批准)
|
||||
* @param accountId 账户 ID
|
||||
* @return AccountSetupStatusDto
|
||||
*/
|
||||
suspend fun checkAccountSetupStatus(accountId: Long): Result<AccountSetupStatusDto> {
|
||||
return try {
|
||||
if (accountId <= 0) {
|
||||
return Result.failure(IllegalArgumentException("账户 ID 无效"))
|
||||
}
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
val proxyAddress = account.proxyAddress
|
||||
if (proxyAddress.isBlank()) {
|
||||
return Result.success(
|
||||
AccountSetupStatusDto(
|
||||
proxyDeployed = false,
|
||||
tradingEnabled = account.apiKey != null && account.apiSecret != null && account.apiPassphrase != null,
|
||||
tokensApproved = false,
|
||||
approvalDetails = null,
|
||||
error = "代理地址为空"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 步骤1:代理钱包是否已部署
|
||||
val proxyDeployed = blockchainService.isProxyDeployed(proxyAddress)
|
||||
|
||||
// 步骤2:交易是否已启用(API 凭证是否已配置)
|
||||
val tradingEnabled = account.apiKey != null &&
|
||||
account.apiSecret != null &&
|
||||
account.apiPassphrase != null
|
||||
|
||||
// 步骤3:代币是否已批准(USDC 对各 spender 的 allowance,默认无限授权)
|
||||
val approvalDetails = mutableMapOf<String, String>()
|
||||
var tokensApproved = true
|
||||
for ((name, spender) in setupApprovalSpenders) {
|
||||
val allowanceResult = blockchainService.getUsdcAllowance(proxyAddress, spender)
|
||||
val allowance = allowanceResult.getOrNull() ?: BigInteger.ZERO
|
||||
val displayAmount = if (allowance >= unlimitedAllowance) {
|
||||
"unlimited"
|
||||
} else {
|
||||
java.math.BigDecimal(allowance).divide(usdcDecimals, 6, java.math.RoundingMode.DOWN).toPlainString()
|
||||
}
|
||||
approvalDetails[name] = displayAmount
|
||||
if (allowance <= BigInteger.ZERO) {
|
||||
tokensApproved = false
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(
|
||||
AccountSetupStatusDto(
|
||||
proxyDeployed = proxyDeployed,
|
||||
tradingEnabled = tradingEnabled,
|
||||
tokensApproved = tokensApproved,
|
||||
approvalDetails = approvalDetails,
|
||||
error = null
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查账户设置状态失败: accountId=$accountId, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 步骤1 跳转 URL(代理部署需在 Polymarket 完成) */
|
||||
private val setupStep1RedirectUrl = "https://polymarket.com/settings/wallet"
|
||||
|
||||
/**
|
||||
* 执行设置步骤(由后端实现或返回跳转)
|
||||
* 步骤1:仅返回跳转 URL,由用户前往 Polymarket 完成部署
|
||||
* 步骤2:创建/派生 API Key 并更新账户
|
||||
* 步骤3:通过代理钱包批量执行 USDC 授权
|
||||
*/
|
||||
suspend fun executeSetupStep(accountId: Long, step: Int): Result<ExecuteSetupStepResponse> {
|
||||
return try {
|
||||
if (accountId <= 0) {
|
||||
return Result.failure(IllegalArgumentException("账户 ID 无效"))
|
||||
}
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
when (step) {
|
||||
1 -> {
|
||||
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.MAGIC)
|
||||
if (walletType == WalletType.MAGIC) {
|
||||
Result.success(
|
||||
ExecuteSetupStepResponse(
|
||||
success = false,
|
||||
redirectUrl = setupStep1RedirectUrl
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val proxyAddress = account.proxyAddress
|
||||
if (proxyAddress.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("代理地址为空"))
|
||||
}
|
||||
val alreadyDeployed = blockchainService.isProxyDeployed(proxyAddress)
|
||||
if (alreadyDeployed) {
|
||||
Result.success(ExecuteSetupStepResponse(success = true))
|
||||
} else {
|
||||
val privateKey = decryptPrivateKey(account)
|
||||
val deployResult = relayClientService.deploySafeViaBuilderRelayer(
|
||||
privateKey = privateKey,
|
||||
proxyAddress = proxyAddress,
|
||||
fromAddress = account.walletAddress
|
||||
)
|
||||
deployResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
Result.success(
|
||||
ExecuteSetupStepResponse(
|
||||
success = true,
|
||||
transactionHash = txHash
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("Safe 部署失败: accountId=$accountId, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
2 -> {
|
||||
val privateKey = decryptPrivateKey(account)
|
||||
val result = apiKeyService.createOrDeriveApiKey(
|
||||
privateKey = privateKey,
|
||||
walletAddress = account.walletAddress,
|
||||
chainId = 137L
|
||||
)
|
||||
if (result.isFailure) {
|
||||
val e = result.exceptionOrNull()
|
||||
logger.error("启用交易(API Key)失败: accountId=$accountId, ${e?.message}", e)
|
||||
return Result.failure(e ?: IllegalStateException("获取 API Key 失败"))
|
||||
}
|
||||
val creds = result.getOrNull()
|
||||
?: return Result.failure(IllegalStateException("API Key 返回为空"))
|
||||
val encryptedSecret = creds.secret?.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedPassphrase = creds.passphrase?.let { cryptoUtils.encrypt(it) }
|
||||
val updated = account.copy(
|
||||
apiKey = creds.apiKey,
|
||||
apiSecret = encryptedSecret,
|
||||
apiPassphrase = encryptedPassphrase,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
accountRepository.save(updated)
|
||||
orderPushService.refreshSubscriptions()
|
||||
Result.success(ExecuteSetupStepResponse(success = true))
|
||||
}
|
||||
3 -> {
|
||||
val proxyAddress = account.proxyAddress
|
||||
if (proxyAddress.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("代理地址为空,请先完成步骤1"))
|
||||
}
|
||||
val privateKey = decryptPrivateKey(account)
|
||||
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||
val approveTxs = setupApprovalSpenders.values.map { spender ->
|
||||
relayClientService.createUsdcApproveTx(spender, unlimitedAllowance)
|
||||
}
|
||||
val multiSendTx = relayClientService.createMultiSendTx(approveTxs)
|
||||
val executeResult = relayClientService.execute(
|
||||
privateKey = privateKey,
|
||||
proxyAddress = proxyAddress,
|
||||
safeTx = multiSendTx,
|
||||
walletType = walletType
|
||||
)
|
||||
executeResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
Result.success(
|
||||
ExecuteSetupStepResponse(
|
||||
success = true,
|
||||
transactionHash = txHash
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("代币授权执行失败: accountId=$accountId, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
}
|
||||
else -> Result.failure(IllegalArgumentException("无效的步骤: $step,应为 1、2 或 3"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("执行设置步骤失败: accountId=$accountId, step=$step, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
|
||||
+6
-5
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.service.cryptotail
|
||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||
@@ -225,7 +226,7 @@ class CryptoTailOrderbookWsService(
|
||||
}
|
||||
if (oldTokenIds == tokenIds.toSet()) {
|
||||
scheduleRefreshAtPeriodEnd(newMap)
|
||||
precomputeAutoMinSpreadForCurrentPeriods(newMap)
|
||||
precomputeAutoSpreadForCurrentPeriods(newMap)
|
||||
return
|
||||
}
|
||||
closeWebSocketAndReconnect()
|
||||
@@ -244,7 +245,7 @@ class CryptoTailOrderbookWsService(
|
||||
return
|
||||
}
|
||||
scheduleRefreshAtPeriodEnd(newMap)
|
||||
precomputeAutoMinSpreadForCurrentPeriods(newMap)
|
||||
precomputeAutoSpreadForCurrentPeriods(newMap)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,11 +265,11 @@ class CryptoTailOrderbookWsService(
|
||||
}
|
||||
|
||||
/**
|
||||
* AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期最小价差,触发时直接用缓存。
|
||||
* AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期价差,触发时直接用缓存。
|
||||
*/
|
||||
private fun precomputeAutoMinSpreadForCurrentPeriods(newMap: Map<String, List<WsBookEntry>>) {
|
||||
private fun precomputeAutoSpreadForCurrentPeriods(newMap: Map<String, List<WsBookEntry>>) {
|
||||
val autoPeriods = newMap.values.asSequence().flatten()
|
||||
.filter { it.strategy.minSpreadMode.uppercase() == "AUTO" }
|
||||
.filter { it.strategy.spreadMode == SpreadMode.AUTO }
|
||||
.distinctBy { "${it.strategy.intervalSeconds}-${it.periodStartUnix}" }
|
||||
.map { it.strategy.intervalSeconds to it.periodStartUnix }
|
||||
.toList()
|
||||
|
||||
+54
-25
@@ -6,6 +6,8 @@ import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||
import com.wrbug.polymarketbot.enums.SpreadDirection
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
@@ -34,6 +36,9 @@ import java.util.regex.Pattern
|
||||
/** 尾盘策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */
|
||||
private const val TRIGGER_FIXED_PRICE = "0.99"
|
||||
|
||||
/** 最大价差模式(MAX)时,买入价格调整系数(加在触发价格上) */
|
||||
private const val SPREAD_MAX_PRICE_ADJUSTMENT = "0.02"
|
||||
|
||||
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
|
||||
private const val SIZE_DECIMAL_SCALE = 2
|
||||
|
||||
@@ -190,51 +195,58 @@ class CryptoTailStrategyExecutionService(
|
||||
val closePrice = oc?.second?.toPlainString() ?: "-"
|
||||
val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "尾盘策略-${strategy.marketSlugPrefix}"
|
||||
val direction = if (outcomeIndex == 0) "Up" else "Down"
|
||||
val modeStr = if (strategy.spreadDirection == SpreadDirection.MAX) "最大价差" else "最小价差"
|
||||
logger.info(
|
||||
"尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
|
||||
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " +
|
||||
"direction=$direction, outcomeIndex=$outcomeIndex"
|
||||
"direction=$direction, outcomeIndex=$outcomeIndex, spreadMode=$modeStr"
|
||||
)
|
||||
}
|
||||
if (!passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
|
||||
if (!passSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
|
||||
ensurePeriodContext(strategy, periodStartUnix, tokenIds, marketTitle)
|
||||
placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun passMinSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
|
||||
val mode = strategy.minSpreadMode.uppercase()
|
||||
if (mode == "NONE") return true
|
||||
private fun passSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
|
||||
if (strategy.spreadMode == SpreadMode.NONE) return true
|
||||
val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix)
|
||||
?: return false
|
||||
val (openP, closeP) = oc
|
||||
val spreadAbs = closeP.subtract(openP).abs()
|
||||
when (mode) {
|
||||
"FIXED" -> {
|
||||
val effectiveMinSpread = strategy.minSpreadValue?.takeIf { it > BigDecimal.ZERO }
|
||||
if (effectiveMinSpread == null || effectiveMinSpread <= BigDecimal.ZERO) return true
|
||||
return spreadAbs >= effectiveMinSpread
|
||||
|
||||
// 获取有效价差
|
||||
val effectiveSpread = when (strategy.spreadMode) {
|
||||
SpreadMode.FIXED -> {
|
||||
strategy.spreadValue?.takeIf { it > BigDecimal.ZERO } ?: return true
|
||||
}
|
||||
"AUTO" -> {
|
||||
val result = computeAutoEffectiveMinSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
|
||||
val effectiveMinSpread = result.effectiveMinSpread
|
||||
if (effectiveMinSpread <= BigDecimal.ZERO) return true
|
||||
return spreadAbs >= effectiveMinSpread
|
||||
SpreadMode.AUTO -> {
|
||||
val result = computeAutoEffectiveSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
|
||||
result.effectiveSpread.takeIf { it > BigDecimal.ZERO } ?: return true
|
||||
}
|
||||
else -> return true
|
||||
SpreadMode.NONE -> return true
|
||||
}
|
||||
|
||||
// 根据价差方向判断
|
||||
return if (strategy.spreadDirection == SpreadDirection.MAX) {
|
||||
// 最大价差模式:价差 <= 配置值时触发
|
||||
spreadAbs <= effectiveSpread
|
||||
} else {
|
||||
// 最小价差模式:价差 >= 配置值时触发
|
||||
spreadAbs >= effectiveSpread
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AUTO 模式:取 100% 基准价差,按窗口内毫秒进度计算动态系数(100%→50%)得到有效最小价差。
|
||||
* AUTO 模式:取 100% 基准价差,按窗口内毫秒进度计算动态系数(100%→50%)得到有效价差。
|
||||
*/
|
||||
private data class AutoSpreadResult(
|
||||
val baseSpread: BigDecimal,
|
||||
val coefficient: BigDecimal,
|
||||
val effectiveMinSpread: BigDecimal
|
||||
val effectiveSpread: BigDecimal
|
||||
)
|
||||
|
||||
private fun computeAutoEffectiveMinSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): AutoSpreadResult? {
|
||||
private fun computeAutoEffectiveSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): AutoSpreadResult? {
|
||||
val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(strategy.intervalSeconds, periodStartUnix, outcomeIndex)
|
||||
?: binanceKlineAutoSpreadService.computeAndCache(strategy.intervalSeconds, periodStartUnix)?.let { if (outcomeIndex == 0) it.first else it.second }
|
||||
?: return null
|
||||
@@ -251,8 +263,8 @@ class CryptoTailStrategyExecutionService(
|
||||
.let { p -> maxOf(BigDecimal.ZERO, minOf(BigDecimal.ONE, p)) }
|
||||
BigDecimal.ONE.subtract(progress.multi("0.5"))
|
||||
}
|
||||
val effectiveMinSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP)
|
||||
return AutoSpreadResult(baseSpread, coefficient, effectiveMinSpread)
|
||||
val effectiveSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP)
|
||||
return AutoSpreadResult(baseSpread, coefficient, effectiveSpread)
|
||||
}
|
||||
|
||||
private suspend fun placeOrderForTrigger(
|
||||
@@ -284,7 +296,15 @@ class CryptoTailStrategyExecutionService(
|
||||
return
|
||||
}
|
||||
|
||||
val price = BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
// 根据价差方向确定下单价格
|
||||
val price = if (strategy.spreadDirection == SpreadDirection.MAX) {
|
||||
// 最大价差模式:触发价格 + 0.02
|
||||
triggerPrice.add(BigDecimal(SPREAD_MAX_PRICE_ADJUSTMENT)).setScale(8, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
// 最小价差模式:固定价格 0.99
|
||||
BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
}
|
||||
val priceStr = price.toPlainString()
|
||||
val size = computeSize(amountUsdc, price)
|
||||
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
@@ -292,7 +312,7 @@ class CryptoTailStrategyExecutionService(
|
||||
makerAddress = ctx.account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = TRIGGER_FIXED_PRICE,
|
||||
price = priceStr,
|
||||
size = size,
|
||||
signatureType = ctx.signatureType,
|
||||
nonce = "0",
|
||||
@@ -380,7 +400,16 @@ class CryptoTailStrategyExecutionService(
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界")
|
||||
return
|
||||
}
|
||||
val price = BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
|
||||
// 根据价差方向确定下单价格
|
||||
val price = if (strategy.spreadDirection == SpreadDirection.MAX) {
|
||||
// 最大价差模式:触发价格 + 0.02
|
||||
triggerPrice.add(BigDecimal(SPREAD_MAX_PRICE_ADJUSTMENT)).setScale(8, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
// 最小价差模式:固定价格 0.99
|
||||
BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
}
|
||||
val priceStr = price.toPlainString()
|
||||
val size = computeSize(amountUsdc, price)
|
||||
|
||||
val decryptedKey = try {
|
||||
@@ -405,7 +434,7 @@ class CryptoTailStrategyExecutionService(
|
||||
makerAddress = account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = TRIGGER_FIXED_PRICE,
|
||||
price = priceStr,
|
||||
size = size,
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
|
||||
+40
-15
@@ -4,6 +4,8 @@ import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||
import com.wrbug.polymarketbot.enums.SpreadDirection
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||
@@ -62,12 +64,18 @@ class CryptoTailStrategyService(
|
||||
if (amountValue <= BigDecimal.ZERO) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val minSpreadMode = (request.minSpreadMode ?: "NONE").uppercase()
|
||||
if (minSpreadMode != "NONE" && minSpreadMode != "FIXED" && minSpreadMode != "AUTO") {
|
||||
val spreadMode = try {
|
||||
SpreadMode.fromString(request.spreadMode)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val minSpreadValue = request.minSpreadValue?.toSafeBigDecimal()
|
||||
if (minSpreadMode == "FIXED" && (minSpreadValue == null || minSpreadValue < BigDecimal.ZERO)) {
|
||||
val spreadValue = request.spreadValue?.toSafeBigDecimal()
|
||||
if (spreadMode == SpreadMode.FIXED && (spreadValue == null || spreadValue < BigDecimal.ZERO)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val spreadDirection = try {
|
||||
SpreadDirection.fromString(request.spreadDirection)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
|
||||
@@ -85,8 +93,9 @@ class CryptoTailStrategyService(
|
||||
maxPrice = maxPrice,
|
||||
amountMode = amountMode,
|
||||
amountValue = amountValue,
|
||||
minSpreadMode = minSpreadMode,
|
||||
minSpreadValue = minSpreadValue,
|
||||
spreadMode = spreadMode,
|
||||
spreadValue = spreadValue,
|
||||
spreadDirection = spreadDirection,
|
||||
enabled = request.enabled
|
||||
)
|
||||
val saved = strategyRepository.save(entity)
|
||||
@@ -121,13 +130,27 @@ class CryptoTailStrategyService(
|
||||
?: existing.name?.takeIf { it.isNotBlank() }
|
||||
?: generateStrategyName(existing.marketSlugPrefix)
|
||||
|
||||
val newMinSpreadMode = request.minSpreadMode?.uppercase() ?: existing.minSpreadMode
|
||||
if (newMinSpreadMode != "NONE" && newMinSpreadMode != "FIXED" && newMinSpreadMode != "AUTO") {
|
||||
val newSpreadMode = if (request.spreadMode != null) {
|
||||
try {
|
||||
SpreadMode.fromString(request.spreadMode)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
} else {
|
||||
existing.spreadMode
|
||||
}
|
||||
val newSpreadValue = request.spreadValue?.toSafeBigDecimal() ?: existing.spreadValue
|
||||
if (newSpreadMode == SpreadMode.FIXED && (newSpreadValue == null || newSpreadValue < BigDecimal.ZERO)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val newMinSpreadValue = request.minSpreadValue?.toSafeBigDecimal() ?: existing.minSpreadValue
|
||||
if (newMinSpreadMode == "FIXED" && (newMinSpreadValue == null || newMinSpreadValue < BigDecimal.ZERO)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
val newSpreadDirection = if (request.spreadDirection != null) {
|
||||
try {
|
||||
SpreadDirection.fromString(request.spreadDirection)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
} else {
|
||||
existing.spreadDirection
|
||||
}
|
||||
|
||||
val updated = existing.copy(
|
||||
@@ -138,8 +161,9 @@ class CryptoTailStrategyService(
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice,
|
||||
amountMode = request.amountMode?.uppercase() ?: existing.amountMode,
|
||||
amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue,
|
||||
minSpreadMode = newMinSpreadMode,
|
||||
minSpreadValue = newMinSpreadValue,
|
||||
spreadMode = newSpreadMode,
|
||||
spreadValue = newSpreadValue,
|
||||
spreadDirection = newSpreadDirection,
|
||||
enabled = request.enabled ?: existing.enabled,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
@@ -263,8 +287,9 @@ class CryptoTailStrategyService(
|
||||
maxPrice = e.maxPrice.toPlainString(),
|
||||
amountMode = e.amountMode,
|
||||
amountValue = e.amountValue.toPlainString(),
|
||||
minSpreadMode = e.minSpreadMode,
|
||||
minSpreadValue = e.minSpreadValue?.toPlainString(),
|
||||
spreadMode = e.spreadMode.name,
|
||||
spreadValue = e.spreadValue?.toPlainString(),
|
||||
spreadDirection = e.spreadDirection.name,
|
||||
enabled = e.enabled,
|
||||
lastTriggerAt = lastTriggerAt,
|
||||
totalRealizedPnl = totalPnl?.toPlainString(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.enums.WalletType
|
||||
import com.wrbug.polymarketbot.util.Eip712Encoder
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
@@ -58,6 +59,10 @@ class RelayClientService(
|
||||
// Builder Relayer API 交易类型常量
|
||||
private val RELAYER_TYPE_PROXY = "PROXY"
|
||||
private val RELAYER_TYPE_SAFE = "SAFE"
|
||||
private val RELAYER_TYPE_SAFE_CREATE = "SAFE-CREATE"
|
||||
|
||||
// Safe 代理工厂(用于 SAFE-CREATE 部署)
|
||||
private val safeProxyFactoryAddress = PolymarketConstants.SAFE_PROXY_FACTORY_ADDRESS
|
||||
|
||||
private val polygonRpcApi: EthereumRpcApi by lazy {
|
||||
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||
@@ -301,6 +306,23 @@ class RelayClientService(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 USDC approve 交易(ERC20 approve(spender, amount))
|
||||
* 用于 Polymarket 设置步骤3:代币授权
|
||||
*/
|
||||
fun createUsdcApproveTx(spender: String, amount: BigInteger): SafeTransaction {
|
||||
val functionSelector = EthereumUtils.getFunctionSelector("approve(address,uint256)")
|
||||
val encodedSpender = EthereumUtils.encodeAddress(spender)
|
||||
val encodedAmount = EthereumUtils.encodeUint256(amount)
|
||||
val callData = "0x" + functionSelector.removePrefix("0x") + encodedSpender + encodedAmount
|
||||
return SafeTransaction(
|
||||
to = usdcContractAddress,
|
||||
operation = 0, // CALL
|
||||
data = callData,
|
||||
value = "0"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 MultiSend 交易(合并多个 SafeTransaction 为一笔交易)
|
||||
* 参考 TypeScript: builder-relayer-client/src/encode/safe.ts createSafeMultisendTransaction
|
||||
@@ -694,6 +716,17 @@ class RelayClientService(
|
||||
}
|
||||
val proxyNonce = BigInteger(nonceResponse.body()!!.nonce)
|
||||
|
||||
// 调试 GS026:记录 nonce 与交易参数,便于与 relayer/链上对比
|
||||
logger.debug(
|
||||
"Safe exec 签名参数: nonce={}, to={}, value={}, dataLen={}, operation={}, proxyWallet={}",
|
||||
proxyNonce,
|
||||
safeTx.to,
|
||||
safeTx.value,
|
||||
redeemCallData.removePrefix("0x").length / 2,
|
||||
safeTx.operation,
|
||||
proxyAddress
|
||||
)
|
||||
|
||||
// 构建 Safe 交易哈希并签名
|
||||
// 注意:encodeSafeTx 需要 data 带 0x 前缀
|
||||
val safeTxGas = BigInteger.ZERO
|
||||
@@ -725,6 +758,12 @@ class RelayClientService(
|
||||
messageHash = safeTxHash
|
||||
)
|
||||
|
||||
// 调试 GS026:记录 EIP-712 structHash 与最终签名的 hash(可与 Safe.getTransactionHash 对比)
|
||||
logger.debug(
|
||||
"Safe exec 哈希: structHash=0x{}, hashToSign 将基于 prefix+structHash 的 keccak256",
|
||||
safeTxStructuredHash.joinToString("") { "%02x".format(it) }
|
||||
)
|
||||
|
||||
// 注意:ethers.js 的 signMessage 会添加 EIP-191 前缀
|
||||
// 格式:\x19Ethereum Signed Message:\n<length><message>
|
||||
// 我们需要模拟这个行为以匹配 TypeScript 实现
|
||||
@@ -739,6 +778,11 @@ class RelayClientService(
|
||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||
keccak256.doFinal(hashWithPrefix, 0)
|
||||
|
||||
logger.debug(
|
||||
"Safe exec hashToSign=0x{} (personal_sign 后签名的 32 字节)",
|
||||
hashWithPrefix.joinToString("") { "%02x".format(it) }
|
||||
)
|
||||
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||
|
||||
@@ -788,6 +832,104 @@ class RelayClientService(
|
||||
return Result.success(txHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Builder Relayer 部署 Safe 代理(SAFE-CREATE)
|
||||
* 参考: builder-relayer-client client.ts deploy()、builder/create.ts buildSafeCreateTransactionRequest
|
||||
*
|
||||
* @param privateKey EOA 私钥
|
||||
* @param proxyAddress 待部署的 Safe 代理地址(与 getProxyAddress 一致)
|
||||
* @param fromAddress EOA 地址(from)
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun deploySafeViaBuilderRelayer(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
fromAddress: String
|
||||
): Result<String> {
|
||||
return try {
|
||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||
val builderSecret = systemConfigService.getBuilderSecret()
|
||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
return Result.failure(IllegalStateException("Builder API Key 未配置,无法执行 Safe 部署"))
|
||||
}
|
||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
apiKey = builderApiKey!!,
|
||||
secret = builderSecret!!,
|
||||
passphrase = builderPassphrase!!
|
||||
)
|
||||
val zeroAddress = "0x0000000000000000000000000000000000000000"
|
||||
val paymentToken = zeroAddress
|
||||
val payment = "0"
|
||||
val paymentReceiver = zeroAddress
|
||||
val domainSeparator = Eip712Encoder.encodeSafeCreateDomain(
|
||||
name = PolymarketConstants.SAFE_FACTORY_EIP712_NAME,
|
||||
chainId = 137L,
|
||||
verifyingContract = safeProxyFactoryAddress
|
||||
)
|
||||
val createProxyHash = Eip712Encoder.encodeCreateProxyMessage(
|
||||
paymentToken = paymentToken,
|
||||
payment = BigInteger.ZERO,
|
||||
paymentReceiver = paymentReceiver
|
||||
)
|
||||
val digest = Eip712Encoder.hashStructuredData(domainSeparator, createProxyHash)
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val signature = org.web3j.crypto.Sign.signMessage(digest, ecKeyPair, false)
|
||||
// SAFE-CREATE 使用标准 EIP-712 签名格式(0x + r + s + v,v 为 27/28),与 signTypedData 一致
|
||||
val signatureHex = signatureToStandardHex(signature)
|
||||
val request = BuilderRelayerApi.TransactionRequest(
|
||||
type = RELAYER_TYPE_SAFE_CREATE,
|
||||
from = fromAddress,
|
||||
to = safeProxyFactoryAddress,
|
||||
proxyWallet = proxyAddress,
|
||||
data = "0x",
|
||||
nonce = null,
|
||||
signature = signatureHex,
|
||||
signatureParams = BuilderRelayerApi.SignatureParams(
|
||||
paymentToken = paymentToken,
|
||||
payment = payment,
|
||||
paymentReceiver = paymentReceiver
|
||||
),
|
||||
metadata = null
|
||||
)
|
||||
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
updateQuotaBlockedFromErrorBody(errorBody)
|
||||
logger.error("Builder Relayer SAFE-CREATE 失败: code=${response.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("部署 Safe 失败: ${response.code()} - $errorBody"))
|
||||
}
|
||||
val relayerResponse = response.body()!!
|
||||
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
|
||||
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
|
||||
logger.info("Safe 部署成功: proxy=$proxyAddress, txHash=$txHash")
|
||||
Result.success(txHash)
|
||||
} catch (e: Exception) {
|
||||
logger.error("部署 Safe 失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 SignatureData 转为标准 hex 签名(0x + r(64) + s(64) + v(2),v 为 27/28)
|
||||
* 用于 SAFE-CREATE,与 viem signTypedData 输出格式一致
|
||||
*/
|
||||
private fun signatureToStandardHex(signature: org.web3j.crypto.Sign.SignatureData): String {
|
||||
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
|
||||
val v = if (vBytes != null && vBytes.isNotEmpty()) {
|
||||
vBytes[0].toInt() and 0xff
|
||||
} else {
|
||||
27
|
||||
}
|
||||
val vHex = String.format("%02x", v)
|
||||
return "0x$rHex$sHex$vHex"
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||
* 将签名打包成 Gnosis Safe 接受的格式:encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
|
||||
|
||||
@@ -377,5 +377,62 @@ object Eip712Encoder {
|
||||
|
||||
return keccak256(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* SafeCreate 用 EIP712 域(Polymarket Contract Proxy Factory)
|
||||
* Domain: EIP712Domain(string name, uint256 chainId, address verifyingContract)
|
||||
* 参考: builder-relayer-client/src/builder/create.ts createSafeCreateSignature
|
||||
*/
|
||||
fun encodeSafeCreateDomain(
|
||||
name: String,
|
||||
chainId: Long,
|
||||
verifyingContract: String
|
||||
): ByteArray {
|
||||
val domainTypeHash = encodeType(
|
||||
"EIP712Domain",
|
||||
listOf(
|
||||
"name" to "string",
|
||||
"chainId" to "uint256",
|
||||
"verifyingContract" to "address"
|
||||
)
|
||||
)
|
||||
val nameHash = encodeString(name)
|
||||
val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId))
|
||||
val contractBytes = encodeAddress(verifyingContract)
|
||||
val encoded = ByteArray(32 + 32 + 32 + 32)
|
||||
System.arraycopy(domainTypeHash, 0, encoded, 0, 32)
|
||||
System.arraycopy(nameHash, 0, encoded, 32, 32)
|
||||
System.arraycopy(chainIdBytes, 0, encoded, 64, 32)
|
||||
System.arraycopy(contractBytes, 0, encoded, 96, 32)
|
||||
return keccak256(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateProxy 消息哈希(SafeCreate 签名用)
|
||||
* CreateProxy(address paymentToken, uint256 payment, address paymentReceiver)
|
||||
*/
|
||||
fun encodeCreateProxyMessage(
|
||||
paymentToken: String,
|
||||
payment: BigInteger,
|
||||
paymentReceiver: String
|
||||
): ByteArray {
|
||||
val typeHash = encodeType(
|
||||
"CreateProxy",
|
||||
listOf(
|
||||
"paymentToken" to "address",
|
||||
"payment" to "uint256",
|
||||
"paymentReceiver" to "address"
|
||||
)
|
||||
)
|
||||
val tokenBytes = encodeAddress(paymentToken)
|
||||
val paymentBytes = encodeUint256(payment)
|
||||
val receiverBytes = encodeAddress(paymentReceiver)
|
||||
val encoded = ByteArray(32 + 32 + 32 + 32)
|
||||
System.arraycopy(typeHash, 0, encoded, 0, 32)
|
||||
System.arraycopy(tokenBytes, 0, encoded, 32, 32)
|
||||
System.arraycopy(paymentBytes, 0, encoded, 64, 32)
|
||||
System.arraycopy(receiverBytes, 0, encoded, 96, 32)
|
||||
return keccak256(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- 尾盘策略价差字段重构:支持最小价差/最大价差方向,使用枚举数值存储
|
||||
-- 1. 重命名 min_spread_mode -> spread_mode,并转换为 TINYINT (0=NONE, 1=FIXED, 2=AUTO)
|
||||
-- 2. 重命名 min_spread_value -> spread_value
|
||||
-- 3. 新增 spread_direction 字段,使用 TINYINT (0=MIN, 1=MAX)
|
||||
|
||||
-- 步骤1: 重命名并迁移 spread_mode 数据
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
ADD COLUMN spread_mode_new TINYINT NOT NULL DEFAULT 0 COMMENT '价差模式: 0=NONE, 1=FIXED, 2=AUTO';
|
||||
|
||||
UPDATE crypto_tail_strategy
|
||||
SET spread_mode_new = CASE
|
||||
WHEN min_spread_mode = 'NONE' THEN 0
|
||||
WHEN min_spread_mode = 'FIXED' THEN 1
|
||||
WHEN min_spread_mode = 'AUTO' THEN 2
|
||||
ELSE 0
|
||||
END;
|
||||
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
DROP COLUMN min_spread_mode,
|
||||
CHANGE COLUMN spread_mode_new spread_mode TINYINT NOT NULL DEFAULT 0 COMMENT '价差模式: 0=NONE, 1=FIXED, 2=AUTO';
|
||||
|
||||
-- 步骤2: 重命名 spread_value
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
CHANGE COLUMN min_spread_value spread_value DECIMAL(20, 8) NULL COMMENT '价差数值(FIXED 时必填;AUTO 时可存计算值)';
|
||||
|
||||
-- 步骤3: 新增 spread_direction 字段
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
ADD COLUMN spread_direction TINYINT NOT NULL DEFAULT 0 COMMENT '价差方向: 0=MIN(价差>=配置值触发), 1=MAX(价差<=配置值触发)';
|
||||
Reference in New Issue
Block a user