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:
WrBug
2026-02-18 03:01:35 +08:00
co-authored by Cursor
parent 4e1bb0bbcf
commit de89175c8e
28 changed files with 1619 additions and 188 deletions
@@ -92,7 +92,7 @@ interface BuilderRelayerApi {
val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
@SerializedName("nonce")
val nonce: String, // Safe nonce字符串
val nonce: String? = null, // Safe nonceSAFE 必填,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"
}
@@ -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: 授权额度(USDC6位小数)
*/
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)
}
}
/**
* 更新账户信息
*/
@@ -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()
@@ -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",
@@ -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 + vv 为 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(价差<=配置值触发)';