Compare commits

..
14 Commits
Author SHA1 Message Date
WrBugandCursor 5bb46ebb97 docs: 尾盘策略文档归集至 docs/crypto-tail-strategy/,前端链接同步
- 将尾盘策略相关文档移至 docs/crypto-tail-strategy/{zh,en}/
- 新增 docs/crypto-tail-strategy/README.md 目录说明
- 前端配置指南链接改为 docs/crypto-tail-strategy/${lang}/crypto-tail-strategy-user-guide.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 03:25:30 +08:00
WrBugandCursor cbcebf6e28 chore: 账户设置与杂项修复
- BlockchainService: 新增 isProxyDeployed、getUsdcAllowance(账户设置检查/授权用)
- RetrofitFactory: 移除 RPC URL 替换 debug 日志
- AccountImportForm: 修复 checkSetupStatus/onSuccess 参数类型(使用 newAccount.id)
- AccountSetupStatusBlock: 移除未使用的 isMobile 与 useMediaQuery 引用

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 03:17:40 +08:00
WrBugandCursor 2013a2eb70 fix: 尾盘策略 book 取最高买价为 bestBid,移除 tokenToEntries null 日志
- book 事件 bids 为升序,改为遍历取最高价作为 bestBid(修复 marketPrice 恒为 0.01)
- 移除 onBestBid 中 tokenToEntries null 的 debug 日志

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 03:16:22 +08:00
WrBugandCursor de89175c8e 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>
2026-02-18 03:01:35 +08:00
WrBugandCursor 4e1bb0bbcf feat: 添加尾盘策略配置指南文档和前端链接
- 新增中文和英文配置指南文档(docs/zh|en/crypto-tail-strategy-user-guide.md)
- 在尾盘策略列表页面添加配置指南链接按钮
- 根据用户语言自动跳转到对应语言的 GitHub 文档预览
- 添加多语言翻译键(中文简体/繁体/英文)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 00:54:30 +08:00
WrBugandCursor 24a4487d0b fix(cryptotail): 尾盘策略失败订单原因移除 HTTP 状态码,便于移动端显示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 04:33:27 +08:00
WrBugandCursor 24bb7bed40 feat(backend): 自动赎回 Builder Relayer 限流与配额处理
- RelayClientService: 429 限流时指数退避重试;解析 quota exceeded/resets in N seconds 并记录冷却时间;暴露 isBuilderRelayerQuotaBlocked 与 getBuilderRelayerQuotaBlockedRemainingSeconds
- PositionCheckService: checkRedeemablePositions 防重入;配额冷却期内跳过自动赎回

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 04:32:52 +08:00
WrBugandCursor 6b56f62532 chore(scripts): 添加 unwrap-wcol 脚本入口与 ethers 依赖
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 01:49:45 +08:00
WrBugandCursor c38529546f feat(system): Builder API Key 等配置前端显示完整值
- 后端 SystemConfigService 返回解密后的完整 builderApiKey/Secret/Passphrase,不再脱敏
- 移除未使用的 maskSensitiveValue 方法
- 更新 DTO 与前端类型注释为「完整显示」

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 01:47:52 +08:00
WrBugandCursor 08e1219f77 feat(neg-risk): WCOL 解包轮询与 Safe/Magic 一致说明
- 新增 WcolUnwrapJobService:每 20s 轮询解包 WCOL,未配置 Builder Key 时直接跳过
- AccountService:赎回流程不再等待确认与解包,由轮询统一处理;新增 runWcolUnwrapForAllAccounts
- BlockchainService:unwrapWcolForProxy 注释说明 Safe/Magic 逻辑一致;waitForTransactionConfirmed 避免 inline lambda 中 continue
- RelayClientService:createUnwrapWcolTx 注释说明共用逻辑;移除 Builder Relayer 调试日志
- docs/neg-risk-redeem.md:更新解包流程与 Safe/Magic 一致说明
- scripts/unwrap-wcol.js:新增脚本支持 Safe/EOA 解包与 Builder Relayer

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 01:46:55 +08:00
WrBug 62842f5aa8 Merge branch 'feature/crypto-market-tail-session-strategy' into dev 2026-02-16 00:12:38 +08:00
WrBugandCursor 13662dd0d5 fix(redeem): Neg Risk 市场使用 WrappedCollateral 赎回,并补充文档
- RelayClientService: 增加 negRiskWrappedCollateralAddress,createRedeemTx 支持 isNegRisk 选用抵押品
- BlockchainService: redeemPositions/redeemPositionsBatch 支持 isNegRisk,批量请求改为 (conditionId, indexSets, isNegRisk)
- AccountService: 赎回前按市场查询 getNegRiskByConditionId,批量与单笔均传入 isNegRisk
- docs/neg-risk-redeem.md: 新增 Neg Risk 赎回逻辑与对应 JS/TS 代码说明

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 00:11:40 +08:00
WrBugandGitHub 07c3548401 Merge pull request #30 from WrBug/feature/crypto-market-tail-session-strategy
feat(crypto-tail): 加密市场尾盘策略完整实现与优化
2026-02-15 02:07:30 +08:00
WrBugandGitHub 364db124d1 Merge pull request #29 from WrBug/dev
Release v2.2.0: Magic 账户支持、按代理地址去重、导入与回测优化
2026-02-14 01:45:48 +08:00
46 changed files with 3705 additions and 268 deletions
@@ -92,7 +92,7 @@ interface BuilderRelayerApi {
val data: String, // 调用数据(十六进制字符串,带 0x 前缀) val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
@SerializedName("nonce") @SerializedName("nonce")
val nonce: String, // Safe nonce字符串 val nonce: String? = null, // Safe nonceSAFE 必填,SAFE-CREATE 不传
@SerializedName("signature") @SerializedName("signature")
val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀) val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀)
@@ -138,7 +138,17 @@ interface BuilderRelayerApi {
val relayHub: String? = null, val relayHub: String? = null,
@SerializedName("relay") @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 交易 * 用于 Gasless 交易
*/ */
const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/" 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))
}
}
/** /**
* 查询账户详情 * 查询账户详情
*/ */
@@ -68,9 +68,9 @@ data class SystemConfigDto(
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置 val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置 val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置 val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(部分显示,用于前端展示) val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(完整,用于前端展示)
val builderSecretDisplay: String? = null, // Builder Secret 显示值(部分显示,用于前端展示) val builderSecretDisplay: String? = null, // Builder Secret 显示值(完整,用于前端展示)
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(部分显示,用于前端展示) val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(完整,用于前端展示)
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启) val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
) )
@@ -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 maxPrice: String? = null,
val amountMode: String = "RATIO", val amountMode: String = "RATIO",
val amountValue: String = "0", val amountValue: String = "0",
val minSpreadMode: String = "NONE", /** 价差模式: NONE, FIXED, AUTO */
val minSpreadValue: String? = null, val spreadMode: String = "NONE",
/** 价差数值 */
val spreadValue: String? = null,
/** 价差方向: MIN=最小价差, MAX=最大价差 */
val spreadDirection: String = "MIN",
val enabled: Boolean = true val enabled: Boolean = true
) )
@@ -32,8 +36,12 @@ data class CryptoTailStrategyUpdateRequest(
val maxPrice: String? = null, val maxPrice: String? = null,
val amountMode: String? = null, val amountMode: String? = null,
val amountValue: String? = null, val amountValue: String? = null,
val minSpreadMode: String? = null, /** 价差模式: NONE, FIXED, AUTO */
val minSpreadValue: String? = null, val spreadMode: String? = null,
/** 价差数值 */
val spreadValue: String? = null,
/** 价差方向: MIN=最小价差, MAX=最大价差 */
val spreadDirection: String? = null,
val enabled: Boolean? = null val enabled: Boolean? = null
) )
@@ -61,8 +69,12 @@ data class CryptoTailStrategyDto(
val maxPrice: String = "1", val maxPrice: String = "1",
val amountMode: String = "RATIO", val amountMode: String = "RATIO",
val amountValue: String = "0", val amountValue: String = "0",
val minSpreadMode: String = "NONE", /** 价差模式: NONE, FIXED, AUTO */
val minSpreadValue: String? = null, val spreadMode: String = "NONE",
/** 价差数值 */
val spreadValue: String? = null,
/** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */
val spreadDirection: String = "MIN",
val enabled: Boolean = true, val enabled: Boolean = true,
val lastTriggerAt: Long? = null, val lastTriggerAt: Long? = null,
/** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */ /** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */
@@ -138,7 +150,7 @@ data class CryptoTailStrategyTriggerListResponse(
) )
/** /**
* 自动最小价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7 * 自动价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7
*/ */
data class CryptoTailAutoMinSpreadResponse( data class CryptoTailAutoMinSpreadResponse(
val minSpreadUp: String = "0", val minSpreadUp: String = "0",
@@ -1,8 +1,11 @@
package com.wrbug.polymarketbot.entity 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 jakarta.persistence.*
import java.math.BigDecimal 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) @Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
val amountValue: BigDecimal = BigDecimal.ZERO, val amountValue: BigDecimal = BigDecimal.ZERO,
@Column(name = "min_spread_mode", nullable = false, length = 16) /** 价差模式: NONE=不校验, FIXED=固定值, AUTO=历史计算 */
val minSpreadMode: String = "NONE", @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) /** 价差数值(FIXED 时必填;AUTO 时可存计算值) */
val minSpreadValue: BigDecimal? = null, @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) @Column(name = "enabled", nullable = false)
val enabled: Boolean = true, 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)
}
}
/** /**
* 更新账户信息 * 更新账户信息
*/ */
@@ -728,6 +937,37 @@ class AccountService(
} }
} }
/**
* 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包为 USDC.e。
* 由 WcolUnwrapJobService 每 20 秒调用,赎回后无需在赎回流程内等待确认与解包。
*/
suspend fun runWcolUnwrapForAllAccounts() {
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
if (accounts.isEmpty()) return
for (account in accounts) {
try {
val privateKey = decryptPrivateKey(account)
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
blockchainService.unwrapWcolForProxy(
privateKey = privateKey,
proxyAddress = account.proxyAddress,
walletType = walletType
).fold(
onSuccess = { txHash ->
if (txHash != null) {
logger.info("轮询解包 WCOL: accountId=${account.id}, proxy=${account.proxyAddress.take(10)}..., txHash=$txHash")
}
},
onFailure = { e ->
logger.warn("轮询解包 WCOL 失败 accountId=${account.id}: ${e.message}")
}
)
} catch (e: Exception) {
logger.warn("轮询解包 WCOL 跳过 accountId=${account.id}: ${e.message}")
}
}
}
/** /**
* 解密账户 API Secret * 解密账户 API Secret
*/ */
@@ -1464,21 +1704,30 @@ class AccountService(
// 按市场分组(同一市场的仓位可以批量赎回) // 按市场分组(同一市场的仓位可以批量赎回)
val positionsByMarket = positions.groupBy { it.first.marketId } val positionsByMarket = positions.groupBy { it.first.marketId }
// 对每个市场执行赎回 // 获取钱包类型
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
// 解密私钥(只需解密一次)
val decryptedPrivateKey = decryptPrivateKey(account)
// 执行赎回
var lastTxHash: String? = null var lastTxHash: String? = null
for ((marketId, marketPositions) in positionsByMarket) {
val indexSets = marketPositions.map { it.second }
// 解密私钥 // Safe 钱包且有多个市场:使用 MultiSend 批量赎回
val decryptedPrivateKey = decryptPrivateKey(account) if (walletTypeEnum == WalletType.SAFE && positionsByMarket.size > 1) {
val redeemRequests = mutableListOf<Triple<String, List<BigInteger>, Boolean>>()
for ((marketId, marketPositions) in positionsByMarket) {
val indexSets = marketPositions.map { it.second }
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
redeemRequests.add(Triple(marketId, indexSets, isNegRisk))
}
// 调用区块链服务赎回仓位 logger.info("账户 $accountId: 使用 MultiSend 批量赎回 ${redeemRequests.size} 个市场")
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
val redeemResult = blockchainService.redeemPositions( val redeemResult = blockchainService.redeemPositionsBatch(
privateKey = decryptedPrivateKey, privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress, proxyAddress = account.proxyAddress,
conditionId = marketId, redeemRequests = redeemRequests,
indexSets = indexSets,
walletType = walletTypeEnum walletType = walletTypeEnum
) )
@@ -1487,11 +1736,38 @@ class AccountService(
lastTxHash = txHash lastTxHash = txHash
}, },
onFailure = { e -> onFailure = { e ->
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e) logger.error("账户 $accountId MultiSend 批量赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}")) return Result.failure(Exception("赎回失败: 账户 $accountId - ${e.message}"))
} }
) )
} else {
// Magic 钱包或单个市场:逐笔赎回
for ((marketId, marketPositions) in positionsByMarket) {
val indexSets = marketPositions.map { it.second }
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
val redeemResult = blockchainService.redeemPositions(
privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress,
conditionId = marketId,
indexSets = indexSets,
isNegRisk = isNegRisk,
walletType = walletTypeEnum
)
redeemResult.fold(
onSuccess = { txHash ->
lastTxHash = txHash
},
onFailure = { e ->
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
}
)
} }
}
// WCOL 解包由 WcolUnwrapJobService 每 20 秒轮询统一处理,赎回流程不再等待确认与解包
// 计算该账户的赎回总价值 // 计算该账户的赎回总价值
val accountTotalValue = redeemedInfo.fold(BigDecimal.ZERO) { sum, info -> val accountTotalValue = redeemedInfo.fold(BigDecimal.ZERO) { sum, info ->
@@ -25,6 +25,7 @@ import com.wrbug.polymarketbot.service.common.MarketPriceService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.math.BigDecimal import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/** /**
* 仓位检查服务 * 仓位检查服务
@@ -78,6 +79,9 @@ class PositionCheckService(
// 同步锁,确保订阅任务的启动和停止是线程安全的 // 同步锁,确保订阅任务的启动和停止是线程安全的
private val lock = Any() private val lock = Any()
// 防止 checkRedeemablePositions 重入:上一轮检查未完成时,新一轮轮询直接跳过
private val redeemCheckInProgress = AtomicBoolean(false)
/** /**
* 初始化服务(订阅 PositionPollingService 的事件,启动缓存清理任务) * 初始化服务(订阅 PositionPollingService 的事件,启动缓存清理任务)
*/ */
@@ -328,12 +332,17 @@ class PositionCheckService(
/** /**
* 逻辑1:处理待赎回仓位 * 逻辑1:处理待赎回仓位
https://clob.polymarket.com * 按照以下逻辑处理: * 按照以下逻辑处理:
* 1. 无待赎回仓位:跳过 * 1. 无待赎回仓位:跳过
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件 * 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑 * 3. (已配置) && 有待赎回的仓位:处理订单逻辑
* 防重入:上一轮检查未完成时,本轮直接跳过,避免并发赎回。
*/ */
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) { private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
if (!redeemCheckInProgress.compareAndSet(false, true)) {
logger.debug("跳过本次待赎回仓位检查:上一次检查尚未完成")
return
}
try { try {
// 1. 无待赎回仓位:跳过 // 1. 无待赎回仓位:跳过
if (redeemablePositions.isEmpty()) { if (redeemablePositions.isEmpty()) {
@@ -374,6 +383,13 @@ class PositionCheckService(
return // 未配置时直接返回,不进行后续处理 return // 未配置时直接返回,不进行后续处理
} }
// Builder Relayer 配额冷却期内不再发起赎回(如 API 返回 quota exceeded, resets in N seconds
if (relayClientService.isBuilderRelayerQuotaBlocked()) {
val remaining = relayClientService.getBuilderRelayerQuotaBlockedRemainingSeconds()
logger.info("Builder Relayer 配额冷却中,跳过本次自动赎回,约 ${remaining} 秒后恢复")
return
}
// 3. (已配置) && 有待赎回的仓位:处理订单逻辑 // 3. (已配置) && 有待赎回的仓位:处理订单逻辑
// 自动赎回已开启且已配置 API Key,按账户分组进行赎回处理 // 自动赎回已开启且已配置 API Key,按账户分组进行赎回处理
// 先执行赎回,赎回成功后再查找订单并更新订单状态 // 先执行赎回,赎回成功后再查找订单并更新订单状态
@@ -451,6 +467,8 @@ class PositionCheckService(
} }
} catch (e: Exception) { } catch (e: Exception) {
logger.error("处理待赎回仓位异常: ${e.message}", e) logger.error("处理待赎回仓位异常: ${e.message}", e)
} finally {
redeemCheckInProgress.set(false)
} }
} }
@@ -0,0 +1,51 @@
package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.service.system.RelayClientService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
/**
* WCOL 解包轮询任务
* 每 20 秒轮询一次,遍历所有账户的代理地址:若 WCOL 余额 > 0 则解包为 USDC.e。
* 同一时间仅允许单次执行;若上次执行未结束则本次忽略(与现有轮询逻辑一致)。
* 若未配置 Builder API Key,直接跳过本轮(解包依赖 Relayer Gasless,未配置则无法执行)。
*/
@Service
class WcolUnwrapJobService(
private val accountService: AccountService,
private val relayClientService: RelayClientService
) {
private val logger = LoggerFactory.getLogger(WcolUnwrapJobService::class.java)
private val scope = kotlinx.coroutines.CoroutineScope(Dispatchers.IO + SupervisorJob())
private var unwrapJob: Job? = null
/**
* 每 20 秒触发一次;若未配置 Builder Key 或当前任务仍在执行则跳过本次
*/
@Scheduled(fixedRate = 20_000)
fun runWcolUnwrapPolling() {
if (!relayClientService.isBuilderApiKeyConfigured()) {
logger.debug("Builder API Key 未配置,跳过 WCOL 解包轮询")
return
}
if (unwrapJob?.isActive == true) {
logger.debug("上一轮 WCOL 解包任务仍在执行,跳过本次")
return
}
unwrapJob = scope.launch {
try {
accountService.runWcolUnwrapForAllAccounts()
} catch (e: Exception) {
logger.error("WCOL 解包轮询异常: ${e.message}", e)
} finally {
unwrapJob = null
}
}
}
}
@@ -18,6 +18,7 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.system.RelayClientService import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.service.system.RpcNodeService import com.wrbug.polymarketbot.service.system.RpcNodeService
import kotlinx.coroutines.delay
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
@@ -55,6 +56,9 @@ class BlockchainService(
// ConditionalTokens 合约地址(Polygon 主网) // ConditionalTokens 合约地址(Polygon 主网)
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
// Neg Risk WrappedCollateral 合约地址(Polygon,解包后得 USDC.e
private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
// 空集合ID(用于计算collectionId // 空集合ID(用于计算collectionId
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000" private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
@@ -244,6 +248,62 @@ class BlockchainService(
} }
} }
/**
* 检查代理钱包是否已部署(链上有合约代码)
* @param proxyAddress 代理钱包地址
* @return 已部署返回 true
*/
suspend fun isProxyDeployed(proxyAddress: String): Boolean {
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return false
}
return isContract(proxyAddress)
}
/**
* 查询 ERC20 USDC 授权额度 allowance(owner, spender)
* @param owner 代币持有者地址(代理钱包地址)
* @param spender 被授权方地址(如 CTF Exchange
* @return 授权额度(原始值,USDC 为 6 位小数,需除以 1e6 为显示值)
*/
suspend fun getUsdcAllowance(owner: String, spender: String): Result<BigInteger> {
return try {
if (owner.isBlank() || spender.isBlank()) {
return Result.failure(IllegalArgumentException("owner 或 spender 不能为空"))
}
val rpcApi = polygonRpcApi
// ERC20 allowance(address owner, address spender) 选择器
val functionSelector = "0xdd62ed3e"
val ownerEncoded = EthereumUtils.encodeAddress(owner)
val spenderEncoded = EthereumUtils.encodeAddress(spender)
val data = functionSelector + ownerEncoded + spenderEncoded
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to usdcContractAddress,
"data" to data
),
"latest"
)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
}
val hexResult = rpcResponse.result?.asString ?: return Result.failure(Exception("RPC 响应 result 为空"))
val allowance = EthereumUtils.decodeUint256(hexResult)
Result.success(allowance)
} catch (e: Exception) {
logger.warn("查询 USDC 授权额度失败: ${e.message}")
Result.failure(e)
}
}
/** /**
* 查询账户 USDC 余额 * 查询账户 USDC 余额
* 通过 Polygon RPC 查询 ERC-20 代币余额 * 通过 Polygon RPC 查询 ERC-20 代币余额
@@ -587,6 +647,7 @@ class BlockchainService(
* @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址) * @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址)
* @param conditionId 市场条件IDbytes32,必须是 0x 开头的 66 位十六进制字符串) * @param conditionId 市场条件IDbytes32,必须是 0x 开头的 66 位十六进制字符串)
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex * @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex
* @param isNegRisk 是否为 Neg Risk 市场(true 时使用 WrappedCollateral 作为抵押品)
* @param walletType 钱包类型:MAGIC 或 SAFE,用于选择执行路径 * @param walletType 钱包类型:MAGIC 或 SAFE,用于选择执行路径
* @return 交易哈希 * @return 交易哈希
*/ */
@@ -595,6 +656,7 @@ class BlockchainService(
proxyAddress: String, proxyAddress: String,
conditionId: String, conditionId: String,
indexSets: List<BigInteger>, indexSets: List<BigInteger>,
isNegRisk: Boolean = false,
walletType: WalletType = WalletType.SAFE walletType: WalletType = WalletType.SAFE
): Result<String> { ): Result<String> {
return try { return try {
@@ -608,7 +670,7 @@ class BlockchainService(
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址")) return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
} }
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets) val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType) relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
} catch (e: Exception) { } catch (e: Exception) {
logger.error("赎回仓位失败: ${e.message}", e) logger.error("赎回仓位失败: ${e.message}", e)
@@ -616,6 +678,183 @@ class BlockchainService(
} }
} }
/**
* 批量赎回多个市场的仓位(使用 MultiSend 合并为一笔交易)
* 仅支持 Safe 钱包类型,Magic 钱包不支持 MultiSend
*
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
* @param proxyAddress 代理地址(Safe 代理钱包地址)
* @param redeemRequests 赎回请求列表,每个元素是 (conditionId, indexSets, isNegRisk)
* @param walletType 钱包类型:仅支持 SAFE
* @return 交易哈希
*/
suspend fun redeemPositionsBatch(
privateKey: String,
proxyAddress: String,
redeemRequests: List<Triple<String, List<BigInteger>, Boolean>>,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
if (redeemRequests.isEmpty()) {
return Result.failure(IllegalArgumentException("redeemRequests 不能为空"))
}
// Magic 钱包不支持 MultiSend
if (walletType == WalletType.MAGIC) {
return Result.failure(IllegalArgumentException("Magic 钱包不支持 MultiSend 批量赎回,请使用逐笔赎回"))
}
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 验证所有 conditionId 格式
for ((conditionId, _, _) in redeemRequests) {
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误: $conditionId"))
}
}
// 创建每个市场的赎回交易(Neg Risk 市场使用 WrappedCollateral
val redeemTxs = redeemRequests.map { (conditionId, indexSets, isNegRisk) ->
if (indexSets.isEmpty()) {
throw IllegalArgumentException("indexSets 不能为空: $conditionId")
}
relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
}
// 使用 MultiSend 合并所有交易
val multiSendTx = relayClientService.createMultiSendTx(redeemTxs)
logger.info("批量赎回: 合并 ${redeemRequests.size} 个市场为一笔交易")
relayClientService.execute(privateKey, proxyAddress, multiSendTx, walletType)
} catch (e: Exception) {
logger.error("批量赎回仓位失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 轮询等待交易上链并确认成功
* @param txHash 交易 hash0x 开头)
* @param maxWaitMs 最大等待毫秒数
* @param pollIntervalMs 轮询间隔毫秒数
* @return 成功返回 Unit,超时或 revert 返回 Result.failure
*/
suspend fun waitForTransactionConfirmed(
txHash: String,
maxWaitMs: Long = 120_000,
pollIntervalMs: Long = 3_000
): Result<Unit> {
val rpcApi = polygonRpcApi
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < maxWaitMs) {
val req = JsonRpcRequest(method = "eth_getTransactionReceipt", params = listOf(txHash))
val response = rpcApi.call(req)
if (!response.isSuccessful || response.body() == null) {
delay(pollIntervalMs)
continue
}
val body = response.body()!!
if (body.error != null) {
delay(pollIntervalMs)
continue
}
val result = body.result
if (result == null || result.isJsonNull) {
delay(pollIntervalMs)
continue
}
val status = result.asJsonObject?.get("status")?.asString
if (status == null) {
delay(pollIntervalMs)
continue
}
return when (status) {
"0x1" -> Result.success(Unit)
"0x0" -> Result.failure(Exception("交易已上链但执行失败 (revert)"))
else -> Result.failure(Exception("交易状态异常: $status"))
}
}
return Result.failure(Exception("等待交易确认超时 (${maxWaitMs}ms)"))
}
/**
* 查询代理地址的 WCOLWrapped Collateral)余额(raw6 位小数)
*/
suspend fun getWcolBalance(proxyAddress: String): Result<BigInteger> {
val rpcApi = polygonRpcApi
val functionSelector = "0x70a08231" // balanceOf(address)
val paddedAddress = proxyAddress.removePrefix("0x").lowercase().padStart(64, '0')
val data = functionSelector + paddedAddress
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to wcolContractAddress,
"data" to data
),
"latest"
)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("查询 WCOL 余额失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("查询 WCOL 余额失败: ${rpcResponse.error.message}"))
}
val hexBalance = rpcResponse.result?.asString ?: return Result.failure(Exception("WCOL 余额结果为空"))
val balance = EthereumUtils.decodeUint256(hexBalance)
return Result.success(balance)
}
/**
* 将代理钱包内的 WCOL 解包为 USDC.e(解包后转入代理地址)
* 赎回 Neg Risk 仓位后到账为 WCOL,调用此方法可转为 USDC.e 以便显示/使用。
*
* Safe 与 Magic 使用同一套逻辑:同一 [createUnwrapWcolTx] + [RelayClientService.execute]
* Safe 走 execTransactionMagic 走 PROXY 编码,最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount)USDC.e 转入 proxyAddress。
*
* @param privateKey 主钱包私钥
* @param proxyAddress 代理地址(Safe 或 Magic 代理)
* @param walletType 钱包类型(SAFE / MAGIC),用于选择 Relayer 执行路径
* @return 成功返回交易 hash,余额为 0 返回 null,失败返回 Result.failure
*/
suspend fun unwrapWcolForProxy(
privateKey: String,
proxyAddress: String,
walletType: WalletType
): Result<String?> {
return try {
val balanceResult = getWcolBalance(proxyAddress)
val balance = balanceResult.getOrElse {
logger.warn("查询 WCOL 余额失败,跳过解包: ${it.message}")
return Result.success(null)
}
if (balance == BigInteger.ZERO) {
return Result.success(null)
}
val unwrapTx = relayClientService.createUnwrapWcolTx(proxyAddress, balance)
val executeResult = relayClientService.execute(privateKey, proxyAddress, unwrapTx, walletType)
executeResult.fold(
onSuccess = { txHash ->
logger.info("WCOL 解包成功: proxy=${proxyAddress.take(10)}..., txHash=$txHash")
Result.success(txHash)
},
onFailure = { e ->
logger.error("WCOL 解包失败: ${e.message}", e)
Result.failure(e)
}
)
} catch (e: Exception) {
logger.error("WCOL 解包异常: ${e.message}", e)
Result.failure(e)
}
}
/** /**
* 获取代理钱包的 nonce(用于构建 Safe 交易) * 获取代理钱包的 nonce(用于构建 Safe 交易)
*/ */
@@ -3,12 +3,14 @@ package com.wrbug.polymarketbot.service.cryptotail
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
import com.wrbug.polymarketbot.constants.PolymarketConstants import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.entity.CryptoTailStrategy import com.wrbug.polymarketbot.entity.CryptoTailStrategy
import com.wrbug.polymarketbot.enums.SpreadMode
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.fromJson import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.toJson import com.wrbug.polymarketbot.util.toJson
import com.wrbug.polymarketbot.util.toSafeBigDecimal import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -135,8 +137,13 @@ class CryptoTailOrderbookWsService(
val assetId = (json.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: return val assetId = (json.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: return
val bids = json.get("bids") as? com.google.gson.JsonArray val bids = json.get("bids") as? com.google.gson.JsonArray
if (bids == null || bids.isEmpty) return if (bids == null || bids.isEmpty) return
val firstBid = bids.get(0) as? com.google.gson.JsonObject // Polymarket book 的 bids 为价格升序,bids[0] 为最低买价;bestBid 应取最高买价
val bestBid = (firstBid?.get("price") as? com.google.gson.JsonPrimitive)?.asString?.toSafeBigDecimal() var bestBid: BigDecimal? = null
for (i in 0 until bids.size()) {
val level = bids.get(i) as? com.google.gson.JsonObject ?: continue
val p = (level.get("price") as? com.google.gson.JsonPrimitive)?.asString?.toSafeBigDecimal() ?: continue
if (bestBid == null || p.gt(bestBid)) bestBid = p
}
if (bestBid != null) onBestBid(assetId, bestBid) if (bestBid != null) onBestBid(assetId, bestBid)
} }
@@ -156,10 +163,7 @@ class CryptoTailOrderbookWsService(
private fun onBestBid(tokenId: String, bestBid: BigDecimal) { private fun onBestBid(tokenId: String, bestBid: BigDecimal) {
if (closedForNoStrategies.get()) return if (closedForNoStrategies.get()) return
val entries = tokenToEntries.get()[tokenId] val entries = tokenToEntries.get()[tokenId]
if (entries == null) { if (entries == null) return
logger.debug("tokenToEntries null: $tokenId")
return
}
val nowSeconds = System.currentTimeMillis() / 1000 val nowSeconds = System.currentTimeMillis() / 1000
for (e in entries) { for (e in entries) {
val windowStart = e.periodStartUnix + e.strategy.windowStartSeconds val windowStart = e.periodStartUnix + e.strategy.windowStartSeconds
@@ -225,7 +229,7 @@ class CryptoTailOrderbookWsService(
} }
if (oldTokenIds == tokenIds.toSet()) { if (oldTokenIds == tokenIds.toSet()) {
scheduleRefreshAtPeriodEnd(newMap) scheduleRefreshAtPeriodEnd(newMap)
precomputeAutoMinSpreadForCurrentPeriods(newMap) precomputeAutoSpreadForCurrentPeriods(newMap)
return return
} }
closeWebSocketAndReconnect() closeWebSocketAndReconnect()
@@ -244,7 +248,7 @@ class CryptoTailOrderbookWsService(
return return
} }
scheduleRefreshAtPeriodEnd(newMap) scheduleRefreshAtPeriodEnd(newMap)
precomputeAutoMinSpreadForCurrentPeriods(newMap) precomputeAutoSpreadForCurrentPeriods(newMap)
} }
/** /**
@@ -264,11 +268,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() val autoPeriods = newMap.values.asSequence().flatten()
.filter { it.strategy.minSpreadMode.uppercase() == "AUTO" } .filter { it.strategy.spreadMode == SpreadMode.AUTO }
.distinctBy { "${it.strategy.intervalSeconds}-${it.periodStartUnix}" } .distinctBy { "${it.strategy.intervalSeconds}-${it.periodStartUnix}" }
.map { it.strategy.intervalSeconds to it.periodStartUnix } .map { it.strategy.intervalSeconds to it.periodStartUnix }
.toList() .toList()
@@ -6,6 +6,8 @@ import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.entity.Account import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.entity.CryptoTailStrategy import com.wrbug.polymarketbot.entity.CryptoTailStrategy
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger 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.AccountRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
@@ -34,6 +36,9 @@ import java.util.regex.Pattern
/** 尾盘策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */ /** 尾盘策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */
private const val TRIGGER_FIXED_PRICE = "0.99" private const val TRIGGER_FIXED_PRICE = "0.99"
/** 最大价差模式(MAX)时,买入价格调整系数(加在触发价格上) */
private const val SPREAD_MAX_PRICE_ADJUSTMENT = "0.02"
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */ /** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
private const val SIZE_DECIMAL_SCALE = 2 private const val SIZE_DECIMAL_SCALE = 2
@@ -190,51 +195,58 @@ class CryptoTailStrategyExecutionService(
val closePrice = oc?.second?.toPlainString() ?: "-" val closePrice = oc?.second?.toPlainString() ?: "-"
val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "尾盘策略-${strategy.marketSlugPrefix}" val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "尾盘策略-${strategy.marketSlugPrefix}"
val direction = if (outcomeIndex == 0) "Up" else "Down" val direction = if (outcomeIndex == 0) "Up" else "Down"
val modeStr = if (strategy.spreadDirection == SpreadDirection.MAX) "最大价差" else "最小价差"
logger.info( logger.info(
"尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " + "尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " + "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) ensurePeriodContext(strategy, periodStartUnix, tokenIds, marketTitle)
placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid) placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid)
} }
} }
private fun passMinSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean { private fun passSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
val mode = strategy.minSpreadMode.uppercase() if (strategy.spreadMode == SpreadMode.NONE) return true
if (mode == "NONE") return true
val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix) val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix)
?: return false ?: return false
val (openP, closeP) = oc val (openP, closeP) = oc
val spreadAbs = closeP.subtract(openP).abs() val spreadAbs = closeP.subtract(openP).abs()
when (mode) {
"FIXED" -> { // 获取有效价差
val effectiveMinSpread = strategy.minSpreadValue?.takeIf { it > BigDecimal.ZERO } val effectiveSpread = when (strategy.spreadMode) {
if (effectiveMinSpread == null || effectiveMinSpread <= BigDecimal.ZERO) return true SpreadMode.FIXED -> {
return spreadAbs >= effectiveMinSpread strategy.spreadValue?.takeIf { it > BigDecimal.ZERO } ?: return true
} }
"AUTO" -> { SpreadMode.AUTO -> {
val result = computeAutoEffectiveMinSpread(strategy, periodStartUnix, outcomeIndex) ?: return true val result = computeAutoEffectiveSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
val effectiveMinSpread = result.effectiveMinSpread result.effectiveSpread.takeIf { it > BigDecimal.ZERO } ?: return true
if (effectiveMinSpread <= BigDecimal.ZERO) return true
return spreadAbs >= effectiveMinSpread
} }
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( private data class AutoSpreadResult(
val baseSpread: BigDecimal, val baseSpread: BigDecimal,
val coefficient: 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) val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(strategy.intervalSeconds, periodStartUnix, outcomeIndex)
?: binanceKlineAutoSpreadService.computeAndCache(strategy.intervalSeconds, periodStartUnix)?.let { if (outcomeIndex == 0) it.first else it.second } ?: binanceKlineAutoSpreadService.computeAndCache(strategy.intervalSeconds, periodStartUnix)?.let { if (outcomeIndex == 0) it.first else it.second }
?: return null ?: return null
@@ -251,8 +263,8 @@ class CryptoTailStrategyExecutionService(
.let { p -> maxOf(BigDecimal.ZERO, minOf(BigDecimal.ONE, p)) } .let { p -> maxOf(BigDecimal.ZERO, minOf(BigDecimal.ONE, p)) }
BigDecimal.ONE.subtract(progress.multi("0.5")) BigDecimal.ONE.subtract(progress.multi("0.5"))
} }
val effectiveMinSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP) val effectiveSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP)
return AutoSpreadResult(baseSpread, coefficient, effectiveMinSpread) return AutoSpreadResult(baseSpread, coefficient, effectiveSpread)
} }
private suspend fun placeOrderForTrigger( private suspend fun placeOrderForTrigger(
@@ -284,7 +296,15 @@ class CryptoTailStrategyExecutionService(
return 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 size = computeSize(amountUsdc, price)
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0" val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
val signedOrder = orderSigningService.createAndSignOrder( val signedOrder = orderSigningService.createAndSignOrder(
@@ -292,7 +312,7 @@ class CryptoTailStrategyExecutionService(
makerAddress = ctx.account.proxyAddress, makerAddress = ctx.account.proxyAddress,
tokenId = tokenId, tokenId = tokenId,
side = "BUY", side = "BUY",
price = TRIGGER_FIXED_PRICE, price = priceStr,
size = size, size = size,
signatureType = ctx.signatureType, signatureType = ctx.signatureType,
nonce = "0", nonce = "0",
@@ -335,7 +355,7 @@ class CryptoTailStrategyExecutionService(
failReason = body.errorMsg ?: "unknown" failReason = body.errorMsg ?: "unknown"
} else { } else {
val errorBody = response.errorBody()?.string().orEmpty() val errorBody = response.errorBody()?.string().orEmpty()
failReason = "HTTP ${response.code()} $errorBody" failReason = errorBody.ifEmpty { "请求失败" }
} }
} catch (e: Exception) { } catch (e: Exception) {
failReason = e.message ?: e.toString() failReason = e.message ?: e.toString()
@@ -380,7 +400,16 @@ class CryptoTailStrategyExecutionService(
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界") saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界")
return 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 size = computeSize(amountUsdc, price)
val decryptedKey = try { val decryptedKey = try {
@@ -405,7 +434,7 @@ class CryptoTailStrategyExecutionService(
makerAddress = account.proxyAddress, makerAddress = account.proxyAddress,
tokenId = tokenId, tokenId = tokenId,
side = "BUY", side = "BUY",
price = TRIGGER_FIXED_PRICE, price = priceStr,
size = size, size = size,
signatureType = signatureType, signatureType = signatureType,
nonce = "0", nonce = "0",
@@ -4,6 +4,8 @@ import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.CryptoTailStrategy import com.wrbug.polymarketbot.entity.CryptoTailStrategy
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
import com.wrbug.polymarketbot.enums.ErrorCode 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.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
@@ -62,12 +64,18 @@ class CryptoTailStrategyService(
if (amountValue <= BigDecimal.ZERO) { if (amountValue <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
} }
val minSpreadMode = (request.minSpreadMode ?: "NONE").uppercase() val spreadMode = try {
if (minSpreadMode != "NONE" && minSpreadMode != "FIXED" && minSpreadMode != "AUTO") { SpreadMode.fromString(request.spreadMode)
} catch (e: Exception) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
} }
val minSpreadValue = request.minSpreadValue?.toSafeBigDecimal() val spreadValue = request.spreadValue?.toSafeBigDecimal()
if (minSpreadMode == "FIXED" && (minSpreadValue == null || minSpreadValue < BigDecimal.ZERO)) { 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)) return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
} }
@@ -85,8 +93,9 @@ class CryptoTailStrategyService(
maxPrice = maxPrice, maxPrice = maxPrice,
amountMode = amountMode, amountMode = amountMode,
amountValue = amountValue, amountValue = amountValue,
minSpreadMode = minSpreadMode, spreadMode = spreadMode,
minSpreadValue = minSpreadValue, spreadValue = spreadValue,
spreadDirection = spreadDirection,
enabled = request.enabled enabled = request.enabled
) )
val saved = strategyRepository.save(entity) val saved = strategyRepository.save(entity)
@@ -121,13 +130,27 @@ class CryptoTailStrategyService(
?: existing.name?.takeIf { it.isNotBlank() } ?: existing.name?.takeIf { it.isNotBlank() }
?: generateStrategyName(existing.marketSlugPrefix) ?: generateStrategyName(existing.marketSlugPrefix)
val newMinSpreadMode = request.minSpreadMode?.uppercase() ?: existing.minSpreadMode val newSpreadMode = if (request.spreadMode != null) {
if (newMinSpreadMode != "NONE" && newMinSpreadMode != "FIXED" && newMinSpreadMode != "AUTO") { 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)) return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
} }
val newMinSpreadValue = request.minSpreadValue?.toSafeBigDecimal() ?: existing.minSpreadValue val newSpreadDirection = if (request.spreadDirection != null) {
if (newMinSpreadMode == "FIXED" && (newMinSpreadValue == null || newMinSpreadValue < BigDecimal.ZERO)) { try {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) SpreadDirection.fromString(request.spreadDirection)
} catch (e: Exception) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
} else {
existing.spreadDirection
} }
val updated = existing.copy( val updated = existing.copy(
@@ -138,8 +161,9 @@ class CryptoTailStrategyService(
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice, maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice,
amountMode = request.amountMode?.uppercase() ?: existing.amountMode, amountMode = request.amountMode?.uppercase() ?: existing.amountMode,
amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue, amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue,
minSpreadMode = newMinSpreadMode, spreadMode = newSpreadMode,
minSpreadValue = newMinSpreadValue, spreadValue = newSpreadValue,
spreadDirection = newSpreadDirection,
enabled = request.enabled ?: existing.enabled, enabled = request.enabled ?: existing.enabled,
updatedAt = System.currentTimeMillis() updatedAt = System.currentTimeMillis()
) )
@@ -263,8 +287,9 @@ class CryptoTailStrategyService(
maxPrice = e.maxPrice.toPlainString(), maxPrice = e.maxPrice.toPlainString(),
amountMode = e.amountMode, amountMode = e.amountMode,
amountValue = e.amountValue.toPlainString(), amountValue = e.amountValue.toPlainString(),
minSpreadMode = e.minSpreadMode, spreadMode = e.spreadMode.name,
minSpreadValue = e.minSpreadValue?.toPlainString(), spreadValue = e.spreadValue?.toPlainString(),
spreadDirection = e.spreadDirection.name,
enabled = e.enabled, enabled = e.enabled,
lastTriggerAt = lastTriggerAt, lastTriggerAt = lastTriggerAt,
totalRealizedPnl = totalPnl?.toPlainString(), totalRealizedPnl = totalPnl?.toPlainString(),
@@ -5,12 +5,16 @@ import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.JsonRpcRequest import com.wrbug.polymarketbot.api.JsonRpcRequest
import com.wrbug.polymarketbot.constants.PolymarketConstants import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.enums.WalletType import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.util.Eip712Encoder
import com.wrbug.polymarketbot.util.EthereumUtils import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient import com.wrbug.polymarketbot.util.createClient
import kotlinx.coroutines.delay
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import retrofit2.Response
import java.math.BigInteger import java.math.BigInteger
import java.util.concurrent.atomic.AtomicLong
/** /**
* RelayClient 服务 * RelayClient 服务
@@ -20,8 +24,8 @@ import java.math.BigInteger
* 如果需要真正的 Gasless 功能需要集成 Builder Relayer API * 如果需要真正的 Gasless 功能需要集成 Builder Relayer API
* *
* 参考 * 参考
* - TypeScript: @polymarket/builder-relayer-client * - TypeScript: https://github.com/Polymarket/builder-relayer-clientclient.execute、src/encode/safe.ts MultiSend
* - TypeScript: utils/redeem.ts * - 赎回 calldata 由本服务构建官方仓库无 redeem 工具Neg Risk 逻辑见 docs/neg-risk-redeem.md
*/ */
@Service @Service
class RelayClientService( class RelayClientService(
@@ -35,9 +39,12 @@ class RelayClientService(
// ConditionalTokens 合约地址 // ConditionalTokens 合约地址
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
// USDC.e 合约地址 // USDC.e 合约地址(普通市场抵押品)
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// Neg Risk 市场使用的 WrappedCollateral 合约地址(Polygonneg-risk-ctf-adapter
private val negRiskWrappedCollateralAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
// 空集合ID // 空集合ID
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000" private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
@@ -46,15 +53,75 @@ class RelayClientService(
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494" private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
private val defaultProxyGasLimit = "10000000" private val defaultProxyGasLimit = "10000000"
// Safe MultiSend 合约地址(Polygon 主网)
private val safeMultisendAddress = "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761"
// Builder Relayer API 交易类型常量 // Builder Relayer API 交易类型常量
private val RELAYER_TYPE_PROXY = "PROXY" private val RELAYER_TYPE_PROXY = "PROXY"
private val RELAYER_TYPE_SAFE = "SAFE" 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 { private val polygonRpcApi: EthereumRpcApi by lazy {
val rpcUrl = rpcNodeService.getHttpUrl() val rpcUrl = rpcNodeService.getHttpUrl()
retrofitFactory.createEthereumRpcApi(rpcUrl) retrofitFactory.createEthereumRpcApi(rpcUrl)
} }
/** 遇到 429 限流时的重试次数 */
private val builderRelayerRateLimitMaxAttempts = 3
/** 429 限流重试退避基数(毫秒),第 n 次重试等待 baseMs * 2^(n-1) */
private val builderRelayerRateLimitBackoffMs = 2000L
/** Builder Relayer 配额用尽后的冷却截止时间(毫秒时间戳),在此时间前不再发起赎回 */
private val builderRelayerQuotaBlockedUntilMs = AtomicLong(0)
/**
* 是否处于 Builder Relayer 配额冷却期配额用尽后在该时间内不再发起赎回
*/
fun isBuilderRelayerQuotaBlocked(): Boolean = System.currentTimeMillis() < builderRelayerQuotaBlockedUntilMs.get()
/**
* 配额冷却剩余秒数未在冷却期时返回 0
*/
fun getBuilderRelayerQuotaBlockedRemainingSeconds(): Long {
val remaining = (builderRelayerQuotaBlockedUntilMs.get() - System.currentTimeMillis()) / 1000
return maxOf(0, remaining)
}
/**
* API 错误响应中解析 "quota exceeded... resets in N seconds"并设置配额冷却截止时间
*/
private fun updateQuotaBlockedFromErrorBody(errorBody: String) {
if (!errorBody.contains("quota exceeded", ignoreCase = true)) return
val regex = Regex("resets\\s+in\\s+(\\d+)\\s+seconds", RegexOption.IGNORE_CASE)
regex.find(errorBody)?.groupValues?.getOrNull(1)?.toLongOrNull()?.let { seconds ->
val untilMs = System.currentTimeMillis() + seconds * 1000
builderRelayerQuotaBlockedUntilMs.set(untilMs)
logger.warn("Builder Relayer 配额已用尽,${seconds}秒内不再发起赎回")
}
}
/**
* Builder Relayer API 调用进行 429 限流重试指数退避
* HTTP 状态为 429Too Many Requests Cloudflare 1015时等待后重试避免瞬时限流导致赎回失败
*/
private suspend fun <T> withBuilderRelayerRateLimitRetry(block: suspend () -> Response<T>): Response<T> {
var lastResponse: Response<T>? = null
for (attempt in 1..builderRelayerRateLimitMaxAttempts) {
val response = block()
lastResponse = response
if (response.code() != 429) return response
if (attempt == builderRelayerRateLimitMaxAttempts) return response
val delayMs = builderRelayerRateLimitBackoffMs * (1L shl (attempt - 1))
logger.warn("Builder Relayer API 限流(429)${delayMs}ms 后重试 (${attempt}/${builderRelayerRateLimitMaxAttempts})")
delay(delayMs)
}
return lastResponse!!
}
/** /**
* 获取 Builder Relayer API 客户端动态获取因为配置可能更新 * 获取 Builder Relayer API 客户端动态获取因为配置可能更新
*/ */
@@ -125,6 +192,7 @@ class RelayClientService(
Result.success(responseTime) Result.success(responseTime)
} else { } else {
val errorBody = response.errorBody()?.string() ?: "未知错误" val errorBody = response.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody")) Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -170,19 +238,22 @@ class RelayClientService(
/** /**
* 创建赎回交易支持多个 indexSets用于批量赎回 * 创建赎回交易支持多个 indexSets用于批量赎回
* 参考 TypeScript: utils/redeem.ts createRedeemTx * 参考 TypeScript: utils/redeem.ts createRedeemTx
* Neg Risk 市场使用 WrappedCollateral 作为抵押品需传 isNegRisk=true
* *
* @param conditionId 市场条件ID * @param conditionId 市场条件ID
* @param indexSets 索引集合列表每个元素是 2^outcomeIndex * @param indexSets 索引集合列表每个元素是 2^outcomeIndex
* @param isNegRisk 是否为 Neg Risk 市场true 时使用 WrappedCollateral 地址
* @return Safe 交易对象 * @return Safe 交易对象
*/ */
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>): SafeTransaction { fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>, isNegRisk: Boolean = false): SafeTransaction {
// 编码 redeemPositions 函数调用 // 编码 redeemPositions 函数调用
val functionSelector = EthereumUtils.getFunctionSelector( val functionSelector = EthereumUtils.getFunctionSelector(
"redeemPositions(address,bytes32,bytes32,uint256[])" "redeemPositions(address,bytes32,bytes32,uint256[])"
) )
// 编码参数 // Neg Risk 市场仓位由 WrappedCollateral 抵押,普通市场由 USDC 抵押
val encodedCollateral = EthereumUtils.encodeAddress(usdcContractAddress) val collateralAddress = if (isNegRisk) negRiskWrappedCollateralAddress else usdcContractAddress
val encodedCollateral = EthereumUtils.encodeAddress(collateralAddress)
val encodedParentCollection = EthereumUtils.encodeBytes32(EMPTY_SET) val encodedParentCollection = EthereumUtils.encodeBytes32(EMPTY_SET)
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId) val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
@@ -210,6 +281,115 @@ class RelayClientService(
) )
} }
/**
* 创建 WCOL 解包交易 Wrapped Collateral 解包为 USDC.e
* 合约: Neg Risk WrappedCollateral 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2
* 方法: unwrap(address _to, uint256 _amount)解包后 USDC.e 转到 _to
*
* Safe Magic 共用此交易对象Safe [executeViaBuilderRelayer] / [executeManually]execTransaction
* Magic [executeViaBuilderRelayerProxy]encodeProxyTransactionData语义一致
*
* @param toAddress 接收 USDC.e 的地址通常为 proxy 自身使余额留在代理钱包
* @param amountWei WCOL 数量6 位小数对应的 raw balanceOf 返回一致
* @return Safe 交易对象
*/
fun createUnwrapWcolTx(toAddress: String, amountWei: BigInteger): SafeTransaction {
val functionSelector = EthereumUtils.getFunctionSelector("unwrap(address,uint256)")
val encodedTo = EthereumUtils.encodeAddress(toAddress)
val encodedAmount = EthereumUtils.encodeUint256(amountWei)
val callData = "0x" + functionSelector.removePrefix("0x") + encodedTo + encodedAmount
return SafeTransaction(
to = negRiskWrappedCollateralAddress,
operation = 0, // CALL
data = callData,
value = "0"
)
}
/**
* 创建 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
*
* 使用 Gnosis Safe MultiSend 合约将多个交易合并为一笔 DelegateCall 交易
*
* @param safeTxs 多个 Safe 交易
* @return 合并后的 MultiSend 交易operation = 1 = DelegateCall
*/
fun createMultiSendTx(safeTxs: List<SafeTransaction>): SafeTransaction {
if (safeTxs.isEmpty()) {
throw IllegalArgumentException("safeTxs 不能为空")
}
// 单个交易直接返回,不需要 MultiSend
if (safeTxs.size == 1) {
logger.debug("单个交易,不使用 MultiSend")
return safeTxs.first()
}
logger.debug("创建 MultiSend 交易: ${safeTxs.size} 个交易待合并")
// MultiSend 函数选择器:multiSend(bytes)
val multiSendSelector = EthereumUtils.getFunctionSelector("multiSend(bytes)")
// 编码每个交易:encodePacked([uint8 operation, address to, uint256 value, uint256 dataLength, bytes data])
// 与 builder-relayer-client encode/safe.ts 完全一致
val encodedTransactions = safeTxs.map { tx ->
val operation = tx.operation.toByte()
// address: 20 字节,右对齐(取最后 40 个十六进制字符)
val toHex = tx.to.removePrefix("0x").lowercase().padStart(40, '0').takeLast(40)
val to = EthereumUtils.hexToBytes(toHex)
// value: 32 字节大端
val valueHex = BigInteger(tx.value).toString(16).padStart(64, '0')
val value = EthereumUtils.hexToBytes(valueHex)
val dataBytes = EthereumUtils.hexToBytes(tx.data.removePrefix("0x"))
// dataLength: 32 字节大端,表示 data 的字节数
val dataLengthHex = BigInteger.valueOf(dataBytes.size.toLong()).toString(16).padStart(64, '0')
val dataLength = EthereumUtils.hexToBytes(dataLengthHex)
// encodePacked: operation(1) + to(20) + value(32) + dataLength(32) + data(variable)
byteArrayOf(operation) + to + value + dataLength + dataBytes
}
// 拼接所有交易(无 padding,与 viem concatHex 一致)
val concatenatedTransactions = encodedTransactions.reduce { acc, bytes -> acc + bytes }
val totalDataLength = concatenatedTransactions.size
// multiSend(bytes) 的 ABI 编码:offset(32) + length(32) + data(按 32 字节对齐 padding)
val paddedLength = ((totalDataLength + 31) / 32) * 32
val paddedData = concatenatedTransactions + ByteArray(paddedLength - totalDataLength)
val encodedOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
val encodedLength = EthereumUtils.encodeUint256(BigInteger.valueOf(totalDataLength.toLong()))
val encodedData = paddedData.joinToString("") { "%02x".format(it) }
val callData = "0x" + multiSendSelector.removePrefix("0x") + encodedOffset + encodedLength + encodedData
return SafeTransaction(
to = safeMultisendAddress,
operation = 1, // DelegateCall
data = callData,
value = "0"
)
}
/** /**
* 执行代理交易Safe Magic PROXY * 执行代理交易Safe Magic PROXY
* 参考 TypeScript: RelayClient.execute() * 参考 TypeScript: RelayClient.execute()
@@ -294,9 +474,10 @@ class RelayClientService(
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16)) val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
val fromAddress = credentials.address val fromAddress = credentials.address
val relayPayloadResponse = relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY) val relayPayloadResponse = withBuilderRelayerRateLimitRetry { relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY) }
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) { if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误" val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody") logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody")
return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody")) return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody"))
} }
@@ -360,9 +541,10 @@ class RelayClientService(
metadata = "Redeem positions via Builder Relayer PROXY" metadata = "Redeem positions via Builder Relayer PROXY"
) )
val response = relayerApi.submitTransaction(request) val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
if (!response.isSuccessful || response.body() == null) { if (!response.isSuccessful || response.body() == null) {
val errorBody = response.errorBody()?.string() ?: "未知错误" val errorBody = response.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody") logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody")
return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody")) return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody"))
} }
@@ -524,15 +706,27 @@ class RelayClientService(
// safeTx.data 已经是带 0x 前缀的完整调用数据 // safeTx.data 已经是带 0x 前缀的完整调用数据
val redeemCallData = safeTx.data val redeemCallData = safeTx.data
// 获取 Proxy 的 nonce(通过 Builder Relayer API // 获取 Proxy 的 nonce(通过 Builder Relayer API,遇 429 限流时重试
val nonceResponse = relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE) val nonceResponse = withBuilderRelayerRateLimitRetry { relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE) }
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) { if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误" val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody") logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
return Result.failure(Exception("获取 nonce 失败: ${nonceResponse.code()} - $errorBody")) return Result.failure(Exception("获取 nonce 失败: ${nonceResponse.code()} - $errorBody"))
} }
val proxyNonce = BigInteger(nonceResponse.body()!!.nonce) 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 交易哈希并签名 // 构建 Safe 交易哈希并签名
// 注意:encodeSafeTx 需要 data 带 0x 前缀 // 注意:encodeSafeTx 需要 data 带 0x 前缀
val safeTxGas = BigInteger.ZERO val safeTxGas = BigInteger.ZERO
@@ -564,6 +758,12 @@ class RelayClientService(
messageHash = safeTxHash 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 前缀 // 注意:ethers.js 的 signMessage 会添加 EIP-191 前缀
// 格式:\x19Ethereum Signed Message:\n<length><message> // 格式:\x19Ethereum Signed Message:\n<length><message>
// 我们需要模拟这个行为以匹配 TypeScript 实现 // 我们需要模拟这个行为以匹配 TypeScript 实现
@@ -578,17 +778,17 @@ class RelayClientService(
val hashWithPrefix = ByteArray(keccak256.digestSize) val hashWithPrefix = ByteArray(keccak256.digestSize)
keccak256.doFinal(hashWithPrefix, 0) 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 ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false) val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig // 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig
val packedSignature = splitAndPackSig(safeSignature) val packedSignature = splitAndPackSig(safeSignature)
// 调试日志(地址已遮蔽)
logger.debug("=== Builder Relayer 签名调试 ===")
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
logger.debug("Signature Length: ${packedSignature.length}")
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts // 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀 // 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
val request = BuilderRelayerApi.TransactionRequest( val request = BuilderRelayerApi.TransactionRequest(
@@ -607,16 +807,19 @@ class RelayClientService(
gasToken = gasToken, gasToken = gasToken,
refundReceiver = refundReceiver refundReceiver = refundReceiver
), ),
metadata = "Redeem positions via Builder Relayer" metadata = if (safeTx.operation == 1) {
"MultiSend redeem positions via Builder Relayer"
} else {
"Redeem positions via Builder Relayer"
}
) )
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}") // 调用 Builder Relayer API(认证头通过拦截器添加,遇 429 限流时重试)
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
// 调用 Builder Relayer API(认证头通过拦截器添加)
val response = relayerApi.submitTransaction(request)
if (!response.isSuccessful || response.body() == null) { if (!response.isSuccessful || response.body() == null) {
val errorBody = response.errorBody()?.string() ?: "未知错误" val errorBody = response.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("Builder Relayer API 调用失败: code=${response.code()}, body=$errorBody") logger.error("Builder Relayer API 调用失败: code=${response.code()}, body=$errorBody")
return Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody")) return Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
} }
@@ -629,6 +832,104 @@ class RelayClientService(
return Result.success(txHash) 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 * 打包签名参考 builder-relayer-client/src/utils/index.ts splitAndPackSig
* 将签名打包成 Gnosis Safe 接受的格式encodePacked(["uint256", "uint256", "uint8"], [r, s, v]) * 将签名打包成 Gnosis Safe 接受的格式encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
@@ -25,19 +25,6 @@ class SystemConfigService(
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret" const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase" const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem" const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
/**
* 遮蔽敏感信息仅显示前4位和后4位
* 例如abcd1234...wxyz5678
*/
fun maskSensitiveValue(value: String?): String? {
if (value == null) return null
return when {
value.length <= 8 -> "****" // 太短则完全遮蔽
value.length <= 16 -> "${value.take(2)}...${value.takeLast(2)}"
else -> "${value.take(4)}...${value.takeLast(4)}"
}
}
} }
/** /**
@@ -49,10 +36,10 @@ class SystemConfigService(
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE) val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
val autoRedeem = isAutoRedeemEnabled() val autoRedeem = isAutoRedeemEnabled()
// 获取遮蔽后的显示值(仅显示部分字符,用于前端确认配置 // 获取完整显示值(用于前端展示与编辑
val builderApiKeyDisplay = builderApiKey?.let { val builderApiKeyDisplay = builderApiKey?.let {
try { try {
maskSensitiveValue(cryptoUtils.decrypt(it)) cryptoUtils.decrypt(it)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -60,7 +47,7 @@ class SystemConfigService(
val builderSecretDisplay = builderSecret?.let { val builderSecretDisplay = builderSecret?.let {
try { try {
maskSensitiveValue(cryptoUtils.decrypt(it)) cryptoUtils.decrypt(it)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -68,7 +55,7 @@ class SystemConfigService(
val builderPassphraseDisplay = builderPassphrase?.let { val builderPassphraseDisplay = builderPassphrase?.let {
try { try {
maskSensitiveValue(cryptoUtils.decrypt(it)) cryptoUtils.decrypt(it)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -377,5 +377,62 @@ object Eip712Encoder {
return keccak256(encoded) 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)
}
} }
@@ -400,8 +400,6 @@ class RpcUrlReplaceInterceptor(
private val fixedBaseUrl: String, private val fixedBaseUrl: String,
private val actualRpcUrl: String private val actualRpcUrl: String
) : Interceptor { ) : Interceptor {
private val logger = LoggerFactory.getLogger(RpcUrlReplaceInterceptor::class.java)
@Throws(IOException::class) @Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request() val originalRequest = chain.request()
@@ -415,8 +413,6 @@ class RpcUrlReplaceInterceptor(
val newUrl = newUrlString.toHttpUrlOrNull() val newUrl = newUrlString.toHttpUrlOrNull()
?: throw IllegalArgumentException("无效的 RPC URL: $newUrlString") ?: throw IllegalArgumentException("无效的 RPC URL: $newUrlString")
logger.debug("RPC URL 替换: $originalUrlString -> $newUrlString")
val newRequest = originalRequest.newBuilder() val newRequest = originalRequest.newBuilder()
.url(newUrl) .url(newUrl)
.build() .build()
@@ -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(价差<=配置值触发)';
+32
View File
@@ -0,0 +1,32 @@
# 尾盘策略文档 (Crypto Tail Strategy)
本目录集中存放与 Polymarket 加密市场尾盘策略相关的文档。
## 目录结构
```
crypto-tail-strategy/
├── README.md # 本说明
├── crypto-tail-auto-spread-dynamic-coefficient.md # 自动价差动态系数(中英通用)
├── zh/ # 中文文档
│ ├── crypto-tail-strategy-user-guide.md # 用户配置指南
│ ├── crypto-tail-strategy-ui-spec.md # UI 规格
│ ├── crypto-tail-strategy-tasks.md # 任务与验收
│ ├── crypto-tail-strategy-flow.md # 流程说明
│ ├── crypto-tail-strategy-min-spread-flow.md # 最小/最大价差流程
│ └── crypto-tail-strategy-market-data.md # 市场数据与周期
└── en/ # 英文文档
└── crypto-tail-strategy-user-guide.md # User configuration guide
```
## 文档说明
| 文档 | 说明 |
|------|------|
| **user-guide** (zh/en) | 面向用户的策略配置指南与 FAQ |
| **ui-spec** (zh) | 前端列表、表单、时间窗口、触发记录等 UI 规格 |
| **tasks** (zh) | 开发任务与验收项 |
| **flow** (zh) | 策略整体流程 |
| **min-spread-flow** (zh) | 价差过滤(最小/最大价差)流程 |
| **market-data** (zh) | Gamma slug、周期、时间区间、价格判断等市场数据规则 |
| **auto-spread-dynamic-coefficient** | 自动价差模式下动态系数计算说明 |
@@ -0,0 +1,469 @@
# Crypto Tail Strategy Configuration Guide
## Part 1: What is Crypto Tail Strategy?
Crypto Tail Strategy is an automated trading strategy designed specifically for Polymarket crypto markets' **5-minute** or **15-minute** "Up or Down" markets.
**Core Logic**: Within a specified time window, when the market price enters your set price range, the system will automatically buy at a fixed price (0.99) without manual operation.
**Use Cases**:
- You want to capture price fluctuations at the end of market cycles
- You want to automate trading execution and avoid manual monitoring
- You have some judgment about market trends and want to set conditions for automatic triggering
---
## Part 2: How the Strategy Works
### 2.1 Basic Flow
```
Cycle Start → Within Time Window → Price Enters Range → Auto Order
```
1. **Cycle**: Each market runs on fixed cycles (5 minutes or 15 minutes)
- 5-minute market: Every 5 minutes is a cycle (e.g., 10:00, 10:05, 10:10...)
- 15-minute market: Every 15 minutes is a cycle (e.g., 10:00, 10:15, 10:30...)
2. **Time Window**: You can set a time period within the cycle
- Example: 15-minute market, set window to "3 minutes ~ 12 minutes"
- Meaning: Only triggers between the 3rd and 12th minute after cycle start
3. **Price Range**: Set the trigger price range
- Example: Minimum price 0.50, Maximum price 0.80
- Meaning: Only triggers when market price is between 0.50 ~ 0.80
4. **Auto Order**: After conditions are met, the system automatically buys at price 0.99
### 2.2 Important Limitations
- **Maximum one trigger per cycle**: Within the same cycle, even if conditions are met multiple times, only one order is placed
- **Fixed order price**: All orders are submitted at price 0.99
- **Requires separate wallet**: It's recommended to use a dedicated wallet for tail strategies to avoid conflicts with other operations (manual trading, copy trading, etc.)
---
## Part 3: Parameter Details
### 3.1 Basic Parameters
| Parameter | Description | Required | Example |
|-----------|-------------|----------|---------|
| **Account** | Select the wallet account for trading | ✅ | Account A |
| **Strategy Name** | Name your strategy for easy identification | ❌ | "BTC 15min Tail Strategy" |
| **Market** | Select the market to trade (5-minute or 15-minute) | ✅ | btc-updown-15m |
### 3.2 Cycle Settings
| Parameter | Description | Required | Example |
|-----------|-------------|----------|---------|
| **Cycle Length** | Automatically determined by selected market | ✅ | 15 minutes (900 seconds) |
| **Time Window Start** | Minutes after cycle start to begin monitoring | ✅ | 3 min 0 sec |
| **Time Window End** | Minutes after cycle start to stop monitoring | ✅ | 12 min 0 sec |
**Time Window Explanation**:
- 5-minute market: Can choose any time period within 0 ~ 5 minutes
- 15-minute market: Can choose any time period within 0 ~ 15 minutes
- **Start time must ≤ End time**
- Times outside the window won't trigger even if price conditions are met
**Example**:
- 15-minute market, window "3 min 0 sec ~ 12 min 0 sec"
- 0 ~ 3 minutes after cycle start: Not monitoring
- 3 ~ 12 minutes after cycle start: Monitoring price, triggers when conditions met
- 12 ~ 15 minutes after cycle start: Not monitoring
### 3.3 Price Range
| Parameter | Description | Required | Range | Example |
|-----------|-------------|----------|-------|---------|
| **Minimum Price (minPrice)** | Minimum trigger price | ✅ | 0 ~ 1 | 0.50 |
| **Maximum Price (maxPrice)** | Maximum trigger price | ❌ | 0 ~ 1, default 1 | 0.80 |
**Price Range Explanation**:
- Price range is a decimal between 0 ~ 1
- Only triggers when market price is within [Minimum Price, Maximum Price]
- If maximum price is not filled, defaults to 1.0 (triggers as long as price ≥ minimum price)
**Example**:
- Minimum price 0.50, Maximum price 0.80
- Price 0.45: Not triggered (below minimum)
- Price 0.60: Triggered ✅ (within range)
- Price 0.85: Not triggered (above maximum)
### 3.4 Investment Amount
| Parameter | Description | Required | Example |
|-----------|-------------|----------|---------|
| **Investment Mode** | Choose ratio or fixed amount | ✅ | Ratio / Fixed Amount |
| **Ratio (%)** | Percentage of account balance to invest | Conditionally required | 10% (Account has 100 USDC, invest 10 USDC) |
| **Fixed Amount (USDC)** | Fixed amount to invest each time | Conditionally required | 50 USDC |
**Investment Mode Explanation**:
**Mode 1: By Ratio (RATIO)**
- Each trigger invests a percentage of current available balance
- Example: Account has 100 USDC, set ratio to 10%
- 1st trigger: Invest 10 USDC
- 2nd trigger: If balance becomes 90 USDC, invest 9 USDC
- **Advantages**: Automatically adapts to account balance changes
- **Disadvantages**: Investment amount may vary each time
**Mode 2: Fixed Amount (FIXED)**
- Each trigger invests a fixed specified amount
- Example: Set fixed amount to 50 USDC
- Every trigger invests 50 USDC
- **Advantages**: Stable investment amount, easy to manage
- **Disadvantages**: Need to ensure sufficient account balance
**Notes**:
- Minimum order amount: At least 1 USDC
- If account balance is insufficient, order will fail and record failure reason
### 3.5 Spread Filter (Advanced Feature)
The spread filter controls whether to trigger based on Binance BTC/USDC K-line volatility. It supports two directions: **Minimum spread** and **Maximum spread**.
| Parameter | Description | Required | Example |
|-----------|-------------|----------|---------|
| **Spread Mode** | Choose spread validation method | ✅ | None / Fixed / Auto |
| **Spread Direction** | Min spread (trigger when ≥) or Max spread (trigger when ≤) | ✅ | Min spread / Max spread |
| **Spread Value** | Fill when using Fixed mode (unit: USDC) | Conditionally required | 30 |
**Spread Direction**:
- **Min spread**: Triggers only when Binance K-line spread **≥** the set value
- Use when you want to trade only when volatility is "large enough" (avoid entering when volatility is too small).
- **Max spread**: Triggers only when Binance K-line spread **≤** the set value
- Use when you want to trade only when volatility is "small enough" (avoid entering when volatility is too high).
**Three Spread Modes**:
**Mode 1: None (NONE)**
- No spread validation
- Triggers as long as time window and price range conditions are met
- **Suitable for**: Not concerned about Binance price volatility, only watching Polymarket price
**Mode 2: Fixed (FIXED)**
- Set a fixed spread value (unit: USDC)
- **Min spread**: Triggers when K-line spread ≥ set value
- Example: Set 30, spread ≥ 30 → triggered ✅, spread < 30 → not triggered
- **Max spread**: Triggers when K-line spread ≤ set value
- Example: Set 50, spread ≤ 50 → triggered ✅, spread > 50 → not triggered
- **Suitable for**: You have a clear spread threshold in mind
**Mode 3: Auto (AUTO)**
- System automatically calculates an effective spread from the last 20 K-lines
- Calculation logic:
1. Get recent 20 K-lines (matching strategy cycle)
2. Filter by direction (Up direction only looks at rising K-lines, Down direction only looks at falling K-lines)
3. Remove outliers (using IQR method)
4. Calculate average spread × 0.8 as effective spread
- **Min spread**: Triggers when K-line spread ≥ effective spread
- **Max spread**: Triggers when K-line spread ≤ effective spread
- **Suitable for**: Want automatic adjustment based on historical data without setting a specific value
**Spread Explanation**:
- Spread = |close price - open price| (Binance BTC/USDC for that K-line)
- Example: Open price 50000, close price 50030, spread = 30
- Larger spread means greater price volatility in that cycle
---
## Part 4: Configuration Examples
### Example 1: Simple Strategy (5-minute Market)
**Scenario**: In the last 2 minutes of a 5-minute market, if price is below 0.60, automatically buy 10 USDC
**Configuration**:
```
Account: Account A
Strategy Name: BTC 5min Simple Strategy
Market: btc-updown-5m
Time Window: 3 min 0 sec ~ 5 min 0 sec
Minimum Price: 0.00
Maximum Price: 0.60
Investment Mode: Fixed Amount
Fixed Amount: 10 USDC
Spread Mode: None
Enabled: On
```
**Explanation**:
- 0 ~ 3 minutes after cycle start: Not monitoring
- 3 ~ 5 minutes after cycle start: If price ≤ 0.60, automatically buy 10 USDC
---
### Example 2: Ratio Investment Strategy (15-minute Market)
**Scenario**: In the middle segment (5 ~ 10 minutes) of a 15-minute market, if price is between 0.40 ~ 0.70, invest 15% of account balance
**Configuration**:
```
Account: Account B
Strategy Name: BTC 15min Ratio Strategy
Market: btc-updown-15m
Time Window: 5 min 0 sec ~ 10 min 0 sec
Minimum Price: 0.40
Maximum Price: 0.70
Investment Mode: By Ratio
Ratio: 15%
Spread Mode: None
Enabled: On
```
**Explanation**:
- Assuming account balance is 100 USDC
- 5 ~ 10 minutes after cycle start: If price is between 0.40 ~ 0.70, automatically buy about 15 USDC (100 × 15%)
---
### Example 3: Strategy with Spread Filter (15-minute Market)
**Scenario**: In the latter segment (10 ~ 14 minutes) of a 15-minute market, if price is between 0.50 ~ 0.80 and Binance spread ≥ 50, invest 20 USDC
**Configuration**:
```
Account: Account C
Strategy Name: BTC 15min Spread Strategy
Market: btc-updown-15m
Time Window: 10 min 0 sec ~ 14 min 0 sec
Minimum Price: 0.50
Maximum Price: 0.80
Investment Mode: Fixed Amount
Fixed Amount: 20 USDC
Spread Mode: Fixed
Spread Direction: Min spread
Spread Value: 50
Enabled: On
```
**Explanation**:
- 10 ~ 14 minutes after cycle start: Only triggers when both conditions are met:
1. Price is between 0.50 ~ 0.80 ✅
2. Spread direction is "Min spread" and Binance spread ≥ 50 ✅
- If spread is only 30, won't trigger even if price condition is met
---
### Example 4: Auto Spread Strategy (15-minute Market)
**Scenario**: In the early segment (2 ~ 8 minutes) of a 15-minute market, if price is between 0.30 ~ 0.90, invest 20% of account balance, spread calculated automatically by system
**Configuration**:
```
Account: Account D
Strategy Name: BTC 15min Auto Spread Strategy
Market: btc-updown-15m
Time Window: 2 min 0 sec ~ 8 min 0 sec
Minimum Price: 0.30
Maximum Price: 0.90
Investment Mode: By Ratio
Ratio: 20%
Spread Mode: Auto
Spread Direction: Min spread
Enabled: On
```
**Explanation**:
- System automatically calculates effective spread from the last 20 K-lines
- 2 ~ 8 minutes after cycle start: Only triggers when both conditions are met:
1. Price is between 0.30 ~ 0.90 ✅
2. Spread direction is "Min spread" and Binance spread ≥ system-calculated effective spread ✅
---
## Part 5: Frequently Asked Questions
### Q1: When will the strategy trigger?
**A**: All of the following conditions must be met simultaneously:
1. ✅ Current time is within the time window
2. ✅ Market price is within [Minimum Price, Maximum Price] range
3. ✅ This cycle hasn't triggered yet (maximum one trigger per cycle)
4. ✅ If spread filter is set, Binance spread and spread direction must both be satisfied
### Q2: Why didn't my strategy trigger?
**Possible reasons**:
1. **Time window incorrect**: Current time is not within the set time window
2. **Price not in range**: Market price is not within [Minimum Price, Maximum Price] range
3. **Already triggered this cycle**: This cycle has already triggered once, won't trigger again
4. **Spread not met**: If spread filter is set, Binance spread or spread direction requirement is not satisfied
5. **Insufficient account balance**: Account balance is less than the set investment amount
6. **Strategy not enabled**: Check if strategy's enabled status is "On"
### Q3: What does "maximum one trigger per cycle" mean?
**A**: Within each cycle (5 minutes or 15 minutes), even if conditions are met multiple times, only one order is placed.
**Example**:
- 15-minute market, cycle starts at 10:00
- At 10:05, price meets condition, triggers order ✅
- At 10:08, price meets condition again, but won't place another order (already triggered this cycle)
- At 10:15, new cycle starts, can trigger again
### Q4: What's the difference between fixed amount and ratio?
**Fixed Amount**:
- Invests the same amount each trigger
- Example: Set 50 USDC, every trigger is 50 USDC
- Need to ensure sufficient account balance
**By Ratio**:
- Invests a percentage of account balance each trigger
- Example: Set 10%, when account has 100 USDC, invest 10 USDC, after balance becomes 90 USDC, next trigger invests 9 USDC
- Automatically adapts to balance changes
### Q5: What's the use of the spread filter feature?
**A**: The spread filter decides whether to trigger based on Binance BTC/USDC K-line volatility. It supports two directions.
**Min spread** (trigger when spread **≥** set value):
- Avoids triggering when volatility is too small
- Example: Set 30, only triggers when spread ≥ 30
**Max spread** (trigger when spread **≤** set value):
- Avoids triggering when volatility is too high (lower risk)
- Example: Set 50, only triggers when spread ≤ 50
**Three mode selection suggestions**:
- **None**: Not concerned about Binance price volatility, only watching Polymarket price
- **Fixed**: You know the expected spread threshold (use with Min or Max spread direction)
- **Auto**: Want effective spread calculated from historical data without setting a specific value
### Q6: Why is it recommended to use a separate wallet?
**A**: To avoid the following issues:
1. **Balance changes**: If wallet is also used for manual trading, balance changes may affect strategy execution
2. **Position conflicts**: Manual trading and strategy trading may conflict
3. **Management confusion**: Difficult to distinguish which orders are from strategy vs manual
**Recommendation**: Create a dedicated wallet, only for tail strategies.
### Q7: Why is the order price fixed at 0.99?
**A**: This is a design feature of the strategy:
- 0.99 is the highest price in the market (close to 1.0)
- Buying at the highest price ensures orders execute quickly
- Although buying price is higher, the strategy's core is capturing market volatility, not pursuing optimal price
### Q8: Does the strategy depend on auto-redeem functionality?
**A**: Yes, tail strategy depends on auto-redeem functionality.
**Reasons**:
- Strategy orders create positions after execution
- These positions need to be automatically redeemed after market settlement
- If auto-redeem is not configured, positions may not be redeemed in time
**Configuration Requirements**:
- Configure Builder API Key in "System Settings"
- Enable auto-redeem functionality
---
## Part 6: Important Notes
### 6.1 Account Requirements
- ✅ Account must have API Key, API Secret, API Passphrase configured
- ✅ Account must have sufficient USDC balance
- ✅ Recommended to use a dedicated wallet to avoid conflicts with other operations
### 6.2 Time Window Settings
- ⚠️ Start time must ≤ End time
- ⚠️ Time window cannot exceed cycle length (5-minute market ≤ 5 minutes, 15-minute market ≤ 15 minutes)
- ⚠️ Recommended to set reasonable time windows, avoid triggering at cycle start or end
### 6.3 Price Range Settings
- ⚠️ Minimum price must ≤ Maximum price
- ⚠️ Price range is a decimal between 0 ~ 1
- ⚠️ Recommended to set reasonable price ranges based on market conditions
### 6.4 Investment Amount Settings
- ⚠️ Minimum order amount: At least 1 USDC
- ⚠️ Ensure sufficient account balance to avoid order failures
- ⚠️ Ratio mode: Note the impact of account balance changes on investment amount
### 6.5 Spread Filter Settings
- ⚠️ Spread direction: Min spread means "trigger when ≥"; Max spread means "trigger when ≤". Choose according to your need.
- ⚠️ Fixed mode: Need to fill a reasonable spread value (unit: USDC)
- ⚠️ Auto mode: System calculates effective spread within the window, no manual value needed
- ⚠️ Overly strict spread (min spread too high or max spread too low) may make the strategy rarely trigger
### 6.6 Other Notes
- ⚠️ Strategy is enabled by default after creation, can disable "Enabled Status" if need to pause
- ⚠️ Maximum one trigger per cycle, set trigger conditions reasonably
- ⚠️ Strategy depends on auto-redeem functionality, ensure Builder API Key is configured
- ⚠️ Recommended to regularly check trigger records to understand strategy execution
---
## Part 7: Strategy Management
### 7.1 View Strategy List
On the "Crypto Tail Strategy" page, you can view all strategies:
- Strategy name
- Market information
- Time window
- Price range
- Investment mode
- Enabled status
- Last trigger time
- Statistics like total profit, win rate
### 7.2 View Trigger Records
Click on a strategy to view detailed trigger records:
- Trigger time
- Market price
- Investment amount
- Order ID
- Order status (success/fail)
- Settlement information (profit/loss, win rate, etc.)
### 7.3 Edit Strategy
You can modify strategy parameters at any time:
- Time window
- Price range
- Investment mode
- Spread filter (mode, direction, value)
- Enabled status
**Note**: Modified strategies take effect in the next cycle.
### 7.4 Delete Strategy
After deleting a strategy:
- Strategy configuration is deleted
- Historical trigger records are retained
- Already placed orders are not affected
---
## Part 8: Summary
Crypto Tail Strategy is a powerful automated trading tool that can help you:
1. **Automated Trading**: No need for manual monitoring, system executes automatically
2. **Precise Control**: Precisely control trigger conditions through time windows and price ranges
3. **Flexible Configuration**: Supports both ratio and fixed amount investment modes
4. **Risk Filtering**: Control volatility conditions through spread filter (min spread / max spread)
**Usage Recommendations**:
- For first-time users, start with simple strategies (no spread filter)
- After familiarizing, try adding spread filter features
- Regularly check trigger records, adjust strategy parameters based on actual situation
- Use a dedicated wallet to avoid conflicts with other operations
**Happy Trading!** 🚀
@@ -0,0 +1,469 @@
# 尾盘策略配置指南
## 一、什么是尾盘策略?
尾盘策略是一种自动化交易策略,专门用于 Polymarket 加密市场的 **5分钟****15分钟** "Up or Down" 市场。
**核心逻辑**:在指定时间窗口内,当市场价格进入您设定的价格区间时,系统会自动以固定价格(0.99)买入,无需手动操作。
**适用场景**
- 您希望捕捉市场在周期末段的价格波动
- 您想自动化执行交易,避免手动盯盘
- 您对市场走势有一定判断,希望设置条件自动触发
---
## 二、策略工作原理
### 2.1 基本流程
```
周期开始 → 时间窗口内 → 价格进入区间 → 自动下单
```
1. **周期**:每个市场按固定周期运行(5分钟或15分钟)
- 5分钟市场:每5分钟为一个周期(如 10:00、10:05、10:10...
- 15分钟市场:每15分钟为一个周期(如 10:00、10:15、10:30...
2. **时间窗口**:您可以在周期内设置一个时间段
- 例如:15分钟市场,设置窗口为「3分钟~12分钟」
- 表示:从周期开始后第3分钟到第12分钟之间才会触发
3. **价格区间**:设置触发价格范围
- 例如:最低价 0.50,最高价 0.80
- 表示:当市场价格在 0.50~0.80 之间时才会触发
4. **自动下单**:满足条件后,系统自动以 0.99 的价格买入
### 2.2 重要限制
- **每周期最多触发一次**:同一个周期内,即使多次满足条件,也只下单一次
- **固定下单价格**:所有订单都以 0.99 的价格提交
- **需要单独钱包**:建议使用专门的钱包运行尾盘策略,避免与其他操作(手动交易、跟单等)冲突
---
## 三、参数详细说明
### 3.1 基础参数
| 参数 | 说明 | 必填 | 示例 |
|------|------|------|------|
| **账户** | 选择用于交易的钱包账户 | ✅ | 账户A |
| **策略名称** | 给策略起个名字,方便识别 | ❌ | "BTC 15分钟尾盘策略" |
| **市场** | 选择要交易的市场(5分钟或15分钟) | ✅ | btc-updown-15m |
### 3.2 周期设置
| 参数 | 说明 | 必填 | 示例 |
|------|------|------|------|
| **周期长度** | 由选择的市场自动确定 | ✅ | 15分钟(900秒) |
| **时间窗口开始** | 从周期起点算起,多少分钟后开始监听 | ✅ | 3分0秒 |
| **时间窗口结束** | 从周期起点算起,多少分钟后停止监听 | ✅ | 12分0秒 |
**时间窗口说明**
- 5分钟市场:可选 0~5 分钟内的任意时间段
- 15分钟市场:可选 0~15 分钟内的任意时间段
- **开始时间必须 ≤ 结束时间**
- 窗口外的时间即使价格满足也不会触发
**示例**
- 15分钟市场,窗口「3分0秒 ~ 12分0秒」
- 周期开始后 0~3 分钟:不监听
- 周期开始后 3~12 分钟:监听价格,满足条件即触发
- 周期开始后 12~15 分钟:不监听
### 3.3 价格区间
| 参数 | 说明 | 必填 | 取值范围 | 示例 |
|------|------|------|----------|------|
| **最低价 (minPrice)** | 触发的最低价格 | ✅ | 01 | 0.50 |
| **最高价 (maxPrice)** | 触发的最高价格 | ❌ | 0~1,默认1 | 0.80 |
**价格区间说明**
- 价格范围是 01 之间的小数
- 当市场价格在 [最低价, 最高价] 区间内时才会触发
- 如果不填最高价,默认使用 1.0(即只要价格 ≥ 最低价就触发)
**示例**
- 最低价 0.50,最高价 0.80
- 价格 0.45:不触发(低于最低价)
- 价格 0.60:触发 ✅(在区间内)
- 价格 0.85:不触发(高于最高价)
### 3.4 投入金额
| 参数 | 说明 | 必填 | 示例 |
|------|------|------|------|
| **投入方式** | 选择按比例或固定金额 | ✅ | 按比例 / 固定金额 |
| **比例 (%)** | 按账户余额的百分比投入 | 条件必填 | 10%(账户有100 USDC,投入10 USDC |
| **固定金额 (USDC)** | 每次固定投入的金额 | 条件必填 | 50 USDC |
**投入方式说明**
**方式一:按比例 (RATIO)**
- 每次触发时,按账户当前可用余额的百分比投入
- 例如:账户有 100 USDC,设置比例 10%
- 第1次触发:投入 10 USDC
- 第2次触发:如果余额变为 90 USDC,投入 9 USDC
- **优点**:自动适应账户余额变化
- **缺点**:每次投入金额可能不同
**方式二:固定金额 (FIXED)**
- 每次触发时,固定投入指定金额
- 例如:设置固定金额 50 USDC
- 每次触发都投入 50 USDC
- **优点**:投入金额稳定,便于管理
- **缺点**:需要确保账户余额充足
**注意事项**
- 最小下单金额:至少 1 USDC
- 如果账户余额不足,下单会失败并记录失败原因
### 3.5 价差过滤(高级功能)
价差功能用于根据币安 BTC/USDC 的 K 线波动决定是否触发,支持「最小价差」与「最大价差」两种方向。
| 参数 | 说明 | 必填 | 示例 |
|------|------|------|------|
| **价差模式** | 选择价差校验方式 | ✅ | 无 / 固定 / 自动 |
| **价差方向** | 最小价差(≥ 触发)或 最大价差(≤ 触发) | ✅ | 最小价差 / 最大价差 |
| **价差值** | 固定模式时填写(单位:USDC) | 条件必填 | 30 |
**价差方向说明**
- **最小价差**:当币安 K 线价差 **≥** 设定值时才触发
- 适合:只在波动「足够大」时交易(避免波动过小、不值得进场)
- **最大价差**:当币安 K 线价差 **≤** 设定值时才触发
- 适合:只在波动「足够小」时交易(避免波动过大、风险高)
**三种价差模式**
**模式一:无 (NONE)**
- 不进行价差校验
- 只要时间窗口和价格区间满足就触发
- **适合**:不关心币安价格波动,只看 Polymarket 价格
**模式二:固定 (FIXED)**
- 设置一个固定的价差值(单位:USDC)
- **最小价差**:当 K 线价差 ≥ 设定值时触发
- 示例:设定 30,价差 ≥ 30 触发 ✅,价差 < 30 不触发
- **最大价差**:当 K 线价差 ≤ 设定值时触发
- 示例:设定 50,价差 ≤ 50 触发 ✅,价差 > 50 不触发
- **适合**:您有明确的价差阈值
**模式三:自动 (AUTO)**
- 系统根据历史 20 根 K 线自动计算基准价差
- 计算逻辑:
1. 获取最近 20 根 K 线(与策略周期一致)
2. 按方向筛选(Up 方向只看上涨的 K 线,Down 方向只看下跌的 K 线)
3. 剔除异常值(使用 IQR 方法)
4. 计算平均价差 × 0.8 作为有效价差
- **最小价差**K 线价差 ≥ 有效价差时触发
- **最大价差**K 线价差 ≤ 有效价差时触发
- **适合**:希望根据历史数据自动调整,无需手动设具体数值
**价差说明**
- 价差 = |收盘价 - 开盘价|(币安 BTC/USDC 当根 K 线)
- 例如:开盘价 50000,收盘价 50030,价差 = 30
- 价差越大,说明该周期内价格波动越大
---
## 四、配置示例
### 示例1:简单策略(5分钟市场)
**场景**:在 5 分钟市场的最后 2 分钟,如果价格低于 0.60,自动买入 10 USDC
**配置**
```
账户:账户A
策略名称:BTC 5分钟简单策略
市场:btc-updown-5m
时间窗口:3分0秒 5分0秒
最低价:0.00
最高价:0.60
投入方式:固定金额
固定金额:10 USDC
价差模式:无
启用状态:开启
```
**说明**
- 周期开始后 03 分钟:不监听
- 周期开始后 3~5 分钟:如果价格 ≤ 0.60,自动买入 10 USDC
---
### 示例2:比例投入策略(15分钟市场)
**场景**:在 15 分钟市场的中段(5~10分钟),如果价格在 0.40~0.70 之间,投入账户余额的 15%
**配置**
```
账户:账户B
策略名称:BTC 15分钟比例策略
市场:btc-updown-15m
时间窗口:5分0秒 10分0秒
最低价:0.40
最高价:0.70
投入方式:按比例
比例:15%
价差模式:无
启用状态:开启
```
**说明**
- 假设账户余额 100 USDC
- 周期开始后 5~10 分钟:如果价格在 0.40~0.70 之间,自动买入约 15 USDC100 × 15%
---
### 示例3:带价差过滤的策略(15分钟市场)
**场景**:在 15 分钟市场的后段(10~14分钟),如果价格在 0.50~0.80 之间,且币安价差 ≥ 50,投入 20 USDC
**配置**
```
账户:账户C
策略名称:BTC 15分钟价差策略
市场:btc-updown-15m
时间窗口:10分0秒 14分0秒
最低价:0.50
最高价:0.80
投入方式:固定金额
固定金额:20 USDC
价差模式:固定
价差方向:最小价差
价差值:50
启用状态:开启
```
**说明**
- 周期开始后 10~14 分钟:同时满足以下条件才触发
1. 价格在 0.500.80 之间 ✅
2. 价差方向为「最小价差」且币安价差 ≥ 50 ✅
- 如果价差只有 30,即使价格满足也不会触发
---
### 示例4:自动价差策略(15分钟市场)
**场景**:在 15 分钟市场的前段(2~8分钟),如果价格在 0.30~0.90 之间,投入账户余额的 20%,价差由系统自动计算
**配置**
```
账户:账户D
策略名称:BTC 15分钟自动价差策略
市场:btc-updown-15m
时间窗口:2分0秒 8分0秒
最低价:0.30
最高价:0.90
投入方式:按比例
比例:20%
价差模式:自动
价差方向:最小价差
启用状态:开启
```
**说明**
- 系统会根据历史 20 根 K 线自动计算有效价差
- 周期开始后 2~8 分钟:同时满足以下条件才触发
1. 价格在 0.300.90 之间 ✅
2. 价差方向为「最小价差」且币安价差 ≥ 系统计算的有效价差 ✅
---
## 五、常见问题
### Q1:策略什么时候会触发?
**A**:需要同时满足以下条件:
1. ✅ 当前时间在时间窗口内
2. ✅ 市场价格在 [最低价, 最高价] 区间内
3. ✅ 本周期尚未触发过(每周期最多触发一次)
4. ✅ 如果设置了价差过滤,币安价差与价差方向需同时满足条件
### Q2:为什么我的策略没有触发?
**可能原因**
1. **时间窗口不对**:当前时间不在设定的时间窗口内
2. **价格不在区间**:市场价格不在 [最低价, 最高价] 范围内
3. **本周期已触发**:该周期已经触发过一次,不会再触发
4. **价差不满足**:如果设置了价差过滤,币安价差或价差方向未满足要求
5. **账户余额不足**:账户余额小于设定的投入金额
6. **策略未启用**:检查策略的启用状态是否为"开启"
### Q3:每周期最多触发一次是什么意思?
**A**:每个周期(5分钟或15分钟)内,即使多次满足条件,也只下单一次。
**示例**
- 15分钟市场,周期从 10:00 开始
- 10:05 时价格满足条件,触发下单 ✅
- 10:08 时价格再次满足条件,但不会再次下单(本周期已触发)
- 10:15 开始新周期,可以再次触发
### Q4:固定金额和按比例有什么区别?
**固定金额**
- 每次触发都投入相同金额
- 例如:设置 50 USDC,每次都是 50 USDC
- 需要确保账户余额充足
**按比例**
- 每次触发时按账户余额的百分比投入
- 例如:设置 10%,账户有 100 USDC 时投入 10 USDC,余额变为 90 USDC 后下次投入 9 USDC
- 自动适应余额变化
### Q5:价差过滤功能有什么用?
**A**:价差过滤根据币安 BTC/USDC 的 K 线波动决定是否触发,支持两种方向。
**最小价差**(价差 ≥ 设定值才触发):
- 波动太小时不触发,避免在波动不足时进场
- 例如:设定 30,只有价差 ≥ 30 才触发
**最大价差**(价差 ≤ 设定值才触发):
- 波动太大时不触发,避免在波动过大、风险高时进场
- 例如:设定 50,只有价差 ≤ 50 才触发
**三种模式选择建议**
- **无**:不关心币安价格波动,只看 Polymarket 价格
- **固定**:您知道期望的价差阈值(配合最小/最大价差方向使用)
- **自动**:希望根据历史数据自动计算有效价差,无需手动设具体数值
### Q6:为什么建议使用单独的钱包?
**A**:避免以下问题:
1. **余额变化**:如果钱包同时用于手动交易,余额变化可能影响策略执行
2. **仓位冲突**:手动交易和策略交易可能产生冲突
3. **管理混乱**:难以区分哪些订单是策略产生的,哪些是手动产生的
**建议**:创建一个专门的钱包,只用于尾盘策略。
### Q7:下单价格为什么是固定的 0.99?
**A**:这是策略的设计特点:
- 0.99 是市场中的最高价格(接近 1.0)
- 以最高价买入可以确保订单快速成交
- 虽然买入价格较高,但策略的核心是捕捉市场波动,而非追求最优价格
### Q8:策略需要依赖自动赎回功能吗?
**A**:是的,尾盘策略依赖自动赎回功能。
**原因**
- 策略下单后会形成仓位
- 这些仓位需要在市场结算后自动赎回
- 如果未配置自动赎回,仓位可能无法及时赎回
**配置要求**
- 在「系统设置」中配置 Builder API Key
- 开启自动赎回功能
---
## 六、注意事项
### 6.1 账户要求
- ✅ 账户必须配置 API Key、API Secret、API Passphrase
- ✅ 账户必须有足够的 USDC 余额
- ✅ 建议使用专门的钱包,避免与其他操作冲突
### 6.2 时间窗口设置
- ⚠️ 开始时间必须 ≤ 结束时间
- ⚠️ 时间窗口不能超出周期长度(5分钟市场 ≤ 5分钟,15分钟市场 ≤ 15分钟)
- ⚠️ 建议设置合理的时间窗口,避免在周期开始或结束时触发
### 6.3 价格区间设置
- ⚠️ 最低价必须 ≤ 最高价
- ⚠️ 价格范围是 0~1 之间的小数
- ⚠️ 建议根据市场情况设置合理的价格区间
### 6.4 投入金额设置
- ⚠️ 最小下单金额:至少 1 USDC
- ⚠️ 确保账户余额充足,避免下单失败
- ⚠️ 按比例模式:注意账户余额变化对投入金额的影响
### 6.5 价差过滤设置
- ⚠️ 价差方向:最小价差为「≥ 触发」,最大价差为「≤ 触发」,请按需求选择
- ⚠️ 固定模式:需要填写合理的价差值(单位:USDC)
- ⚠️ 自动模式:系统会在周期内按窗口进度自动计算有效价差,无需手动设置
- ⚠️ 价差设定过严(最小价差设得过大或最大价差设得过小)可能导致策略难以触发
### 6.6 其他注意事项
- ⚠️ 策略创建后默认启用,如需暂停可以关闭"启用状态"
- ⚠️ 每周期最多触发一次,请合理设置触发条件
- ⚠️ 策略依赖自动赎回功能,请确保已配置 Builder API Key
- ⚠️ 建议定期查看触发记录,了解策略执行情况
---
## 七、策略管理
### 7.1 查看策略列表
在「尾盘策略」页面可以查看所有策略:
- 策略名称
- 市场信息
- 时间窗口
- 价格区间
- 投入方式
- 启用状态
- 最后触发时间
- 总收益、胜率等统计信息
### 7.2 查看触发记录
点击策略可以查看详细的触发记录:
- 触发时间
- 市场价格
- 投入金额
- 订单ID
- 订单状态(成功/失败)
- 结算信息(盈亏、胜率等)
### 7.3 编辑策略
可以随时修改策略参数:
- 时间窗口
- 价格区间
- 投入方式
- 价差过滤(模式、方向、数值)
- 启用状态
**注意**:修改后的策略会在下一个周期生效。
### 7.4 删除策略
删除策略后:
- 策略配置会被删除
- 历史触发记录会保留
- 已下单的订单不受影响
---
## 八、总结
尾盘策略是一个强大的自动化交易工具,可以帮助您:
1. **自动化交易**:无需手动盯盘,系统自动执行
2. **精准控制**:通过时间窗口和价格区间精确控制触发条件
3. **灵活配置**:支持比例和固定金额两种投入方式
4. **风险过滤**:通过价差过滤(最小价差/最大价差)控制波动条件
**使用建议**
- 初次使用建议从简单策略开始(无价差过滤)
- 熟悉后再尝试添加价差过滤功能
- 定期查看触发记录,根据实际情况调整策略参数
- 使用专门的钱包,避免与其他操作冲突
**祝您交易顺利!** 🚀
+46
View File
@@ -0,0 +1,46 @@
# Neg Risk 赎回与对应 JS/TS 代码说明
## Neg Risk 特殊逻辑
### 1. 赎回(Redeem
- **普通市场**:仓位由 **USDC.e**Bridged USDC)抵押,调用 CTF 的 `redeemPositions(collateralToken, parentCollectionId, conditionId, indexSets)` 时,`collateralToken` 为 USDC.e 地址(Polygon: `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`)。
- **Neg Risk 市场**:仓位由 **WrappedCollateral** 抵押(neg-risk-ctf-adapter 设计),同一笔赎回必须使用 WrappedCollateral 地址(Polygon: `0x3A3BD7bb9528E159577F7C2e685CC81A765002E2`),否则链上找不到对应仓位,会得到 payout 0。
本项目中:通过 Gamma API 的 `negRisk` / `negRiskOther` 判断市场类型,赎回时对 Neg Risk 市场传 `isNegRisk=true`,在 `createRedeemTx` 中选用 WrappedCollateral 作为 `collateralToken`
### 2. 下单/签约(Order Signing
- **普通市场**:使用标准 CTF Exchange 合约签约。
- **Neg Risk 市场**:必须使用 **Neg Risk CTF Exchange** 合约签约,否则 CLOB 返回 invalid signature。
`OrderSigningService.getExchangeContract(negRisk)``CopyOrderTrackingService` 中按 `getNegRiskByConditionId` 选择 exchange。
---
## 对应的 JS/TS 代码位置
| 功能 | 仓库/来源 | 路径或说明 |
|------|-----------|------------|
| Relayer 执行、Safe 交易提交 | [Polymarket/builder-relayer-client](https://github.com/Polymarket/builder-relayer-client) | `src/client.ts``execute`)、`src/encode/safe.ts`MultiSend `createSafeMultisendTransaction` |
| 链与合约配置 | builder-relayer-client | `src/config/index.ts`Polygon/Amoy 的 SafeMultisend 等) |
| 赎回 calldata 构建 | **官方仓库无** | 官方库只负责执行传入的 `Transaction[]`,不包含 `createRedeemTx` 或 redeem 工具函数 |
| 社区赎回示例(单一 collateral | [Gist: redeem-positions](https://gist.github.com/Waawzer/5cdff342767265c2637e21607d03f6eb) | 使用 `collateralToken` 调用 `redeemPositions`**未区分 Neg Risk**(全部用同一 collateral,如 USDC |
| Neg Risk 合约与 WrappedCollateral | [Polymarket/neg-risk-ctf-adapter](https://github.com/Polymarket/neg-risk-ctf-adapter) | README、`addresses.json`137 链上 negRiskWrappedCollateral 等地址) |
| 市场是否 Neg Risk | Gamma API | 市场/事件的 `negRisk``negRiskOther` 字段,本项目中通过 `MarketService.getNegRiskByConditionId` 查询 |
---
## 赎回后自动解包 WCOL → USDC.e
Neg Risk 赎回到账为 **WCOL**,若不解包则余额显示为 WCOL 而非 USDC.e。本项目中由**轮询任务**统一解包,赎回流程内不再等待确认与解包:
- **WcolUnwrapJobService**:每 20 秒轮询一次(`@Scheduled(fixedRate = 20_000)`),遍历所有账户的代理地址,若 WCOL 余额 > 0 则调用 **BlockchainService.unwrapWcolForProxy** 解包为 USDC.e。同一时间仅允许单次执行;若上次执行未结束则本次忽略。
- **AccountService.redeemPositions**:只负责赎回,不再在流程内等待交易确认或执行解包;解包由上述 Job 在后续轮询中处理(含重启后未解包、解包失败重试等)。
- **BlockchainService**:提供 `getWcolBalance(proxyAddress)``unwrapWcolForProxy(...)`**RelayClientService** 提供 `createUnwrapWcolTx(toAddress, amountWei)`
- **Safe 与 Magic 解包逻辑一致**:均使用同一 `createUnwrapWcolTx(proxyAddress, balance)``execute(privateKey, proxyAddress, unwrapTx, walletType)`Safe 走 execTransaction(或 Builder Relayer SAFE),Magic 走 Builder Relayer PROXYencodeProxyTransactionData),最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount)USDC.e 转入该代理地址。
## 小结
- **Neg Risk 特殊逻辑**:赎回用 WrappedCollateral、下单用 Neg Risk Exchange;均由「是否为 Neg Risk 市场」分支处理。
- **对应 JS 代码**:执行与 MultiSend 在 **builder-relayer-client**;赎回参数与 calldata 在**应用层**构建,官方无现成 redeem 工具;Neg Risk 的抵押品与合约见 **neg-risk-ctf-adapter****Gamma API**
+60 -2
View File
@@ -15,6 +15,7 @@ import {
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api' import { apiService } from '../services/api'
import type { ProxyOption } from '../types' import type { ProxyOption } from '../types'
import AccountSetupGuideModal from './AccountSetupGuideModal'
type ImportType = 'privateKey' | 'mnemonic' type ImportType = 'privateKey' | 'mnemonic'
@@ -41,6 +42,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
const [selectedProxyType, setSelectedProxyType] = useState<string>('') const [selectedProxyType, setSelectedProxyType] = useState<string>('')
const [loadingProxyOptions, setLoadingProxyOptions] = useState<boolean>(false) const [loadingProxyOptions, setLoadingProxyOptions] = useState<boolean>(false)
const [step, setStep] = useState<'input' | 'select'>('input') // 步骤:输入 -> 选择代理地址 const [step, setStep] = useState<'input' | 'select'>('input') // 步骤:输入 -> 选择代理地址
const [setupModalVisible, setSetupModalVisible] = useState<boolean>(false)
const [setupStatus, setSetupStatus] = useState<any>(null)
const [importedAccountId, setImportedAccountId] = useState<number | undefined>(undefined)
// 当私钥输入时,自动推导地址(不支持换行,自动去除换行符) // 当私钥输入时,自动推导地址(不支持换行,自动去除换行符)
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -254,11 +258,34 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
// 获取新添加的账户ID(通过API获取,因为store可能还没更新) // 获取新添加的账户ID(通过API获取,因为store可能还没更新)
const accountsResponse = await apiService.accounts.list() const accountsResponse = await apiService.accounts.list()
let accountId: number | undefined = undefined
if (accountsResponse.data.code === 0 && accountsResponse.data.data) { if (accountsResponse.data.code === 0 && accountsResponse.data.data) {
const newAccounts = accountsResponse.data.data.list || [] const newAccounts = accountsResponse.data.data.list || []
const newAccount = newAccounts.find((acc: any) => acc.walletAddress === walletAddress) const newAccount = newAccounts.find((acc: any) => acc.walletAddress === walletAddress)
if (newAccount && onSuccess) { if (newAccount) {
onSuccess(newAccount.id) accountId = newAccount.id
setImportedAccountId(accountId)
// 检查账户设置状态
let willShowSetupModal = false
try {
const setupResponse = await apiService.accounts.checkSetupStatus(newAccount.id)
if (setupResponse.data.code === 0 && setupResponse.data.data) {
const status = setupResponse.data.data
setSetupStatus(status)
const hasIncomplete = !status.proxyDeployed || !status.tradingEnabled || !status.tokensApproved
if (hasIncomplete) {
setSetupModalVisible(true)
willShowSetupModal = true
}
}
} catch (error) {
console.error('检查账户设置状态失败:', error)
}
// 未展示设置弹窗时才调用 onSuccess,避免父组件关闭导入弹窗导致设置弹窗被卸载
if (!willShowSetupModal && onSuccess) {
onSuccess(newAccount.id)
}
} else if (onSuccess) { } else if (onSuccess) {
onSuccess(0) onSuccess(0)
} }
@@ -551,6 +578,37 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
{/* 账户设置引导弹窗 */}
<AccountSetupGuideModal
visible={setupModalVisible}
setupStatus={setupStatus}
accountId={importedAccountId}
onClose={() => {
setSetupModalVisible(false)
onSuccess?.(importedAccountId ?? 0)
}}
onComplete={async () => {
// 刷新设置状态
if (importedAccountId) {
try {
const setupResponse = await apiService.accounts.checkSetupStatus(importedAccountId)
if (setupResponse.data.code === 0 && setupResponse.data.data) {
setSetupStatus(setupResponse.data.data)
const status = setupResponse.data.data
// 如果所有步骤都完成了,关闭弹窗并通知父组件
if (status.proxyDeployed && status.tradingEnabled && status.tokensApproved) {
setSetupModalVisible(false)
message.success(t('accountSetup.allCompleted.title'))
onSuccess?.(importedAccountId ?? 0)
}
}
} catch (error) {
console.error('刷新设置状态失败:', error)
}
}
}}
/>
</div> </div>
) )
} }
@@ -0,0 +1,108 @@
import React, { useState, useEffect } from 'react'
import { Modal, Alert, Space, Button, Typography } from 'antd'
import { CheckCircleOutlined, ExclamationCircleOutlined, WalletOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import AccountSetupStatusBlock from './AccountSetupStatusBlock'
import type { SetupStatus } from './AccountSetupStatusBlock'
const { Text } = Typography
interface AccountSetupGuideModalProps {
visible: boolean
setupStatus: SetupStatus | null
accountId?: number
onClose: () => void
onComplete?: () => void
}
const AccountSetupGuideModal: React.FC<AccountSetupGuideModalProps> = ({
visible,
setupStatus: _initialStatus,
accountId,
onClose,
onComplete
}) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [allCompleted, setAllCompleted] = useState(false)
useEffect(() => {
if (visible) setAllCompleted(false)
}, [visible, accountId])
if (!visible) return null
return (
<Modal
title={
<Space>
<WalletOutlined style={{ fontSize: '20px', color: '#1890ff' }} />
<span>{t('accountSetup.title')}</span>
</Space>
}
open={visible}
onCancel={onClose}
footer={
<div style={{ textAlign: 'right' }}>
{allCompleted ? (
<Button type="primary" onClick={onClose} size={isMobile ? 'middle' : 'large'}>
{t('common.confirm')}
</Button>
) : (
<Button onClick={onClose} size={isMobile ? 'middle' : 'large'}>
{t('common.later')}
</Button>
)}
</div>
}
width={isMobile ? '95%' : 680}
style={{ top: isMobile ? 20 : 50 }}
destroyOnClose
maskClosable={allCompleted}
closable
>
<div style={{ padding: isMobile ? '16px 0' : '24px 0' }}>
{allCompleted ? (
<Alert
message={t('accountSetup.allCompleted.title')}
description={t('accountSetup.allCompleted.description')}
type="success"
icon={<CheckCircleOutlined />}
showIcon
style={{ marginBottom: 24 }}
/>
) : (
<Alert
message={t('accountSetup.incomplete.title')}
description={t('accountSetup.incomplete.description')}
type="warning"
icon={<ExclamationCircleOutlined />}
showIcon
style={{ marginBottom: 24 }}
/>
)}
{accountId != null && accountId > 0 ? (
<AccountSetupStatusBlock
accountId={accountId}
embedded
showApprovalDetails
onAllCompleted={() => setAllCompleted(true)}
onRefresh={onComplete}
/>
) : (
<Text type="secondary">{t('accountSetup.error.description')}</Text>
)}
<div style={{ marginTop: 24, padding: '12px', background: '#f5f5f5', borderRadius: '4px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>
{t('accountSetup.help')}
</Text>
</div>
</div>
</Modal>
)
}
export default AccountSetupGuideModal
@@ -0,0 +1,305 @@
import React, { useEffect, useState } from 'react'
import { Card, Steps, Button, Space, Tag, Spin, Typography, message } from 'antd'
import {
CheckCircleOutlined,
CloseCircleOutlined,
WalletOutlined,
KeyOutlined,
SafetyOutlined,
LinkOutlined,
ReloadOutlined
} from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api'
const { Paragraph, Text } = Typography
export interface SetupStatus {
proxyDeployed: boolean
tradingEnabled: boolean
tokensApproved: boolean
approvalDetails?: Record<string, string>
error?: string
}
interface AccountSetupStatusBlockProps {
accountId: number
onRefresh?: () => void
onAllCompleted?: () => void
size?: 'small' | 'default'
showApprovalDetails?: boolean
/** 嵌入模式:不渲染 Card,仅渲染步骤与授权详情(供弹窗等复用) */
embedded?: boolean
}
/** 步骤 key 与步骤编号对应 */
const STEP_KEYS = ['step1', 'step2', 'step3'] as const
const stepKeyToNumber = (key: string): number =>
STEP_KEYS.indexOf(key as typeof STEP_KEYS[number]) + 1
const AccountSetupStatusBlock: React.FC<AccountSetupStatusBlockProps> = ({
accountId,
onRefresh,
onAllCompleted,
size = 'default',
showApprovalDetails = true,
embedded = false
}) => {
const { t } = useTranslation()
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const fetchStatus = async () => {
if (accountId <= 0) return
try {
const response = await apiService.accounts.checkSetupStatus(accountId)
if (response.data.code === 0 && response.data.data) {
setSetupStatus(response.data.data)
} else {
setSetupStatus(null)
}
} catch (error) {
console.error('获取账户设置状态失败:', error)
setSetupStatus(null)
} finally {
setLoading(false)
setRefreshing(false)
}
}
useEffect(() => {
setLoading(true)
fetchStatus()
}, [accountId])
// 每 5 秒轮询最新状态(首次加载完成后且存在未完成步骤时轮询,全部完成后停止)
useEffect(() => {
if (accountId <= 0 || setupStatus == null) return
const allCompleted =
setupStatus.proxyDeployed &&
setupStatus.tradingEnabled &&
setupStatus.tokensApproved
if (allCompleted) return
const timer = setInterval(() => {
fetchStatus()
}, 5000)
return () => clearInterval(timer)
}, [accountId, setupStatus?.proxyDeployed, setupStatus?.tradingEnabled, setupStatus?.tokensApproved])
// 全部完成时通知父组件(供弹窗等关闭或更新用)
const allCompleted =
setupStatus != null &&
setupStatus.proxyDeployed &&
setupStatus.tradingEnabled &&
setupStatus.tokensApproved
useEffect(() => {
if (allCompleted) onAllCompleted?.()
}, [allCompleted, onAllCompleted])
const handleRefresh = async () => {
setRefreshing(true)
await fetchStatus()
onRefresh?.()
}
const handleStepAction = async (key: string) => {
const stepNum = stepKeyToNumber(key)
if (stepNum < 1) return
setActionLoading(key)
try {
const response = await apiService.accounts.executeSetupStep(accountId, stepNum)
const res = response.data
if (res.code !== 0) {
message.error(res.msg || t('accountSetup.actionFailed'))
return
}
const data = res.data
if (data?.redirectUrl) {
window.open(data.redirectUrl, '_blank')
}
if (data?.success !== false) {
await fetchStatus()
onRefresh?.()
if (data?.transactionHash) {
message.success(t('accountSetup.actionSuccess'))
}
}
} catch (err) {
message.error(t('accountSetup.actionFailed'))
} finally {
setActionLoading(null)
}
}
if (loading && !setupStatus) {
const loadingContent = (
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<Spin />
</div>
)
return embedded ? <div>{loadingContent}</div> : (
<Card title={t('accountSetup.title')} size={size}>{loadingContent}</Card>
)
}
if (!setupStatus) {
const errorContent = (
<>
<Text type="secondary">{t('accountSetup.error.description')}</Text>
<div style={{ marginTop: 12 }}>
<Button icon={<ReloadOutlined />} onClick={handleRefresh}>
{t('accountSetup.refresh')}
</Button>
</div>
</>
)
return embedded ? <div>{errorContent}</div> : (
<Card title={t('accountSetup.title')} size={size}>{errorContent}</Card>
)
}
const steps = [
{
key: 'step1',
title: t('accountSetup.step1.title'),
description: t('accountSetup.step1.description'),
icon: <WalletOutlined />,
completed: setupStatus.proxyDeployed,
actionLabel: t('accountSetup.step1.action')
},
{
key: 'step2',
title: t('accountSetup.step2.title'),
description: t('accountSetup.step2.description'),
icon: <KeyOutlined />,
completed: setupStatus.tradingEnabled,
actionLabel: t('accountSetup.step2.action')
},
{
key: 'step3',
title: t('accountSetup.step3.title'),
description: t('accountSetup.step3.description'),
icon: <SafetyOutlined />,
completed: setupStatus.tokensApproved,
actionLabel: t('accountSetup.step3.action')
}
]
const stepsContent = (
<>
<Steps
direction="vertical"
current={steps.findIndex(s => !s.completed)}
size="small"
style={{ marginBottom: 16 }}
>
{steps.map((step) => (
<Steps.Step
key={step.key}
title={
<Space>
<span>{step.title}</span>
{step.completed ? (
<Tag color="success" icon={<CheckCircleOutlined />}>
{t('accountSetup.completed')}
</Tag>
) : (
<Tag color="warning" icon={<CloseCircleOutlined />}>
{t('accountSetup.pending')}
</Tag>
)}
</Space>
}
description={
<div style={{ marginTop: 8 }}>
<Paragraph style={{ marginBottom: 8, fontSize: 14, color: '#666' }}>
{step.description}
</Paragraph>
{!step.completed && (
<Button
type="primary"
size="small"
icon={<LinkOutlined />}
onClick={() => handleStepAction(step.key)}
loading={actionLoading === step.key}
style={{ marginTop: 4 }}
>
{step.actionLabel}
</Button>
)}
</div>
}
icon={step.icon}
status={step.completed ? 'finish' : 'process'}
/>
))}
</Steps>
{showApprovalDetails && setupStatus.approvalDetails && Object.keys(setupStatus.approvalDetails).length > 0 && (
<div style={{ marginTop: 16, padding: '12px', background: '#fafafa', borderRadius: 4 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>{t('accountSetup.approvalDetails.title')}</Text>
<Space direction="vertical" style={{ width: '100%' }} size="small">
{Object.entries(setupStatus.approvalDetails).map(([contract, allowance]) => {
const isUnlimited = allowance === 'unlimited'
const isApproved = isUnlimited || parseFloat(allowance) > 0
const displayText = isUnlimited
? t('accountSetup.approvalDetails.unlimited')
: isApproved
? `${parseFloat(allowance).toFixed(2)} USDC`
: t('accountSetup.approvalDetails.notApproved')
return (
<div
key={contract}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: 13,
minHeight: 24
}}
>
<span>{t(`accountSetup.approvalDetails.${contract}`) || contract}</span>
<span style={{ minWidth: 100, textAlign: 'right' }}>{displayText}</span>
</div>
)
})}
</Space>
</div>
)}
{setupStatus.error && (
<div style={{ marginTop: 12 }}>
<Text type="danger">{setupStatus.error}</Text>
</div>
)}
</>
)
if (embedded) {
return <div style={{ position: 'relative' }}>{stepsContent}</div>
}
return (
<Card
title={t('accountSetup.title')}
size={size}
extra={
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={refreshing}
>
{t('accountSetup.refresh')}
</Button>
}
>
{stepsContent}
</Card>
)
}
export default AccountSetupStatusBlock
+59 -8
View File
@@ -13,6 +13,7 @@
"success": "Success", "success": "Success",
"failed": "Failed", "failed": "Failed",
"confirm": "Confirm", "confirm": "Confirm",
"later": "Later",
"submit": "Submit", "submit": "Submit",
"reset": "Reset", "reset": "Reset",
"close": "Close", "close": "Close",
@@ -237,6 +238,51 @@
"select": "Select this proxy address" "select": "Select this proxy address"
} }
}, },
"accountSetup": {
"title": "Account Setup Check",
"completed": "Completed",
"pending": "Pending",
"refresh": "Refresh Status",
"allCompleted": {
"title": "All Setup Completed",
"description": "Your account is ready to use all features."
},
"incomplete": {
"title": "Account Setup Incomplete",
"description": "Please complete the following setup steps to ensure your account works properly."
},
"step1": {
"title": "Deploy Proxy Wallet",
"description": "Proxy wallet is required for trading on Polymarket. Safe accounts can deploy with one click; Magic accounts will be redirected to Polymarket.",
"action": "Deploy Proxy Wallet"
},
"step2": {
"title": "Enable Trading",
"description": "API credentials are required for trading. Click the button below to let the system automatically obtain and save the API Key.",
"action": "Enable Trading (One-Click)"
},
"step3": {
"title": "Approve Tokens",
"description": "You need to authorize the proxy wallet to use your USDC tokens. Click the button below to complete token approval automatically.",
"action": "Approve Tokens (One-Click)"
},
"approvalDetails": {
"title": "Token Approval Details",
"CTF_CONTRACT": "CTF Contract",
"CTF_EXCHANGE": "CTF Exchange",
"NEG_RISK_EXCHANGE": "Neg Risk Exchange",
"NEG_RISK_ADAPTER": "Neg Risk Adapter",
"notApproved": "Not Approved",
"unlimited": "Unlimited"
},
"error": {
"title": "Check Failed",
"description": "Unable to check account setup status, please try again later."
},
"actionSuccess": "Operation successful",
"actionFailed": "Operation failed, please try again later",
"help": "Tip: After completing the setup, click the \"Refresh Status\" button to update the check results. If all steps are completed, you can use account features normally."
},
"leader": { "leader": {
"title": "Leader Management", "title": "Leader Management",
"leaderName": "Leader Name", "leaderName": "Leader Name",
@@ -1428,7 +1474,8 @@
"delete": "Delete", "delete": "Delete",
"viewTriggers": "Orders", "viewTriggers": "Orders",
"deleteConfirm": "Delete this strategy?", "deleteConfirm": "Delete this strategy?",
"fetchFailed": "Failed to fetch list" "fetchFailed": "Failed to fetch list",
"configGuide": "Configuration Guide"
}, },
"form": { "form": {
"walletTip": "Use a dedicated wallet to avoid balance or order issues.", "walletTip": "Use a dedicated wallet to avoid balance or order issues.",
@@ -1451,13 +1498,17 @@
"update": "Update", "update": "Update",
"timeWindowStartLEEnd": "Window start must not be greater than end", "timeWindowStartLEEnd": "Window start must not be greater than end",
"timeWindowExceed": "Time window must not exceed period length", "timeWindowExceed": "Time window must not exceed period length",
"minSpreadMode": "Min spread", "spreadMode": "Spread",
"minSpreadModeTip": "Whether to place an order is based on the spread between open and close in the current period. Auto: system computes a suggested spread from the last 20 klines (updated each period); Fixed: you enter a value (e.g. 30), order only when spread ≥ that value; None: no spread check, order when price is in range.", "spreadModeTip": "Whether to place an order is based on the spread between open and close in the current period. Auto: system computes a suggested spread from the last 20 klines (updated each period); Fixed: you enter a value (e.g. 30); None: no spread check, order when price is in range.",
"minSpreadModeNone": "None", "spreadModeNone": "None",
"minSpreadModeFixed": "Fixed", "spreadModeFixed": "Fixed",
"minSpreadModeAuto": "Auto", "spreadModeAuto": "Auto",
"minSpreadValue": "Min spread value (USDC)", "spreadValue": "Spread value (USDC)",
"minSpreadValuePlaceholder": "e.g. 30" "spreadValuePlaceholder": "e.g. 30",
"spreadDirection": "Spread Direction",
"spreadDirectionTip": "Min spread: trigger when spread ≥ configured value, buy price fixed at 0.99; Max spread: trigger when spread ≤ configured value, buy price = trigger price + 0.02 (suitable for low-price buying).",
"spreadDirectionMin": "Min Spread",
"spreadDirectionMax": "Max Spread"
}, },
"redeemRequiredModal": { "redeemRequiredModal": {
"title": "Configure Auto Redeem First", "title": "Configure Auto Redeem First",
+59 -8
View File
@@ -4,6 +4,7 @@
"save": "保存", "save": "保存",
"cancel": "取消", "cancel": "取消",
"confirm": "确定", "confirm": "确定",
"later": "稍后",
"delete": "删除", "delete": "删除",
"edit": "编辑", "edit": "编辑",
"viewDetail": "查看详情", "viewDetail": "查看详情",
@@ -236,6 +237,51 @@
"select": "选择此代理地址" "select": "选择此代理地址"
} }
}, },
"accountSetup": {
"title": "账户设置检查",
"completed": "已完成",
"pending": "待完成",
"refresh": "刷新状态",
"allCompleted": {
"title": "所有设置已完成",
"description": "您的账户已准备就绪,可以开始使用所有功能。"
},
"incomplete": {
"title": "账户设置未完成",
"description": "请完成以下设置步骤,以确保账户可以正常使用。"
},
"step1": {
"title": "部署代理钱包",
"description": "代理钱包是您在 Polymarket 上进行交易的必要组件。Safe 账户可点击下方按钮由系统一键部署;Magic 账户将跳转至 Polymarket 完成。",
"action": "部署代理钱包"
},
"step2": {
"title": "启用交易",
"description": "需要配置 API 凭证才能进行交易。点击下方按钮由系统自动获取并保存 API Key。",
"action": "一键启用交易"
},
"step3": {
"title": "批准代币",
"description": "需要授权代理钱包使用您的 USDC 代币。点击下方按钮由系统自动完成代币授权。",
"action": "一键批准代币"
},
"approvalDetails": {
"title": "代币授权详情",
"CTF_CONTRACT": "CTF 合约",
"CTF_EXCHANGE": "CTF 交易所",
"NEG_RISK_EXCHANGE": "负风险交易所",
"NEG_RISK_ADAPTER": "负风险适配器",
"notApproved": "未授权",
"unlimited": "无限"
},
"error": {
"title": "检查失败",
"description": "无法检查账户设置状态,请稍后重试。"
},
"actionSuccess": "操作成功",
"actionFailed": "操作失败,请稍后重试",
"help": "提示:完成设置后,点击「刷新状态」按钮更新检查结果。如果所有步骤都已完成,您可以正常使用账户功能。"
},
"leader": { "leader": {
"title": "Leader 管理", "title": "Leader 管理",
"leaderName": "Leader 名称", "leaderName": "Leader 名称",
@@ -1427,7 +1473,8 @@
"delete": "删除", "delete": "删除",
"viewTriggers": "订单", "viewTriggers": "订单",
"deleteConfirm": "确定删除该策略?", "deleteConfirm": "确定删除该策略?",
"fetchFailed": "获取列表失败" "fetchFailed": "获取列表失败",
"configGuide": "配置指南"
}, },
"form": { "form": {
"walletTip": "建议使用专用钱包,避免手动操作等导致余额或下单异常。", "walletTip": "建议使用专用钱包,避免手动操作等导致余额或下单异常。",
@@ -1450,13 +1497,17 @@
"update": "更新", "update": "更新",
"timeWindowStartLEEnd": "时间区间开始不能大于结束", "timeWindowStartLEEnd": "时间区间开始不能大于结束",
"timeWindowExceed": "时间区间不能超过周期长度", "timeWindowExceed": "时间区间不能超过周期长度",
"minSpreadMode": "最小价差", "spreadMode": "价差",
"minSpreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 20 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30),仅当价差 ≥ 该值时才下单;无:不校验价差,满足价格区间即下单。", "spreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 20 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30);无:不校验价差,满足价格区间即下单。",
"minSpreadModeNone": "无", "spreadModeNone": "无",
"minSpreadModeFixed": "固定", "spreadModeFixed": "固定",
"minSpreadModeAuto": "自动", "spreadModeAuto": "自动",
"minSpreadValue": "最小价差数值 (USDC)", "spreadValue": "价差数值 (USDC)",
"minSpreadValuePlaceholder": "如 30" "spreadValuePlaceholder": "如 30",
"spreadDirection": "价差方向",
"spreadDirectionTip": "最小价差:价差 ≥ 配置值时触发,买入价固定 0.99;最大价差:价差 ≤ 配置值时触发,买入价 = 触发价 + 0.02(适合低价买入)。",
"spreadDirectionMin": "最小价差",
"spreadDirectionMax": "最大价差"
}, },
"redeemRequiredModal": { "redeemRequiredModal": {
"title": "请先配置自动赎回", "title": "请先配置自动赎回",
+59 -8
View File
@@ -13,6 +13,7 @@
"success": "成功", "success": "成功",
"failed": "失敗", "failed": "失敗",
"confirm": "確認", "confirm": "確認",
"later": "稍後",
"submit": "提交", "submit": "提交",
"reset": "重置", "reset": "重置",
"close": "關閉", "close": "關閉",
@@ -237,6 +238,51 @@
"select": "選擇此代理地址" "select": "選擇此代理地址"
} }
}, },
"accountSetup": {
"title": "帳戶設置檢查",
"completed": "已完成",
"pending": "待完成",
"refresh": "刷新狀態",
"allCompleted": {
"title": "所有設置已完成",
"description": "您的帳戶已準備就緒,可以開始使用所有功能。"
},
"incomplete": {
"title": "帳戶設置未完成",
"description": "請完成以下設置步驟,以確保帳戶可以正常使用。"
},
"step1": {
"title": "部署代理錢包",
"description": "代理錢包是您在 Polymarket 上進行交易的必要組件。Safe 帳戶可點擊下方按鈕由系統一鍵部署;Magic 帳戶將跳轉至 Polymarket 完成。",
"action": "部署代理錢包"
},
"step2": {
"title": "啟用交易",
"description": "需要配置 API 憑證才能進行交易。點擊下方按鈕由系統自動獲取並保存 API Key。",
"action": "一鍵啟用交易"
},
"step3": {
"title": "批准代幣",
"description": "需要授權代理錢包使用您的 USDC 代幣。點擊下方按鈕由系統自動完成代幣授權。",
"action": "一鍵批准代幣"
},
"approvalDetails": {
"title": "代幣授權詳情",
"CTF_CONTRACT": "CTF 合約",
"CTF_EXCHANGE": "CTF 交易所",
"NEG_RISK_EXCHANGE": "負風險交易所",
"NEG_RISK_ADAPTER": "負風險適配器",
"notApproved": "未授權",
"unlimited": "無限"
},
"error": {
"title": "檢查失敗",
"description": "無法檢查帳戶設置狀態,請稍後重試。"
},
"actionSuccess": "操作成功",
"actionFailed": "操作失敗,請稍後重試",
"help": "提示:完成設置後,點擊「刷新狀態」按鈕更新檢查結果。如果所有步驟都已完成,您可以正常使用帳戶功能。"
},
"leader": { "leader": {
"title": "Leader 管理", "title": "Leader 管理",
"leaderName": "Leader 名稱", "leaderName": "Leader 名稱",
@@ -1428,7 +1474,8 @@
"delete": "刪除", "delete": "刪除",
"viewTriggers": "訂單", "viewTriggers": "訂單",
"deleteConfirm": "確定刪除該策略?", "deleteConfirm": "確定刪除該策略?",
"fetchFailed": "獲取列表失敗" "fetchFailed": "獲取列表失敗",
"configGuide": "配置指南"
}, },
"form": { "form": {
"walletTip": "建議使用專用錢包,避免手動操作等導致餘額或下單異常。", "walletTip": "建議使用專用錢包,避免手動操作等導致餘額或下單異常。",
@@ -1451,13 +1498,17 @@
"update": "更新", "update": "更新",
"timeWindowStartLEEnd": "時間區間開始不能大於結束", "timeWindowStartLEEnd": "時間區間開始不能大於結束",
"timeWindowExceed": "時間區間不能超過週期長度", "timeWindowExceed": "時間區間不能超過週期長度",
"minSpreadMode": "最小價差", "spreadMode": "價差",
"minSpreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 20 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30),僅當價差 ≥ 該值時才下單;無:不校驗價差,滿足價格區間即下單。", "spreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 20 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30);無:不校驗價差,滿足價格區間即下單。",
"minSpreadModeNone": "無", "spreadModeNone": "無",
"minSpreadModeFixed": "固定", "spreadModeFixed": "固定",
"minSpreadModeAuto": "自動", "spreadModeAuto": "自動",
"minSpreadValue": "最小價差數值 (USDC)", "spreadValue": "價差數值 (USDC)",
"minSpreadValuePlaceholder": "如 30" "spreadValuePlaceholder": "如 30",
"spreadDirection": "價差方向",
"spreadDirectionTip": "最小價差:價差 ≥ 配置值時觸發,買入價固定 0.99;最大價差:價差 ≤ 配置值時觸發,買入價 = 觸發價 + 0.02(適合低價買入)。",
"spreadDirectionMin": "最小價差",
"spreadDirectionMax": "最大價差"
}, },
"redeemRequiredModal": { "redeemRequiredModal": {
"title": "請先配置自動贖回", "title": "請先配置自動贖回",
+16 -41
View File
@@ -7,6 +7,7 @@ import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types' import type { Account } from '../types'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils' import { formatUSDC } from '../utils'
import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock'
const { Title } = Typography const { Title } = Typography
@@ -150,10 +151,7 @@ const AccountDetail: React.FC = () => {
onClick={() => { onClick={() => {
setEditModalVisible(true) setEditModalVisible(true)
editForm.setFieldsValue({ editForm.setFieldsValue({
accountName: account.accountName || '', accountName: account.accountName || ''
apiKey: '', // 不显示实际值,留空表示不修改
apiSecret: '', // 不显示实际值,留空表示不修改
apiPassphrase: '' // 不显示实际值,留空表示不修改
}) })
}} }}
size={isMobile ? 'middle' : 'large'} size={isMobile ? 'middle' : 'large'}
@@ -215,44 +213,21 @@ const AccountDetail: React.FC = () => {
<Divider /> <Divider />
<Card {accountId && (
title={t('account.apiCredentials')} <div style={{
style={{
marginTop: isMobile ? '12px' : '16px', marginTop: isMobile ? '12px' : '16px',
margin: isMobile ? '0 -8px' : '0', margin: isMobile ? '0 -8px' : '0'
borderRadius: isMobile ? '0' : undefined }}>
}} <AccountSetupStatusBlock
> accountId={Number(accountId)}
<Descriptions onRefresh={() => { loadAccountDetail(); loadBalance() }}
column={isMobile ? 1 : 2} size={isMobile ? 'small' : 'default'}
bordered showApprovalDetails={true}
size={isMobile ? 'small' : 'middle'} />
style={{ fontSize: isMobile ? '14px' : undefined }} </div>
> )}
<Descriptions.Item label={t('account.apiKey')}>
<Tag color={account.apiKeyConfigured ? 'success' : 'default'}> <Divider style={{ margin: isMobile ? '12px 0' : '16px 0' }} />
{account.apiKeyConfigured ? t('account.configured') : t('account.notConfigured')}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('account.apiSecret')}>
<Tag color={account.apiSecretConfigured ? 'success' : 'default'}>
{account.apiSecretConfigured ? t('account.configured') : t('account.notConfigured')}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('account.apiPassphrase')}>
<Tag color={account.apiPassphraseConfigured ? 'success' : 'default'}>
{account.apiPassphraseConfigured ? t('account.configured') : t('account.notConfigured')}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('account.apiCredentials')}>
{account.apiKeyConfigured && account.apiSecretConfigured && account.apiPassphraseConfigured ? (
<Tag color="success">{t('account.fullConfig')}</Tag>
) : (
<Tag color="warning">{t('account.partialConfig')}</Tag>
)}
</Descriptions.Item>
</Descriptions>
</Card>
{(account.totalOrders !== undefined || account.totalPnl !== undefined || {(account.totalOrders !== undefined || account.totalPnl !== undefined ||
account.activeOrders !== undefined || account.activeOrders !== undefined ||
+10 -33
View File
@@ -7,6 +7,7 @@ import type { Account } from '../types'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils' import { formatUSDC } from '../utils'
import AccountImportForm from '../components/AccountImportForm' import AccountImportForm from '../components/AccountImportForm'
import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock'
const { Title } = Typography const { Title } = Typography
@@ -204,10 +205,7 @@ const AccountList: React.FC = () => {
setEditAccount(accountDetail) setEditAccount(accountDetail)
editForm.setFieldsValue({ editForm.setFieldsValue({
accountName: accountDetail.accountName || '', accountName: accountDetail.accountName || ''
apiKey: '', // 不显示实际值,留空表示不修改
apiSecret: '', // 不显示实际值,留空表示不修改
apiPassphrase: '' // 不显示实际值,留空表示不修改
}) })
} catch (error: any) { } catch (error: any) {
console.error('打开编辑失败:', error) console.error('打开编辑失败:', error)
@@ -720,35 +718,14 @@ const AccountList: React.FC = () => {
<Divider /> <Divider />
<Descriptions <AccountSetupStatusBlock
column={isMobile ? 1 : 2} accountId={detailAccount.id}
bordered onRefresh={handleRefreshDetailBalance}
size={isMobile ? 'small' : 'middle'} size={isMobile ? 'small' : 'default'}
title={t('accountList.apiCredentials')} showApprovalDetails={true}
> />
<Descriptions.Item label={t('accountList.apiKey')}>
<Tag color={detailAccount.apiKeyConfigured ? 'success' : 'default'}> <Divider />
{detailAccount.apiKeyConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('accountList.apiSecret')}>
<Tag color={detailAccount.apiSecretConfigured ? 'success' : 'default'}>
{detailAccount.apiSecretConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('accountList.apiPassphrase')}>
<Tag color={detailAccount.apiPassphraseConfigured ? 'success' : 'default'}>
{detailAccount.apiPassphraseConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('accountList.configStatus')}>
{detailAccount.apiKeyConfigured && detailAccount.apiSecretConfigured && detailAccount.apiPassphraseConfigured ? (
<Tag color="success">{t('accountList.fullConfig')}</Tag>
) : (
<Tag color="warning">{t('accountList.partialConfig')}</Tag>
)}
</Descriptions.Item>
</Descriptions>
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined || {(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined ||
detailAccount.activeOrders !== undefined || detailAccount.activeOrders !== undefined ||
+60 -23
View File
@@ -26,16 +26,17 @@ import {
} from 'antd' } from 'antd'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons' import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, ArrowUpOutlined, ArrowDownOutlined, FileTextOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api' import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore' import { useAccountStore } from '../store/accountStore'
import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto } from '../types' import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto } from '../types'
import { formatUSDC, formatNumber } from '../utils' import { formatUSDC, formatNumber } from '../utils'
import { getVersionInfo } from '../utils/version'
const CryptoTailStrategyList: React.FC = () => { const CryptoTailStrategyList: React.FC = () => {
const { t } = useTranslation() const { t, i18n } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore() const { accounts, fetchAccounts } = useAccountStore()
@@ -148,7 +149,8 @@ const CryptoTailStrategyList: React.FC = () => {
enabled: true, enabled: true,
amountMode: 'RATIO', amountMode: 'RATIO',
maxPrice: '1', maxPrice: '1',
minSpreadMode: 'AUTO', spreadMode: 'AUTO',
spreadDirection: 'MIN',
windowStartMinutes: 0, windowStartMinutes: 0,
windowStartSeconds: 0 windowStartSeconds: 0
}) })
@@ -169,8 +171,9 @@ const CryptoTailStrategyList: React.FC = () => {
maxPrice: record.maxPrice, maxPrice: record.maxPrice,
amountMode: record.amountMode, amountMode: record.amountMode,
amountValue: record.amountValue, amountValue: record.amountValue,
minSpreadMode: record.minSpreadMode ?? 'AUTO', spreadMode: record.spreadMode ?? 'AUTO',
minSpreadValue: record.minSpreadValue ?? undefined, spreadValue: record.spreadValue ?? undefined,
spreadDirection: record.spreadDirection ?? 'MIN',
enabled: record.enabled enabled: record.enabled
}) })
setFormModalOpen(true) setFormModalOpen(true)
@@ -203,8 +206,9 @@ const CryptoTailStrategyList: React.FC = () => {
maxPrice: v.maxPrice != null ? String(v.maxPrice) : undefined, maxPrice: v.maxPrice != null ? String(v.maxPrice) : undefined,
amountMode: v.amountMode as string, amountMode: v.amountMode as string,
amountValue: String(v.amountValue ?? 0), amountValue: String(v.amountValue ?? 0),
minSpreadMode: (v.minSpreadMode as string) || 'AUTO', spreadMode: (v.spreadMode as string) || 'AUTO',
minSpreadValue: v.minSpreadMode === 'FIXED' && v.minSpreadValue != null ? String(v.minSpreadValue) : (v.minSpreadMode === 'AUTO' && v.minSpreadValue != null ? String(v.minSpreadValue) : undefined), spreadValue: v.spreadMode === 'FIXED' && v.spreadValue != null ? String(v.spreadValue) : (v.spreadMode === 'AUTO' && v.spreadValue != null ? String(v.spreadValue) : undefined),
spreadDirection: v.spreadDirection as string || 'MIN',
enabled: v.enabled !== false enabled: v.enabled !== false
} }
if (editingId) { if (editingId) {
@@ -217,8 +221,9 @@ const CryptoTailStrategyList: React.FC = () => {
maxPrice: payload.maxPrice, maxPrice: payload.maxPrice,
amountMode: payload.amountMode, amountMode: payload.amountMode,
amountValue: payload.amountValue, amountValue: payload.amountValue,
minSpreadMode: payload.minSpreadMode, spreadMode: payload.spreadMode,
minSpreadValue: payload.minSpreadValue, spreadValue: payload.spreadValue,
spreadDirection: payload.spreadDirection,
enabled: payload.enabled enabled: payload.enabled
}) })
if (res.data.code === 0) { if (res.data.code === 0) {
@@ -231,7 +236,7 @@ const CryptoTailStrategyList: React.FC = () => {
} else { } else {
const res = await apiService.cryptoTailStrategy.create({ const res = await apiService.cryptoTailStrategy.create({
...payload, ...payload,
minSpreadValue: payload.minSpreadMode === 'FIXED' ? payload.minSpreadValue : undefined spreadValue: payload.spreadMode === 'FIXED' ? payload.spreadValue : undefined
}) })
if (res.data.code === 0) { if (res.data.code === 0) {
message.success(t('common.success')) message.success(t('common.success'))
@@ -531,9 +536,25 @@ const CryptoTailStrategyList: React.FC = () => {
}) })
}, [formModalOpen, editingId, selectedMarket, intervalSeconds]) }, [formModalOpen, editingId, selectedMarket, intervalSeconds])
const getGuideUrl = () => {
const { githubRepoUrl } = getVersionInfo()
const lang = i18n.language === 'zh-CN' || i18n.language === 'zh-TW' ? 'zh' : 'en'
return `${githubRepoUrl}/blob/main/docs/crypto-tail-strategy/${lang}/crypto-tail-strategy-user-guide.md`
}
return ( return (
<div style={{ padding: isMobile ? 12 : 24 }}> <div style={{ padding: isMobile ? 12 : 24 }}>
<h1 style={{ marginBottom: 16, fontSize: isMobile ? 20 : 24 }}>{t('cryptoTailStrategy.list.title')}</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<h1 style={{ margin: 0, fontSize: isMobile ? 20 : 24 }}>{t('cryptoTailStrategy.list.title')}</h1>
<Button
type="link"
icon={<FileTextOutlined />}
onClick={() => window.open(getGuideUrl(), '_blank')}
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 14 : 16 }}
>
{t('cryptoTailStrategy.list.configGuide')}
</Button>
</div>
{binanceUnhealthy.length > 0 && ( {binanceUnhealthy.length > 0 && (
<Alert <Alert
type="error" type="error"
@@ -723,7 +744,7 @@ const CryptoTailStrategyList: React.FC = () => {
destroyOnClose destroyOnClose
> >
<Alert type="warning" showIcon message={t('cryptoTailStrategy.form.walletTip')} style={{ marginBottom: 16 }} /> <Alert type="warning" showIcon message={t('cryptoTailStrategy.form.walletTip')} style={{ marginBottom: 16 }} />
<Form form={form} layout="vertical" initialValues={{ amountMode: 'RATIO', maxPrice: '1', minSpreadMode: 'AUTO', enabled: true }}> <Form form={form} layout="vertical" initialValues={{ amountMode: 'RATIO', maxPrice: '1', spreadMode: 'AUTO', spreadDirection: 'MIN', enabled: true }}>
<Form.Item name="accountId" label={t('cryptoTailStrategy.form.selectAccount')} rules={[{ required: true }]}> <Form.Item name="accountId" label={t('cryptoTailStrategy.form.selectAccount')} rules={[{ required: true }]}>
<Select <Select
placeholder={t('cryptoTailStrategy.form.selectAccount')} placeholder={t('cryptoTailStrategy.form.selectAccount')}
@@ -817,37 +838,37 @@ const CryptoTailStrategyList: React.FC = () => {
} }
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="minSpreadMode" name="spreadMode"
label={ label={
<Space size={4}> <Space size={4}>
<span>{t('cryptoTailStrategy.form.minSpreadMode')}</span> <span>{t('cryptoTailStrategy.form.spreadMode')}</span>
<Tooltip title={t('cryptoTailStrategy.form.minSpreadModeTip')}> <Tooltip title={t('cryptoTailStrategy.form.spreadModeTip')}>
<InfoCircleOutlined style={{ color: '#999', cursor: 'help', fontSize: 14 }} /> <InfoCircleOutlined style={{ color: '#999', cursor: 'help', fontSize: 14 }} />
</Tooltip> </Tooltip>
</Space> </Space>
} }
> >
<Radio.Group> <Radio.Group>
<Radio value="AUTO">{t('cryptoTailStrategy.form.minSpreadModeAuto')}</Radio> <Radio value="AUTO">{t('cryptoTailStrategy.form.spreadModeAuto')}</Radio>
<Radio value="FIXED">{t('cryptoTailStrategy.form.minSpreadModeFixed')}</Radio> <Radio value="FIXED">{t('cryptoTailStrategy.form.spreadModeFixed')}</Radio>
<Radio value="NONE">{t('cryptoTailStrategy.form.minSpreadModeNone')}</Radio> <Radio value="NONE">{t('cryptoTailStrategy.form.spreadModeNone')}</Radio>
</Radio.Group> </Radio.Group>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
noStyle noStyle
shouldUpdate={(prev, curr) => prev.minSpreadMode !== curr.minSpreadMode} shouldUpdate={(prev, curr) => prev.spreadMode !== curr.spreadMode}
> >
{({ getFieldValue }) => {({ getFieldValue }) =>
getFieldValue('minSpreadMode') === 'FIXED' ? ( getFieldValue('spreadMode') === 'FIXED' ? (
<Form.Item <Form.Item
name="minSpreadValue" name="spreadValue"
label={t('cryptoTailStrategy.form.minSpreadValue')} label={t('cryptoTailStrategy.form.spreadValue')}
rules={[{ required: true }]} rules={[{ required: true }]}
> >
<InputNumber <InputNumber
min={0} min={0}
step={1} step={1}
placeholder={t('cryptoTailStrategy.form.minSpreadValuePlaceholder')} placeholder={t('cryptoTailStrategy.form.spreadValuePlaceholder')}
style={{ width: '100%' }} style={{ width: '100%' }}
stringMode stringMode
/> />
@@ -855,6 +876,22 @@ const CryptoTailStrategyList: React.FC = () => {
) : null ) : null
} }
</Form.Item> </Form.Item>
<Form.Item
name="spreadDirection"
label={
<Space size={4}>
<span>{t('cryptoTailStrategy.form.spreadDirection')}</span>
<Tooltip title={t('cryptoTailStrategy.form.spreadDirectionTip')}>
<InfoCircleOutlined style={{ color: '#999', cursor: 'help', fontSize: 14 }} />
</Tooltip>
</Space>
}
>
<Radio.Group>
<Radio value="MIN">{t('cryptoTailStrategy.form.spreadDirectionMin')}</Radio>
<Radio value="MAX">{t('cryptoTailStrategy.form.spreadDirectionMax')}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item name="enabled" valuePropName="checked"> <Form.Item name="enabled" valuePropName="checked">
<Switch checkedChildren={t('common.enabled')} unCheckedChildren={t('common.disabled')} /> <Switch checkedChildren={t('common.enabled')} unCheckedChildren={t('common.disabled')} />
</Form.Item> </Form.Item>
+21 -4
View File
@@ -218,6 +218,21 @@ export const apiService = {
import: (data: any) => import: (data: any) =>
apiClient.post<ApiResponse<any>>('/accounts/import', data), apiClient.post<ApiResponse<any>>('/accounts/import', data),
/**
*
*/
checkSetupStatus: (accountId: number) =>
apiClient.post<ApiResponse<any>>('/accounts/check-setup-status', { accountId }),
/**
* 1 URL2/3
*/
executeSetupStep: (accountId: number, step: number) =>
apiClient.post<ApiResponse<{ success: boolean; redirectUrl?: string; transactionHash?: string }>>(
'/accounts/execute-setup-step',
{ accountId, step }
),
/** /**
* *
*/ */
@@ -447,8 +462,9 @@ export const apiService = {
maxPrice?: string maxPrice?: string
amountMode: string amountMode: string
amountValue: string amountValue: string
minSpreadMode?: string spreadMode?: string
minSpreadValue?: string | null spreadValue?: string | null
spreadDirection?: string
enabled?: boolean enabled?: boolean
}) => }) =>
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/create', data), apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/create', data),
@@ -461,8 +477,9 @@ export const apiService = {
maxPrice?: string maxPrice?: string
amountMode?: string amountMode?: string
amountValue?: string amountValue?: string
minSpreadMode?: string spreadMode?: string
minSpreadValue?: string | null spreadValue?: string | null
spreadDirection?: string
enabled?: boolean enabled?: boolean
}) => }) =>
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/update', data), apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/update', data),
+9 -7
View File
@@ -932,9 +932,9 @@ export interface SystemConfig {
builderApiKeyConfigured: boolean builderApiKeyConfigured: boolean
builderSecretConfigured: boolean builderSecretConfigured: boolean
builderPassphraseConfigured: boolean builderPassphraseConfigured: boolean
builderApiKeyDisplay?: string // Builder API Key 显示值(部分显示 builderApiKeyDisplay?: string // Builder API Key 显示值(完整
builderSecretDisplay?: string // Builder Secret 显示值(部分显示 builderSecretDisplay?: string // Builder Secret 显示值(完整
builderPassphraseDisplay?: string // Builder Passphrase 显示值(部分显示 builderPassphraseDisplay?: string // Builder Passphrase 显示值(完整
autoRedeemEnabled: boolean // 自动赎回(系统级别配置,默认开启) autoRedeemEnabled: boolean // 自动赎回(系统级别配置,默认开启)
} }
@@ -1051,10 +1051,12 @@ export interface CryptoTailStrategyDto {
maxPrice: string maxPrice: string
amountMode: string amountMode: string
amountValue: string amountValue: string
/** 最小价差模式: NONE, FIXED, AUTO */ /** 价差模式: NONE, FIXED, AUTO */
minSpreadMode?: string spreadMode?: string
/** 最小价差数值(FIXED 时必填;AUTO 时可为计算值) */ /** 价差数值 */
minSpreadValue?: string | null spreadValue?: string | null
/** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */
spreadDirection?: string
enabled: boolean enabled: boolean
lastTriggerAt?: number lastTriggerAt?: number
/** 已实现总收益 USDC */ /** 已实现总收益 USDC */
+2
View File
@@ -4,12 +4,14 @@
"description": "Utility scripts for Polyhermes", "description": "Utility scripts for Polyhermes",
"type": "module", "type": "module",
"scripts": { "scripts": {
"unwrap-wcol": "node unwrap-wcol.js",
"get-order-detail": "node get-order-detail.js", "get-order-detail": "node get-order-detail.js",
"verify-backtest-data": "node verify-backtest-data.js", "verify-backtest-data": "node verify-backtest-data.js",
"ws-binance-klines": "node ws_binance_btc_usdc_klines.js" "ws-binance-klines": "node ws_binance_btc_usdc_klines.js"
}, },
"dependencies": { "dependencies": {
"@ethersproject/wallet": "^5.7.0", "@ethersproject/wallet": "^5.7.0",
"ethers": "^5.7.1",
"@polymarket/clob-client": "^5.2.1", "@polymarket/clob-client": "^5.2.1",
"ws": "^8.18.0" "ws": "^8.18.0"
} }
+450
View File
@@ -0,0 +1,450 @@
#!/usr/bin/env node
/**
* Wrapped Collateral (WCOL) 解包为 USDC.e
*
* Polygon Polymarket 使用 USDC.eBridged USDC解包后到账的为 USDC.e在钱包/区块浏览器中显示为 USDC.e
*
* 合约: Polymarket Neg Risk WrappedCollateral
* Polygon: 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2
* 方法: unwrap(address _to, uint256 _amount)
*
* 使用方式:
* 1) Safe 解包Gasless Builder 凭证主钱包无需 POL:
* PRIVATE_KEY=0x... SAFE_ADDRESS=0xB4c3... BUILDERS_API_KEY=... BUILDERS_SECRET=... BUILDERS_PASSPHRASE=... node scripts/unwrap-wcol.js
* 2) Safe 解包自付 gas主钱包需少量 POL:
* PRIVATE_KEY=0x... SAFE_ADDRESS=0xB4c3... node scripts/unwrap-wcol.js
* 3) EOA 解包WCOL 在主钱包上:
* PRIVATE_KEY=0x... node scripts/unwrap-wcol.js
*
* 环境变量:
* PRIVATE_KEY 主钱包私钥必需
* SAFE_ADDRESS 若设置则从 Safe 执行 unwrap
* UNWRAP_TO_MAIN_WALLET=1 Safe 解包时USDC.e 转到主钱包不设则转到 Safe
* BUILDERS_API_KEY / BUILDERS_SECRET / BUILDERS_PASSPHRASE 三者齐备时走 Builder RelayerGasless主钱包无需 POL
* RPC_URL Polygon RPC可选
* RELAYER_URL Builder Relayer 地址可选默认 https://relayer-v2.polymarket.com
*/
import { ethers } from "ethers";
import crypto from "crypto";
const CHAIN_ID = 137;
const RPC_URL = process.env.RPC_URL || "https://polygon-rpc.com";
const RELAYER_URL = (process.env.RELAYER_URL || "https://relayer-v2.polymarket.com").replace(/\/$/, "");
const WCOL_ADDRESS = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2";
const WCOL_ABI = [
"function balanceOf(address account) view returns (uint256)",
"function decimals() view returns (uint8)",
"function unwrap(address _to, uint256 _amount)",
];
// Safe 合约:nonce()
const SAFE_ABI = ["function nonce() view returns (uint256)"];
function normalizePrivateKey(key) {
if (!key || !key.trim()) return null;
const k = key.trim();
return k.startsWith("0x") ? k : "0x" + k;
}
function getPrivateKey() {
const key = normalizePrivateKey(process.env.PRIVATE_KEY);
if (!key) {
console.error("请设置环境变量 PRIVATE_KEY(主钱包私钥)");
process.exit(1);
}
return key;
}
// EIP-712: domain type hash for Safe (chainId + verifyingContract)
function getSafeDomainTypeHash() {
const typeStr = "EIP712Domain(uint256 chainId,address verifyingContract)";
return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(typeStr));
}
function encodeSafeDomain(chainId, verifyingContract) {
const typeHash = getSafeDomainTypeHash();
const chainIdHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(chainId).toHexString(), 32);
const contractHex = ethers.utils.hexZeroPad(verifyingContract.toLowerCase(), 32);
return ethers.utils.keccak256(ethers.utils.concat([typeHash, chainIdHex, contractHex]));
}
// SafeTx type hash
function getSafeTxTypeHash() {
const typeStr =
"SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)";
return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(typeStr));
}
function encodeSafeTxMessage(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce) {
const typeHash = getSafeTxTypeHash();
const toHex = ethers.utils.hexZeroPad(to.toLowerCase(), 32);
const valueHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(value).toHexString(), 32);
const dataHash = data && data !== "0x" ? ethers.utils.keccak256(data) : ethers.constants.HashZero;
const opHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(operation).toHexString(), 32);
const safeTxGasHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(safeTxGas).toHexString(), 32);
const baseGasHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(baseGas).toHexString(), 32);
const gasPriceHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(gasPrice).toHexString(), 32);
const gasTokenHex = ethers.utils.hexZeroPad(gasToken.toLowerCase(), 32);
const refundHex = ethers.utils.hexZeroPad(refundReceiver.toLowerCase(), 32);
const nonceHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(nonce).toHexString(), 32);
return ethers.utils.keccak256(
ethers.utils.concat([
typeHash,
toHex,
valueHex,
dataHash,
opHex,
safeTxGasHex,
baseGasHex,
gasPriceHex,
gasTokenHex,
refundHex,
nonceHex,
])
);
}
function hashStructuredData(domainSeparator, messageHash) {
const prefix = "0x1901";
return ethers.utils.keccak256(ethers.utils.concat([prefix, domainSeparator, messageHash]));
}
// Gnosis Safe 签名:先对 structHash 做 personal_sign 风格(\x19Ethereum Signed Message:\n32 + hash),再对结果做 keccak256 后签名
async function signSafeTx(wallet, structHash) {
const hashBytes = ethers.utils.arrayify(structHash);
const messagePrefix = "\x19Ethereum Signed Message:\n32";
const prefixed = ethers.utils.concat([
ethers.utils.toUtf8Bytes(messagePrefix),
hashBytes,
]);
const hashToSign = ethers.utils.keccak256(prefixed);
const sig = await wallet._signingKey().signDigest(ethers.utils.arrayify(hashToSign));
let v = sig.v;
if (v < 27) v += 27;
const r = ethers.utils.hexZeroPad(ethers.BigNumber.from(sig.r).toHexString(), 32).slice(2);
const s = ethers.utils.hexZeroPad(ethers.BigNumber.from(sig.s).toHexString(), 32).slice(2);
const vByte = v.toString(16).padStart(2, "0");
return "0x" + r + s + vByte;
}
// Builder Relayer 要求 Safe 签名使用调整后的 v(与后端 splitAndPackSig 一致),否则链上恢复签名者会失败
function packSafeSignatureForRelayer(signatureHex) {
const raw = signatureHex.startsWith("0x") ? signatureHex.slice(2) : signatureHex;
if (raw.length !== 130) throw new Error("签名长度应为 65 字节 (130 个十六进制字符)");
const r = raw.slice(0, 64);
const s = raw.slice(64, 128);
const vHex = raw.slice(128, 130);
let v = parseInt(vHex, 16);
const adjustedV = v === 0 || v === 1 ? v + 31 : v === 27 || v === 28 ? v + 4 : v;
return "0x" + r + s + adjustedV.toString(16).padStart(2, "0");
}
// 构建 execTransaction(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, signatures)
function buildExecTransactionCalldata(to, data, operation, signatureHex) {
const iface = new ethers.utils.Interface([
"function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures)",
]);
const value = 0;
const safeTxGas = 0;
const baseGas = 0;
const gasPrice = 0;
const gasToken = ethers.constants.AddressZero;
const refundReceiver = ethers.constants.AddressZero;
const sigBytes = ethers.utils.arrayify(signatureHex);
return iface.encodeFunctionData("execTransaction", [
to,
value,
data,
operation,
safeTxGas,
baseGas,
gasPrice,
gasToken,
refundReceiver,
sigBytes,
]);
}
function isBuildersConfigured() {
const k = process.env.BUILDERS_API_KEY?.trim();
const s = process.env.BUILDERS_SECRET?.trim();
const p = process.env.BUILDERS_PASSPHRASE?.trim();
return !!(k && s && p);
}
function buildBuilderSignature(signString, secret) {
let decodedSecret;
try {
decodedSecret = Buffer.from(secret, "base64");
} catch {
try {
decodedSecret = Buffer.from(secret.replace(/-/g, "+").replace(/_/g, "/"), "base64");
} catch {
decodedSecret = Buffer.from(secret, "utf8");
}
}
const hmac = crypto.createHmac("sha256", decodedSecret);
hmac.update(signString, "utf8");
const base64 = hmac.digest("base64");
return base64.replace(/\+/g, "-").replace(/\//g, "_");
}
function getBuilderHeaders(method, path, body, apiKey, secret, passphrase) {
const timestamp = Date.now().toString();
const bodyStr = body ?? "";
const signString = timestamp + method + path + bodyStr;
const signature = buildBuilderSignature(signString, secret);
return {
POLY_BUILDER_SIGNATURE: signature,
POLY_BUILDER_TIMESTAMP: timestamp,
POLY_BUILDER_API_KEY: apiKey,
POLY_BUILDER_PASSPHRASE: passphrase,
};
}
async function relayerGetNonce(fromAddress) {
const pathForSign = "/nonce";
const url = RELAYER_URL + "/nonce?address=" + encodeURIComponent(fromAddress) + "&type=SAFE";
const headers = getBuilderHeaders(
"GET",
pathForSign,
"",
process.env.BUILDERS_API_KEY.trim(),
process.env.BUILDERS_SECRET.trim(),
process.env.BUILDERS_PASSPHRASE.trim()
);
const res = await fetch(url, { method: "GET", headers });
if (!res.ok) {
const text = await res.text();
throw new Error("Relayer getNonce 失败: " + res.status + " " + text);
}
const data = await res.json();
return data.nonce;
}
async function relayerSubmit(requestBody) {
const pathForSign = "/submit";
const body = JSON.stringify(requestBody);
const headers = {
"Content-Type": "application/json",
...getBuilderHeaders(
"POST",
pathForSign,
body,
process.env.BUILDERS_API_KEY.trim(),
process.env.BUILDERS_SECRET.trim(),
process.env.BUILDERS_PASSPHRASE.trim()
),
};
const res = await fetch(RELAYER_URL + pathForSign, { method: "POST", headers, body });
const rawText = await res.text();
if (!res.ok) throw new Error("Relayer submit 失败: " + res.status + " " + rawText);
const data = JSON.parse(rawText);
console.log("Relayer submit 原始响应:", JSON.stringify(data, null, 2));
return data;
}
async function relayerGetTransaction(transactionId) {
const pathForSign = "/transaction";
const url = RELAYER_URL + "/transaction?id=" + encodeURIComponent(transactionId);
const headers = getBuilderHeaders(
"GET",
pathForSign,
"",
process.env.BUILDERS_API_KEY.trim(),
process.env.BUILDERS_SECRET.trim(),
process.env.BUILDERS_PASSPHRASE.trim()
);
const res = await fetch(url, { method: "GET", headers });
const rawText = await res.text();
if (!res.ok) throw new Error("Relayer getTransaction 失败: " + res.status + " " + rawText);
const data = JSON.parse(rawText);
return { parsed: Array.isArray(data) ? data[0] : data, raw: data };
}
function isSuccessState(state) {
if (!state) return false;
const s = String(state).toUpperCase();
return s === "STATE_CONFIRMED" || s === "STATE_MINED" || s === "STATE_EXECUTED";
}
function isFailedState(state) {
if (!state) return false;
const s = String(state).toUpperCase();
return s === "STATE_FAILED" || s === "STATE_INVALID";
}
// 链上交易 hash 应为 0x + 64 个十六进制字符
function isChainTxHash(s) {
if (!s || typeof s !== "string") return false;
const t = s.trim();
return /^0x[0-9a-fA-F]{64}$/.test(t);
}
function getChainTxHashFromTx(tx) {
if (!tx) return null;
const candidates = [
tx.transactionHash,
tx.hash,
tx.txHash,
tx.blockchainHash,
tx.chainTxHash,
tx.executionHash,
];
for (const c of candidates) {
if (isChainTxHash(c)) return c;
}
return null;
}
async function pollUntilMined(transactionId, maxAttempts = 30, intervalMs = 3000) {
for (let i = 0; i < maxAttempts; i++) {
const { parsed: tx, raw: rawData } = await relayerGetTransaction(transactionId);
const state = tx?.state;
if (isSuccessState(state)) {
console.log("Relayer getTransaction 原始响应 (最终状态):", JSON.stringify(rawData, null, 2));
const chainHash = getChainTxHashFromTx(tx);
if (chainHash) return chainHash;
return null;
}
if (isFailedState(state)) {
console.log("Relayer getTransaction 原始响应 (失败状态):", JSON.stringify(rawData, null, 2));
const detail = tx.transactionHash ? ` txHash=${tx.transactionHash}` : "";
const errMsg = tx.errorMessage ?? tx.error ?? tx.revertReason ?? "";
const full = errMsg ? ` ${errMsg}` : detail;
throw new Error("Relayer 交易失败: state=" + state + full);
}
if (i === 0) console.log("等待 Relayer 确认交易,每", intervalMs / 1000, "秒查询一次...");
console.log(` [${i + 1}/${maxAttempts}] state=${state ?? "未知"}`);
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error("等待交易确认超时");
}
async function main() {
const privateKey = getPrivateKey();
const safeAddress = process.env.SAFE_ADDRESS?.trim();
const provider = new ethers.providers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(privateKey, provider);
const wcol = new ethers.Contract(WCOL_ADDRESS, WCOL_ABI, provider);
const ownerAddress = wallet.address;
const targetBalanceAddress = safeAddress || ownerAddress;
const balanceRaw = await wcol.balanceOf(targetBalanceAddress);
const decimals = await wcol.decimals();
const balanceFormatted = ethers.utils.formatUnits(balanceRaw, decimals);
if (balanceRaw.isZero()) {
console.log(`地址 ${targetBalanceAddress} 的 WCOL 余额为 0,无需解包。`);
return;
}
console.log(`WCOL 余额: ${balanceFormatted} (${targetBalanceAddress})`);
if (safeAddress) {
const iface = new ethers.utils.Interface(WCOL_ABI);
// 从 Safe 解包时,USDC 默认转到 Safe;设环境变量 UNWRAP_TO_MAIN_WALLET=1 则转到主钱包
const unwrapTo = process.env.UNWRAP_TO_MAIN_WALLET === "1" ? ownerAddress : safeAddress;
const unwrapData = iface.encodeFunctionData("unwrap", [unwrapTo, balanceRaw]);
const safeContract = new ethers.Contract(safeAddress, SAFE_ABI, provider);
const useRelayer = isBuildersConfigured();
let nonce;
if (useRelayer) {
const relayerNonce = await relayerGetNonce(safeAddress);
nonce = await safeContract.nonce();
console.log("Nonce: Relayer 返回=" + relayerNonce.toString() + ", Safe 链上=" + nonce.toString() + "Gasless 一律用链上 nonce 签名)");
} else {
nonce = await safeContract.nonce();
}
const domainSeparator = encodeSafeDomain(CHAIN_ID, safeAddress);
const zero = "0";
const gasToken = ethers.constants.AddressZero;
const messageHash = encodeSafeTxMessage(
WCOL_ADDRESS,
zero,
unwrapData,
0,
zero,
zero,
zero,
gasToken,
gasToken,
nonce
);
const structHash = hashStructuredData(domainSeparator, messageHash);
const signatureHex = await signSafeTx(wallet, structHash);
if (useRelayer) {
console.log("使用 Builder RelayerGasless),主钱包无需 POL");
console.log("若链上仍报 GS026,多为 Relayer 上链前 Safe 又执行了其他交易导致 nonce 变化,可改用自付 gas(不设 BUILDERS_*)或确保此时无其他 Safe 交易。");
const relayerSignature = packSafeSignatureForRelayer(signatureHex);
const requestBody = {
type: "SAFE",
from: ownerAddress,
to: WCOL_ADDRESS,
proxyWallet: safeAddress,
data: unwrapData,
nonce: nonce.toString(),
signature: relayerSignature,
signatureParams: {
gasPrice: "0",
operation: "0",
safeTxnGas: "0",
baseGas: "0",
gasToken: ethers.constants.AddressZero,
refundReceiver: ethers.constants.AddressZero,
},
metadata: "unwrap WCOL to USDC.e",
};
const result = await relayerSubmit(requestBody);
const txId = result.transactionID ?? result.transactionId ?? result.id ?? result.transaction_id;
if (!txId) {
console.log("Relayer 已提交,响应:", JSON.stringify(result, null, 2));
return;
}
console.log("Relayer 已提交,transactionId:", txId);
const txHash = await pollUntilMined(txId);
if (txHash) {
console.log("交易已确认 (Polygon):", txHash);
} else {
console.log("Relayer 已标记为成功,但未返回链上 tx hash。可在 https://polygonscan.com 用 Safe 地址查看最新交易:", safeAddress);
console.log("若需排查,可设置 DEBUG=1 查看 Relayer 返回的完整数据");
}
console.log("已解包", balanceFormatted, "WCOL → USDC.e,到账地址:", unwrapTo);
return;
}
const execCalldata = buildExecTransactionCalldata(WCOL_ADDRESS, unwrapData, 0, signatureHex);
console.log("正在由 Safe 执行 unwrap,主钱包发送交易(需少量 POL 付 gas...");
const tx = await wallet.sendTransaction({
to: safeAddress,
data: execCalldata,
value: 0,
gasLimit: 500000,
});
console.log("Tx hash:", tx.hash);
const receipt = await tx.wait();
console.log("已确认,block:", receipt.blockNumber);
console.log("已解包", balanceFormatted, "WCOL → USDC.e,到账地址:", unwrapTo);
return;
}
console.log("正在发送 unwrap 交易,USDC.e 将转到:", ownerAddress);
const tx = await wcol.connect(wallet).unwrap(ownerAddress, balanceRaw);
console.log("Tx hash:", tx.hash);
const receipt = await tx.wait();
console.log("已确认,block:", receipt.blockNumber);
console.log("已解包", balanceFormatted, "WCOL → USDC.e");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});