diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/BuilderRelayerApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/BuilderRelayerApi.kt index 01b07eb..b0a58b3 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/BuilderRelayerApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/BuilderRelayerApi.kt @@ -92,7 +92,7 @@ interface BuilderRelayerApi { val data: String, // 调用数据(十六进制字符串,带 0x 前缀) @SerializedName("nonce") - val nonce: String, // Safe nonce(字符串) + val nonce: String? = null, // Safe nonce(SAFE 必填,SAFE-CREATE 不传) @SerializedName("signature") val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀) @@ -138,7 +138,17 @@ interface BuilderRelayerApi { val relayHub: String? = null, @SerializedName("relay") - val relay: String? = null + val relay: String? = null, + + /** SAFE-CREATE 签名参数 */ + @SerializedName("paymentToken") + val paymentToken: String? = null, + + @SerializedName("payment") + val payment: String? = null, + + @SerializedName("paymentReceiver") + val paymentReceiver: String? = null ) /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/constants/PolymarketConstants.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/constants/PolymarketConstants.kt index 3df493c..11d90b2 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/constants/PolymarketConstants.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/constants/PolymarketConstants.kt @@ -44,5 +44,14 @@ object PolymarketConstants { * 用于 Gasless 交易 */ const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/" + + /** + * Polymarket Safe 代理工厂合约地址(Polygon 主网) + * 用于 Safe 类型账户的代理部署(SAFE-CREATE) + */ + const val SAFE_PROXY_FACTORY_ADDRESS = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b" + + /** SafeCreate 用 EIP-712 domain name,与 builder-relayer-client 一致 */ + const val SAFE_FACTORY_EIP712_NAME = "Polymarket Contract Proxy Factory" } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt index b91212d..3886adb 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt @@ -204,6 +204,82 @@ class AccountController( } } + /** + * 检查账户设置状态(代理部署、交易启用、代币批准) + */ + @PostMapping("/check-setup-status") + fun checkSetupStatus(@RequestBody request: AccountDetailRequest): ResponseEntity> { + 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> { + 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)) + } + } + /** * 查询账户详情 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountSetupStatusDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountSetupStatusDto.kt new file mode 100644 index 0000000..975b224 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountSetupStatusDto.kt @@ -0,0 +1,63 @@ +package com.wrbug.polymarketbot.dto + +/** + * 账户设置状态检查结果 + */ +data class AccountSetupStatusDto( + /** + * 步骤1:代理钱包是否已部署 + */ + val proxyDeployed: Boolean, + + /** + * 步骤2:交易是否已启用(API Key 是否已配置) + */ + val tradingEnabled: Boolean, + + /** + * 步骤3:代币是否已批准 + */ + val tokensApproved: Boolean, + + /** + * 代币批准详情(各合约的授权额度) + * Key: 合约名称(CTF_CONTRACT, CTF_EXCHANGE, NEG_RISK_EXCHANGE, NEG_RISK_ADAPTER) + * Value: 授权额度(USDC,6位小数) + */ + val approvalDetails: Map? = 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 // 设置状态检查结果(可选) +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt index fca8be9..c1e6bf4 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt @@ -15,8 +15,12 @@ data class CryptoTailStrategyCreateRequest( val maxPrice: String? = null, val amountMode: String = "RATIO", val amountValue: String = "0", - val minSpreadMode: String = "NONE", - val minSpreadValue: String? = null, + /** 价差模式: NONE, FIXED, AUTO */ + val spreadMode: String = "NONE", + /** 价差数值 */ + val spreadValue: String? = null, + /** 价差方向: MIN=最小价差, MAX=最大价差 */ + val spreadDirection: String = "MIN", val enabled: Boolean = true ) @@ -32,8 +36,12 @@ data class CryptoTailStrategyUpdateRequest( val maxPrice: String? = null, val amountMode: String? = null, val amountValue: String? = null, - val minSpreadMode: String? = null, - val minSpreadValue: String? = null, + /** 价差模式: NONE, FIXED, AUTO */ + val spreadMode: String? = null, + /** 价差数值 */ + val spreadValue: String? = null, + /** 价差方向: MIN=最小价差, MAX=最大价差 */ + val spreadDirection: String? = null, val enabled: Boolean? = null ) @@ -61,8 +69,12 @@ data class CryptoTailStrategyDto( val maxPrice: String = "1", val amountMode: String = "RATIO", val amountValue: String = "0", - val minSpreadMode: String = "NONE", - val minSpreadValue: String? = null, + /** 价差模式: NONE, FIXED, AUTO */ + val spreadMode: String = "NONE", + /** 价差数值 */ + val spreadValue: String? = null, + /** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */ + val spreadDirection: String = "MIN", val enabled: Boolean = true, val lastTriggerAt: Long? = null, /** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */ @@ -138,7 +150,7 @@ data class CryptoTailStrategyTriggerListResponse( ) /** - * 自动最小价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7) + * 自动价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7) */ data class CryptoTailAutoMinSpreadResponse( val minSpreadUp: String = "0", diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt index 7aa31cf..6a200a0 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt @@ -1,8 +1,11 @@ package com.wrbug.polymarketbot.entity +import com.wrbug.polymarketbot.enums.SpreadDirection +import com.wrbug.polymarketbot.enums.SpreadDirectionConverter +import com.wrbug.polymarketbot.enums.SpreadMode +import com.wrbug.polymarketbot.enums.SpreadModeConverter import jakarta.persistence.* import java.math.BigDecimal -import com.wrbug.polymarketbot.util.toSafeBigDecimal /** * 加密市场尾盘策略实体 @@ -45,11 +48,19 @@ data class CryptoTailStrategy( @Column(name = "amount_value", nullable = false, precision = 20, scale = 8) val amountValue: BigDecimal = BigDecimal.ZERO, - @Column(name = "min_spread_mode", nullable = false, length = 16) - val minSpreadMode: String = "NONE", + /** 价差模式: NONE=不校验, FIXED=固定值, AUTO=历史计算 */ + @Convert(converter = SpreadModeConverter::class) + @Column(name = "spread_mode", nullable = false, columnDefinition = "TINYINT") + val spreadMode: SpreadMode = SpreadMode.NONE, - @Column(name = "min_spread_value", precision = 20, scale = 8) - val minSpreadValue: BigDecimal? = null, + /** 价差数值(FIXED 时必填;AUTO 时可存计算值) */ + @Column(name = "spread_value", precision = 20, scale = 8) + val spreadValue: BigDecimal? = null, + + /** 价差方向: MIN=最小价差(价差>=配置值触发),MAX=最大价差(价差<=配置值触发) */ + @Convert(converter = SpreadDirectionConverter::class) + @Column(name = "spread_direction", nullable = false, columnDefinition = "TINYINT") + val spreadDirection: SpreadDirection = SpreadDirection.MIN, @Column(name = "enabled", nullable = false) val enabled: Boolean = true, diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/SpreadDirection.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/SpreadDirection.kt new file mode 100644 index 0000000..b161ca4 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/SpreadDirection.kt @@ -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") + } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/SpreadDirectionConverter.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/SpreadDirectionConverter.kt new file mode 100644 index 0000000..8c02837 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/SpreadDirectionConverter.kt @@ -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 { + + override fun convertToDatabaseColumn(attribute: SpreadDirection?): Int { + return attribute?.value ?: SpreadDirection.MIN.value + } + + override fun convertToEntityAttribute(dbData: Int?): SpreadDirection { + return SpreadDirection.fromValueOrDefault(dbData) + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/SpreadMode.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/SpreadMode.kt new file mode 100644 index 0000000..589a306 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/SpreadMode.kt @@ -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") + } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/SpreadModeConverter.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/SpreadModeConverter.kt new file mode 100644 index 0000000..8ea01ac --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/SpreadModeConverter.kt @@ -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 { + + override fun convertToDatabaseColumn(attribute: SpreadMode?): Int { + return attribute?.value ?: SpreadMode.NONE.value + } + + override fun convertToEntityAttribute(dbData: Int?): SpreadMode { + return SpreadMode.fromValueOrDefault(dbData) + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt index 2008054..f2d3219 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt @@ -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 { + 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() + 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 { + 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) + } + } + /** * 更新账户信息 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt index fd0ec15..7ad37b3 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt @@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.service.cryptotail import com.wrbug.polymarketbot.api.GammaEventBySlugResponse import com.wrbug.polymarketbot.constants.PolymarketConstants import com.wrbug.polymarketbot.entity.CryptoTailStrategy +import com.wrbug.polymarketbot.enums.SpreadMode import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService @@ -225,7 +226,7 @@ class CryptoTailOrderbookWsService( } if (oldTokenIds == tokenIds.toSet()) { scheduleRefreshAtPeriodEnd(newMap) - precomputeAutoMinSpreadForCurrentPeriods(newMap) + precomputeAutoSpreadForCurrentPeriods(newMap) return } closeWebSocketAndReconnect() @@ -244,7 +245,7 @@ class CryptoTailOrderbookWsService( return } scheduleRefreshAtPeriodEnd(newMap) - precomputeAutoMinSpreadForCurrentPeriods(newMap) + precomputeAutoSpreadForCurrentPeriods(newMap) } /** @@ -264,11 +265,11 @@ class CryptoTailOrderbookWsService( } /** - * AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期最小价差,触发时直接用缓存。 + * AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期价差,触发时直接用缓存。 */ - private fun precomputeAutoMinSpreadForCurrentPeriods(newMap: Map>) { + private fun precomputeAutoSpreadForCurrentPeriods(newMap: Map>) { val autoPeriods = newMap.values.asSequence().flatten() - .filter { it.strategy.minSpreadMode.uppercase() == "AUTO" } + .filter { it.strategy.spreadMode == SpreadMode.AUTO } .distinctBy { "${it.strategy.intervalSeconds}-${it.periodStartUnix}" } .map { it.strategy.intervalSeconds to it.periodStartUnix } .toList() diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt index a26ec4b..3537851 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt @@ -6,6 +6,8 @@ import com.wrbug.polymarketbot.api.PolymarketClobApi import com.wrbug.polymarketbot.entity.Account import com.wrbug.polymarketbot.entity.CryptoTailStrategy import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger +import com.wrbug.polymarketbot.enums.SpreadMode +import com.wrbug.polymarketbot.enums.SpreadDirection import com.wrbug.polymarketbot.repository.AccountRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository @@ -34,6 +36,9 @@ import java.util.regex.Pattern /** 尾盘策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */ private const val TRIGGER_FIXED_PRICE = "0.99" +/** 最大价差模式(MAX)时,买入价格调整系数(加在触发价格上) */ +private const val SPREAD_MAX_PRICE_ADJUSTMENT = "0.02" + /** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */ private const val SIZE_DECIMAL_SCALE = 2 @@ -190,51 +195,58 @@ class CryptoTailStrategyExecutionService( val closePrice = oc?.second?.toPlainString() ?: "-" val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "尾盘策略-${strategy.marketSlugPrefix}" val direction = if (outcomeIndex == 0) "Up" else "Down" + val modeStr = if (strategy.spreadDirection == SpreadDirection.MAX) "最大价差" else "最小价差" logger.info( "尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " + "openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " + - "direction=$direction, outcomeIndex=$outcomeIndex" + "direction=$direction, outcomeIndex=$outcomeIndex, spreadMode=$modeStr" ) } - if (!passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock + if (!passSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock ensurePeriodContext(strategy, periodStartUnix, tokenIds, marketTitle) placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid) } } - private fun passMinSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean { - val mode = strategy.minSpreadMode.uppercase() - if (mode == "NONE") return true + private fun passSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean { + if (strategy.spreadMode == SpreadMode.NONE) return true val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix) ?: return false val (openP, closeP) = oc val spreadAbs = closeP.subtract(openP).abs() - when (mode) { - "FIXED" -> { - val effectiveMinSpread = strategy.minSpreadValue?.takeIf { it > BigDecimal.ZERO } - if (effectiveMinSpread == null || effectiveMinSpread <= BigDecimal.ZERO) return true - return spreadAbs >= effectiveMinSpread + + // 获取有效价差 + val effectiveSpread = when (strategy.spreadMode) { + SpreadMode.FIXED -> { + strategy.spreadValue?.takeIf { it > BigDecimal.ZERO } ?: return true } - "AUTO" -> { - val result = computeAutoEffectiveMinSpread(strategy, periodStartUnix, outcomeIndex) ?: return true - val effectiveMinSpread = result.effectiveMinSpread - if (effectiveMinSpread <= BigDecimal.ZERO) return true - return spreadAbs >= effectiveMinSpread + SpreadMode.AUTO -> { + val result = computeAutoEffectiveSpread(strategy, periodStartUnix, outcomeIndex) ?: return true + result.effectiveSpread.takeIf { it > BigDecimal.ZERO } ?: return true } - else -> return true + SpreadMode.NONE -> return true + } + + // 根据价差方向判断 + return if (strategy.spreadDirection == SpreadDirection.MAX) { + // 最大价差模式:价差 <= 配置值时触发 + spreadAbs <= effectiveSpread + } else { + // 最小价差模式:价差 >= 配置值时触发 + spreadAbs >= effectiveSpread } } /** - * AUTO 模式:取 100% 基准价差,按窗口内毫秒进度计算动态系数(100%→50%)得到有效最小价差。 + * AUTO 模式:取 100% 基准价差,按窗口内毫秒进度计算动态系数(100%→50%)得到有效价差。 */ private data class AutoSpreadResult( val baseSpread: BigDecimal, val coefficient: BigDecimal, - val effectiveMinSpread: BigDecimal + val effectiveSpread: BigDecimal ) - private fun computeAutoEffectiveMinSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): AutoSpreadResult? { + private fun computeAutoEffectiveSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): AutoSpreadResult? { val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(strategy.intervalSeconds, periodStartUnix, outcomeIndex) ?: binanceKlineAutoSpreadService.computeAndCache(strategy.intervalSeconds, periodStartUnix)?.let { if (outcomeIndex == 0) it.first else it.second } ?: return null @@ -251,8 +263,8 @@ class CryptoTailStrategyExecutionService( .let { p -> maxOf(BigDecimal.ZERO, minOf(BigDecimal.ONE, p)) } BigDecimal.ONE.subtract(progress.multi("0.5")) } - val effectiveMinSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP) - return AutoSpreadResult(baseSpread, coefficient, effectiveMinSpread) + val effectiveSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP) + return AutoSpreadResult(baseSpread, coefficient, effectiveSpread) } private suspend fun placeOrderForTrigger( @@ -284,7 +296,15 @@ class CryptoTailStrategyExecutionService( return } - val price = BigDecimal(TRIGGER_FIXED_PRICE) + // 根据价差方向确定下单价格 + val price = if (strategy.spreadDirection == SpreadDirection.MAX) { + // 最大价差模式:触发价格 + 0.02 + triggerPrice.add(BigDecimal(SPREAD_MAX_PRICE_ADJUSTMENT)).setScale(8, RoundingMode.HALF_UP) + } else { + // 最小价差模式:固定价格 0.99 + BigDecimal(TRIGGER_FIXED_PRICE) + } + val priceStr = price.toPlainString() val size = computeSize(amountUsdc, price) val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0" val signedOrder = orderSigningService.createAndSignOrder( @@ -292,7 +312,7 @@ class CryptoTailStrategyExecutionService( makerAddress = ctx.account.proxyAddress, tokenId = tokenId, side = "BUY", - price = TRIGGER_FIXED_PRICE, + price = priceStr, size = size, signatureType = ctx.signatureType, nonce = "0", @@ -380,7 +400,16 @@ class CryptoTailStrategyExecutionService( saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界") return } - val price = BigDecimal(TRIGGER_FIXED_PRICE) + + // 根据价差方向确定下单价格 + val price = if (strategy.spreadDirection == SpreadDirection.MAX) { + // 最大价差模式:触发价格 + 0.02 + triggerPrice.add(BigDecimal(SPREAD_MAX_PRICE_ADJUSTMENT)).setScale(8, RoundingMode.HALF_UP) + } else { + // 最小价差模式:固定价格 0.99 + BigDecimal(TRIGGER_FIXED_PRICE) + } + val priceStr = price.toPlainString() val size = computeSize(amountUsdc, price) val decryptedKey = try { @@ -405,7 +434,7 @@ class CryptoTailStrategyExecutionService( makerAddress = account.proxyAddress, tokenId = tokenId, side = "BUY", - price = TRIGGER_FIXED_PRICE, + price = priceStr, size = size, signatureType = signatureType, nonce = "0", diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt index 491befd..2d9eafc 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt @@ -4,6 +4,8 @@ import com.wrbug.polymarketbot.dto.* import com.wrbug.polymarketbot.entity.CryptoTailStrategy import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.enums.SpreadMode +import com.wrbug.polymarketbot.enums.SpreadDirection import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent @@ -62,12 +64,18 @@ class CryptoTailStrategyService( if (amountValue <= BigDecimal.ZERO) { return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) } - val minSpreadMode = (request.minSpreadMode ?: "NONE").uppercase() - if (minSpreadMode != "NONE" && minSpreadMode != "FIXED" && minSpreadMode != "AUTO") { + val spreadMode = try { + SpreadMode.fromString(request.spreadMode) + } catch (e: Exception) { return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) } - val minSpreadValue = request.minSpreadValue?.toSafeBigDecimal() - if (minSpreadMode == "FIXED" && (minSpreadValue == null || minSpreadValue < BigDecimal.ZERO)) { + val spreadValue = request.spreadValue?.toSafeBigDecimal() + if (spreadMode == SpreadMode.FIXED && (spreadValue == null || spreadValue < BigDecimal.ZERO)) { + return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) + } + val spreadDirection = try { + SpreadDirection.fromString(request.spreadDirection) + } catch (e: Exception) { return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) } @@ -85,8 +93,9 @@ class CryptoTailStrategyService( maxPrice = maxPrice, amountMode = amountMode, amountValue = amountValue, - minSpreadMode = minSpreadMode, - minSpreadValue = minSpreadValue, + spreadMode = spreadMode, + spreadValue = spreadValue, + spreadDirection = spreadDirection, enabled = request.enabled ) val saved = strategyRepository.save(entity) @@ -121,13 +130,27 @@ class CryptoTailStrategyService( ?: existing.name?.takeIf { it.isNotBlank() } ?: generateStrategyName(existing.marketSlugPrefix) - val newMinSpreadMode = request.minSpreadMode?.uppercase() ?: existing.minSpreadMode - if (newMinSpreadMode != "NONE" && newMinSpreadMode != "FIXED" && newMinSpreadMode != "AUTO") { + val newSpreadMode = if (request.spreadMode != null) { + try { + SpreadMode.fromString(request.spreadMode) + } catch (e: Exception) { + return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) + } + } else { + existing.spreadMode + } + val newSpreadValue = request.spreadValue?.toSafeBigDecimal() ?: existing.spreadValue + if (newSpreadMode == SpreadMode.FIXED && (newSpreadValue == null || newSpreadValue < BigDecimal.ZERO)) { return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) } - val newMinSpreadValue = request.minSpreadValue?.toSafeBigDecimal() ?: existing.minSpreadValue - if (newMinSpreadMode == "FIXED" && (newMinSpreadValue == null || newMinSpreadValue < BigDecimal.ZERO)) { - return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) + val newSpreadDirection = if (request.spreadDirection != null) { + try { + SpreadDirection.fromString(request.spreadDirection) + } catch (e: Exception) { + return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) + } + } else { + existing.spreadDirection } val updated = existing.copy( @@ -138,8 +161,9 @@ class CryptoTailStrategyService( maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice, amountMode = request.amountMode?.uppercase() ?: existing.amountMode, amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue, - minSpreadMode = newMinSpreadMode, - minSpreadValue = newMinSpreadValue, + spreadMode = newSpreadMode, + spreadValue = newSpreadValue, + spreadDirection = newSpreadDirection, enabled = request.enabled ?: existing.enabled, updatedAt = System.currentTimeMillis() ) @@ -263,8 +287,9 @@ class CryptoTailStrategyService( maxPrice = e.maxPrice.toPlainString(), amountMode = e.amountMode, amountValue = e.amountValue.toPlainString(), - minSpreadMode = e.minSpreadMode, - minSpreadValue = e.minSpreadValue?.toPlainString(), + spreadMode = e.spreadMode.name, + spreadValue = e.spreadValue?.toPlainString(), + spreadDirection = e.spreadDirection.name, enabled = e.enabled, lastTriggerAt = lastTriggerAt, totalRealizedPnl = totalPnl?.toPlainString(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt index 7cae48f..107e624 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt @@ -5,6 +5,7 @@ import com.wrbug.polymarketbot.api.EthereumRpcApi import com.wrbug.polymarketbot.api.JsonRpcRequest import com.wrbug.polymarketbot.constants.PolymarketConstants import com.wrbug.polymarketbot.enums.WalletType +import com.wrbug.polymarketbot.util.Eip712Encoder import com.wrbug.polymarketbot.util.EthereumUtils import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.createClient @@ -58,6 +59,10 @@ class RelayClientService( // Builder Relayer API 交易类型常量 private val RELAYER_TYPE_PROXY = "PROXY" private val RELAYER_TYPE_SAFE = "SAFE" + private val RELAYER_TYPE_SAFE_CREATE = "SAFE-CREATE" + + // Safe 代理工厂(用于 SAFE-CREATE 部署) + private val safeProxyFactoryAddress = PolymarketConstants.SAFE_PROXY_FACTORY_ADDRESS private val polygonRpcApi: EthereumRpcApi by lazy { val rpcUrl = rpcNodeService.getHttpUrl() @@ -301,6 +306,23 @@ class RelayClientService( ) } + /** + * 创建 USDC approve 交易(ERC20 approve(spender, amount)) + * 用于 Polymarket 设置步骤3:代币授权 + */ + fun createUsdcApproveTx(spender: String, amount: BigInteger): SafeTransaction { + val functionSelector = EthereumUtils.getFunctionSelector("approve(address,uint256)") + val encodedSpender = EthereumUtils.encodeAddress(spender) + val encodedAmount = EthereumUtils.encodeUint256(amount) + val callData = "0x" + functionSelector.removePrefix("0x") + encodedSpender + encodedAmount + return SafeTransaction( + to = usdcContractAddress, + operation = 0, // CALL + data = callData, + value = "0" + ) + } + /** * 创建 MultiSend 交易(合并多个 SafeTransaction 为一笔交易) * 参考 TypeScript: builder-relayer-client/src/encode/safe.ts createSafeMultisendTransaction @@ -694,6 +716,17 @@ class RelayClientService( } val proxyNonce = BigInteger(nonceResponse.body()!!.nonce) + // 调试 GS026:记录 nonce 与交易参数,便于与 relayer/链上对比 + logger.debug( + "Safe exec 签名参数: nonce={}, to={}, value={}, dataLen={}, operation={}, proxyWallet={}", + proxyNonce, + safeTx.to, + safeTx.value, + redeemCallData.removePrefix("0x").length / 2, + safeTx.operation, + proxyAddress + ) + // 构建 Safe 交易哈希并签名 // 注意:encodeSafeTx 需要 data 带 0x 前缀 val safeTxGas = BigInteger.ZERO @@ -725,6 +758,12 @@ class RelayClientService( messageHash = safeTxHash ) + // 调试 GS026:记录 EIP-712 structHash 与最终签名的 hash(可与 Safe.getTransactionHash 对比) + logger.debug( + "Safe exec 哈希: structHash=0x{}, hashToSign 将基于 prefix+structHash 的 keccak256", + safeTxStructuredHash.joinToString("") { "%02x".format(it) } + ) + // 注意:ethers.js 的 signMessage 会添加 EIP-191 前缀 // 格式:\x19Ethereum Signed Message:\n // 我们需要模拟这个行为以匹配 TypeScript 实现 @@ -739,6 +778,11 @@ class RelayClientService( val hashWithPrefix = ByteArray(keccak256.digestSize) keccak256.doFinal(hashWithPrefix, 0) + logger.debug( + "Safe exec hashToSign=0x{} (personal_sign 后签名的 32 字节)", + hashWithPrefix.joinToString("") { "%02x".format(it) } + ) + val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt) val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false) @@ -788,6 +832,104 @@ class RelayClientService( return Result.success(txHash) } + /** + * 通过 Builder Relayer 部署 Safe 代理(SAFE-CREATE) + * 参考: builder-relayer-client client.ts deploy()、builder/create.ts buildSafeCreateTransactionRequest + * + * @param privateKey EOA 私钥 + * @param proxyAddress 待部署的 Safe 代理地址(与 getProxyAddress 一致) + * @param fromAddress EOA 地址(from) + * @return 交易哈希 + */ + suspend fun deploySafeViaBuilderRelayer( + privateKey: String, + proxyAddress: String, + fromAddress: String + ): Result { + return try { + val builderApiKey = systemConfigService.getBuilderApiKey() + val builderSecret = systemConfigService.getBuilderSecret() + val builderPassphrase = systemConfigService.getBuilderPassphrase() + if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) { + return Result.failure(IllegalStateException("Builder API Key 未配置,无法执行 Safe 部署")) + } + val relayerApi = retrofitFactory.createBuilderRelayerApi( + relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL, + apiKey = builderApiKey!!, + secret = builderSecret!!, + passphrase = builderPassphrase!! + ) + val zeroAddress = "0x0000000000000000000000000000000000000000" + val paymentToken = zeroAddress + val payment = "0" + val paymentReceiver = zeroAddress + val domainSeparator = Eip712Encoder.encodeSafeCreateDomain( + name = PolymarketConstants.SAFE_FACTORY_EIP712_NAME, + chainId = 137L, + verifyingContract = safeProxyFactoryAddress + ) + val createProxyHash = Eip712Encoder.encodeCreateProxyMessage( + paymentToken = paymentToken, + payment = BigInteger.ZERO, + paymentReceiver = paymentReceiver + ) + val digest = Eip712Encoder.hashStructuredData(domainSeparator, createProxyHash) + val cleanPrivateKey = privateKey.removePrefix("0x") + val privateKeyBigInt = BigInteger(cleanPrivateKey, 16) + val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt) + val signature = org.web3j.crypto.Sign.signMessage(digest, ecKeyPair, false) + // SAFE-CREATE 使用标准 EIP-712 签名格式(0x + r + s + v,v 为 27/28),与 signTypedData 一致 + val signatureHex = signatureToStandardHex(signature) + val request = BuilderRelayerApi.TransactionRequest( + type = RELAYER_TYPE_SAFE_CREATE, + from = fromAddress, + to = safeProxyFactoryAddress, + proxyWallet = proxyAddress, + data = "0x", + nonce = null, + signature = signatureHex, + signatureParams = BuilderRelayerApi.SignatureParams( + paymentToken = paymentToken, + payment = payment, + paymentReceiver = paymentReceiver + ), + metadata = null + ) + val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) } + if (!response.isSuccessful || response.body() == null) { + val errorBody = response.errorBody()?.string() ?: "未知错误" + updateQuotaBlockedFromErrorBody(errorBody) + logger.error("Builder Relayer SAFE-CREATE 失败: code=${response.code()}, body=$errorBody") + return Result.failure(Exception("部署 Safe 失败: ${response.code()} - $errorBody")) + } + val relayerResponse = response.body()!! + val txHash = relayerResponse.transactionHash ?: relayerResponse.hash + ?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空")) + logger.info("Safe 部署成功: proxy=$proxyAddress, txHash=$txHash") + Result.success(txHash) + } catch (e: Exception) { + logger.error("部署 Safe 失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 将 SignatureData 转为标准 hex 签名(0x + r(64) + s(64) + v(2),v 为 27/28) + * 用于 SAFE-CREATE,与 viem signTypedData 输出格式一致 + */ + private fun signatureToStandardHex(signature: org.web3j.crypto.Sign.SignatureData): String { + val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') + val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0') + val vBytes = signature.v + val v = if (vBytes != null && vBytes.isNotEmpty()) { + vBytes[0].toInt() and 0xff + } else { + 27 + } + val vHex = String.format("%02x", v) + return "0x$rHex$sHex$vHex" + } + /** * 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig) * 将签名打包成 Gnosis Safe 接受的格式:encodePacked(["uint256", "uint256", "uint8"], [r, s, v]) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt index aaf3646..97c4dd8 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt @@ -377,5 +377,62 @@ object Eip712Encoder { return keccak256(encoded) } + + /** + * SafeCreate 用 EIP712 域(Polymarket Contract Proxy Factory) + * Domain: EIP712Domain(string name, uint256 chainId, address verifyingContract) + * 参考: builder-relayer-client/src/builder/create.ts createSafeCreateSignature + */ + fun encodeSafeCreateDomain( + name: String, + chainId: Long, + verifyingContract: String + ): ByteArray { + val domainTypeHash = encodeType( + "EIP712Domain", + listOf( + "name" to "string", + "chainId" to "uint256", + "verifyingContract" to "address" + ) + ) + val nameHash = encodeString(name) + val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId)) + val contractBytes = encodeAddress(verifyingContract) + val encoded = ByteArray(32 + 32 + 32 + 32) + System.arraycopy(domainTypeHash, 0, encoded, 0, 32) + System.arraycopy(nameHash, 0, encoded, 32, 32) + System.arraycopy(chainIdBytes, 0, encoded, 64, 32) + System.arraycopy(contractBytes, 0, encoded, 96, 32) + return keccak256(encoded) + } + + /** + * CreateProxy 消息哈希(SafeCreate 签名用) + * CreateProxy(address paymentToken, uint256 payment, address paymentReceiver) + */ + fun encodeCreateProxyMessage( + paymentToken: String, + payment: BigInteger, + paymentReceiver: String + ): ByteArray { + val typeHash = encodeType( + "CreateProxy", + listOf( + "paymentToken" to "address", + "payment" to "uint256", + "paymentReceiver" to "address" + ) + ) + val tokenBytes = encodeAddress(paymentToken) + val paymentBytes = encodeUint256(payment) + val receiverBytes = encodeAddress(paymentReceiver) + val encoded = ByteArray(32 + 32 + 32 + 32) + System.arraycopy(typeHash, 0, encoded, 0, 32) + System.arraycopy(tokenBytes, 0, encoded, 32, 32) + System.arraycopy(paymentBytes, 0, encoded, 64, 32) + System.arraycopy(receiverBytes, 0, encoded, 96, 32) + return keccak256(encoded) + } } diff --git a/backend/src/main/resources/db/migration/V38__add_crypto_tail_strategy_reverse_buy.sql b/backend/src/main/resources/db/migration/V38__add_crypto_tail_strategy_reverse_buy.sql new file mode 100644 index 0000000..8e02554 --- /dev/null +++ b/backend/src/main/resources/db/migration/V38__add_crypto_tail_strategy_reverse_buy.sql @@ -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(价差<=配置值触发)'; diff --git a/frontend/src/components/AccountImportForm.tsx b/frontend/src/components/AccountImportForm.tsx index 04fade2..8c7a896 100644 --- a/frontend/src/components/AccountImportForm.tsx +++ b/frontend/src/components/AccountImportForm.tsx @@ -15,6 +15,7 @@ import { import { useMediaQuery } from 'react-responsive' import { apiService } from '../services/api' import type { ProxyOption } from '../types' +import AccountSetupGuideModal from './AccountSetupGuideModal' type ImportType = 'privateKey' | 'mnemonic' @@ -41,6 +42,9 @@ const AccountImportForm: React.FC = ({ const [selectedProxyType, setSelectedProxyType] = useState('') const [loadingProxyOptions, setLoadingProxyOptions] = useState(false) const [step, setStep] = useState<'input' | 'select'>('input') // 步骤:输入 -> 选择代理地址 + const [setupModalVisible, setSetupModalVisible] = useState(false) + const [setupStatus, setSetupStatus] = useState(null) + const [importedAccountId, setImportedAccountId] = useState(undefined) // 当私钥输入时,自动推导地址(不支持换行,自动去除换行符) const handlePrivateKeyChange = (e: React.ChangeEvent) => { @@ -254,11 +258,34 @@ const AccountImportForm: React.FC = ({ // 获取新添加的账户ID(通过API获取,因为store可能还没更新) const accountsResponse = await apiService.accounts.list() + let accountId: number | undefined = undefined if (accountsResponse.data.code === 0 && accountsResponse.data.data) { const newAccounts = accountsResponse.data.data.list || [] const newAccount = newAccounts.find((acc: any) => acc.walletAddress === walletAddress) - if (newAccount && onSuccess) { - onSuccess(newAccount.id) + if (newAccount) { + accountId = newAccount.id + setImportedAccountId(accountId) + + // 检查账户设置状态 + let willShowSetupModal = false + try { + const setupResponse = await apiService.accounts.checkSetupStatus(accountId) + 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(accountId) + } } else if (onSuccess) { onSuccess(0) } @@ -551,6 +578,37 @@ const AccountImportForm: React.FC = ({ + + {/* 账户设置引导弹窗 */} + { + 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) + } + } + }} + /> ) } diff --git a/frontend/src/components/AccountSetupGuideModal.tsx b/frontend/src/components/AccountSetupGuideModal.tsx new file mode 100644 index 0000000..3422adc --- /dev/null +++ b/frontend/src/components/AccountSetupGuideModal.tsx @@ -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 = ({ + 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 ( + + + {t('accountSetup.title')} + + } + open={visible} + onCancel={onClose} + footer={ +
+ {allCompleted ? ( + + ) : ( + + )} +
+ } + width={isMobile ? '95%' : 680} + style={{ top: isMobile ? 20 : 50 }} + destroyOnClose + maskClosable={allCompleted} + closable + > +
+ {allCompleted ? ( + } + showIcon + style={{ marginBottom: 24 }} + /> + ) : ( + } + showIcon + style={{ marginBottom: 24 }} + /> + )} + + {accountId != null && accountId > 0 ? ( + setAllCompleted(true)} + onRefresh={onComplete} + /> + ) : ( + {t('accountSetup.error.description')} + )} + +
+ + {t('accountSetup.help')} + +
+
+
+ ) +} + +export default AccountSetupGuideModal diff --git a/frontend/src/components/AccountSetupStatusBlock.tsx b/frontend/src/components/AccountSetupStatusBlock.tsx new file mode 100644 index 0000000..b24f90f --- /dev/null +++ b/frontend/src/components/AccountSetupStatusBlock.tsx @@ -0,0 +1,307 @@ +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 { useMediaQuery } from 'react-responsive' +import { apiService } from '../services/api' + +const { Paragraph, Text } = Typography + +export interface SetupStatus { + proxyDeployed: boolean + tradingEnabled: boolean + tokensApproved: boolean + approvalDetails?: Record + 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 = ({ + accountId, + onRefresh, + onAllCompleted, + size = 'default', + showApprovalDetails = true, + embedded = false +}) => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [setupStatus, setSetupStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [actionLoading, setActionLoading] = useState(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 = ( +
+ +
+ ) + return embedded ?
{loadingContent}
: ( + {loadingContent} + ) + } + + if (!setupStatus) { + const errorContent = ( + <> + {t('accountSetup.error.description')} +
+ +
+ + ) + return embedded ?
{errorContent}
: ( + {errorContent} + ) + } + + const steps = [ + { + key: 'step1', + title: t('accountSetup.step1.title'), + description: t('accountSetup.step1.description'), + icon: , + completed: setupStatus.proxyDeployed, + actionLabel: t('accountSetup.step1.action') + }, + { + key: 'step2', + title: t('accountSetup.step2.title'), + description: t('accountSetup.step2.description'), + icon: , + completed: setupStatus.tradingEnabled, + actionLabel: t('accountSetup.step2.action') + }, + { + key: 'step3', + title: t('accountSetup.step3.title'), + description: t('accountSetup.step3.description'), + icon: , + completed: setupStatus.tokensApproved, + actionLabel: t('accountSetup.step3.action') + } + ] + + const stepsContent = ( + <> + !s.completed)} + size="small" + style={{ marginBottom: 16 }} + > + {steps.map((step) => ( + + {step.title} + {step.completed ? ( + }> + {t('accountSetup.completed')} + + ) : ( + }> + {t('accountSetup.pending')} + + )} + + } + description={ +
+ + {step.description} + + {!step.completed && ( + + )} +
+ } + icon={step.icon} + status={step.completed ? 'finish' : 'process'} + /> + ))} +
+ + {showApprovalDetails && setupStatus.approvalDetails && Object.keys(setupStatus.approvalDetails).length > 0 && ( +
+ {t('accountSetup.approvalDetails.title')} + + {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 ( +
+ {t(`accountSetup.approvalDetails.${contract}`) || contract} + {displayText} +
+ ) + })} +
+
+ )} + + {setupStatus.error && ( +
+ {setupStatus.error} +
+ )} + + ) + + if (embedded) { + return
{stepsContent}
+ } + + return ( + } + onClick={handleRefresh} + loading={refreshing} + > + {t('accountSetup.refresh')} + + } + > + {stepsContent} + + ) +} + +export default AccountSetupStatusBlock diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 173ec60..d424ede 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -13,6 +13,7 @@ "success": "Success", "failed": "Failed", "confirm": "Confirm", + "later": "Later", "submit": "Submit", "reset": "Reset", "close": "Close", @@ -237,6 +238,51 @@ "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": { "title": "Leader Management", "leaderName": "Leader Name", @@ -1452,13 +1498,17 @@ "update": "Update", "timeWindowStartLEEnd": "Window start must not be greater than end", "timeWindowExceed": "Time window must not exceed period length", - "minSpreadMode": "Min 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.", - "minSpreadModeNone": "None", - "minSpreadModeFixed": "Fixed", - "minSpreadModeAuto": "Auto", - "minSpreadValue": "Min spread value (USDC)", - "minSpreadValuePlaceholder": "e.g. 30" + "spreadMode": "Spread", + "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.", + "spreadModeNone": "None", + "spreadModeFixed": "Fixed", + "spreadModeAuto": "Auto", + "spreadValue": "Spread value (USDC)", + "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": { "title": "Configure Auto Redeem First", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 0b7524b..3b3f561 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -4,6 +4,7 @@ "save": "保存", "cancel": "取消", "confirm": "确定", + "later": "稍后", "delete": "删除", "edit": "编辑", "viewDetail": "查看详情", @@ -236,6 +237,51 @@ "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": { "title": "Leader 管理", "leaderName": "Leader 名称", @@ -1451,13 +1497,17 @@ "update": "更新", "timeWindowStartLEEnd": "时间区间开始不能大于结束", "timeWindowExceed": "时间区间不能超过周期长度", - "minSpreadMode": "最小价差", - "minSpreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 20 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30),仅当价差 ≥ 该值时才下单;无:不校验价差,满足价格区间即下单。", - "minSpreadModeNone": "无", - "minSpreadModeFixed": "固定", - "minSpreadModeAuto": "自动", - "minSpreadValue": "最小价差数值 (USDC)", - "minSpreadValuePlaceholder": "如 30" + "spreadMode": "价差", + "spreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 20 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30);无:不校验价差,满足价格区间即下单。", + "spreadModeNone": "无", + "spreadModeFixed": "固定", + "spreadModeAuto": "自动", + "spreadValue": "价差数值 (USDC)", + "spreadValuePlaceholder": "如 30", + "spreadDirection": "价差方向", + "spreadDirectionTip": "最小价差:价差 ≥ 配置值时触发,买入价固定 0.99;最大价差:价差 ≤ 配置值时触发,买入价 = 触发价 + 0.02(适合低价买入)。", + "spreadDirectionMin": "最小价差", + "spreadDirectionMax": "最大价差" }, "redeemRequiredModal": { "title": "请先配置自动赎回", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 982c39e..ab6a7c5 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -13,6 +13,7 @@ "success": "成功", "failed": "失敗", "confirm": "確認", + "later": "稍後", "submit": "提交", "reset": "重置", "close": "關閉", @@ -237,6 +238,51 @@ "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": { "title": "Leader 管理", "leaderName": "Leader 名稱", @@ -1452,13 +1498,17 @@ "update": "更新", "timeWindowStartLEEnd": "時間區間開始不能大於結束", "timeWindowExceed": "時間區間不能超過週期長度", - "minSpreadMode": "最小價差", - "minSpreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 20 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30),僅當價差 ≥ 該值時才下單;無:不校驗價差,滿足價格區間即下單。", - "minSpreadModeNone": "無", - "minSpreadModeFixed": "固定", - "minSpreadModeAuto": "自動", - "minSpreadValue": "最小價差數值 (USDC)", - "minSpreadValuePlaceholder": "如 30" + "spreadMode": "價差", + "spreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 20 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30);無:不校驗價差,滿足價格區間即下單。", + "spreadModeNone": "無", + "spreadModeFixed": "固定", + "spreadModeAuto": "自動", + "spreadValue": "價差數值 (USDC)", + "spreadValuePlaceholder": "如 30", + "spreadDirection": "價差方向", + "spreadDirectionTip": "最小價差:價差 ≥ 配置值時觸發,買入價固定 0.99;最大價差:價差 ≤ 配置值時觸發,買入價 = 觸發價 + 0.02(適合低價買入)。", + "spreadDirectionMin": "最小價差", + "spreadDirectionMax": "最大價差" }, "redeemRequiredModal": { "title": "請先配置自動贖回", diff --git a/frontend/src/pages/AccountDetail.tsx b/frontend/src/pages/AccountDetail.tsx index 6a6ac91..4baf6af 100644 --- a/frontend/src/pages/AccountDetail.tsx +++ b/frontend/src/pages/AccountDetail.tsx @@ -7,6 +7,7 @@ import { useAccountStore } from '../store/accountStore' import type { Account } from '../types' import { useMediaQuery } from 'react-responsive' import { formatUSDC } from '../utils' +import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock' const { Title } = Typography @@ -150,10 +151,7 @@ const AccountDetail: React.FC = () => { onClick={() => { setEditModalVisible(true) editForm.setFieldsValue({ - accountName: account.accountName || '', - apiKey: '', // 不显示实际值,留空表示不修改 - apiSecret: '', // 不显示实际值,留空表示不修改 - apiPassphrase: '' // 不显示实际值,留空表示不修改 + accountName: account.accountName || '' }) }} size={isMobile ? 'middle' : 'large'} @@ -214,46 +212,23 @@ const AccountDetail: React.FC = () => { - - - - - - {account.apiKeyConfigured ? t('account.configured') : t('account.notConfigured')} - - - - - {account.apiSecretConfigured ? t('account.configured') : t('account.notConfigured')} - - - - - {account.apiPassphraseConfigured ? t('account.configured') : t('account.notConfigured')} - - - - {account.apiKeyConfigured && account.apiSecretConfigured && account.apiPassphraseConfigured ? ( - {t('account.fullConfig')} - ) : ( - {t('account.partialConfig')} - )} - - - - + margin: isMobile ? '0 -8px' : '0' + }}> + { loadAccountDetail(); loadBalance() }} + size={isMobile ? 'small' : 'default'} + showApprovalDetails={true} + /> + + )} + + + {(account.totalOrders !== undefined || account.totalPnl !== undefined || account.activeOrders !== undefined || account.completedOrders !== undefined || account.positionCount !== undefined) ? ( diff --git a/frontend/src/pages/AccountList.tsx b/frontend/src/pages/AccountList.tsx index 8d2e880..0ed3693 100644 --- a/frontend/src/pages/AccountList.tsx +++ b/frontend/src/pages/AccountList.tsx @@ -7,6 +7,7 @@ import type { Account } from '../types' import { useMediaQuery } from 'react-responsive' import { formatUSDC } from '../utils' import AccountImportForm from '../components/AccountImportForm' +import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock' const { Title } = Typography @@ -204,10 +205,7 @@ const AccountList: React.FC = () => { setEditAccount(accountDetail) editForm.setFieldsValue({ - accountName: accountDetail.accountName || '', - apiKey: '', // 不显示实际值,留空表示不修改 - apiSecret: '', // 不显示实际值,留空表示不修改 - apiPassphrase: '' // 不显示实际值,留空表示不修改 + accountName: accountDetail.accountName || '' }) } catch (error: any) { console.error('打开编辑失败:', error) @@ -720,35 +718,14 @@ const AccountList: React.FC = () => { - - - - {detailAccount.apiKeyConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')} - - - - - {detailAccount.apiSecretConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')} - - - - - {detailAccount.apiPassphraseConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')} - - - - {detailAccount.apiKeyConfigured && detailAccount.apiSecretConfigured && detailAccount.apiPassphraseConfigured ? ( - {t('accountList.fullConfig')} - ) : ( - {t('accountList.partialConfig')} - )} - - + + + {(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined || detailAccount.activeOrders !== undefined || diff --git a/frontend/src/pages/CryptoTailStrategyList.tsx b/frontend/src/pages/CryptoTailStrategyList.tsx index f5a923a..3ff8c37 100644 --- a/frontend/src/pages/CryptoTailStrategyList.tsx +++ b/frontend/src/pages/CryptoTailStrategyList.tsx @@ -149,7 +149,8 @@ const CryptoTailStrategyList: React.FC = () => { enabled: true, amountMode: 'RATIO', maxPrice: '1', - minSpreadMode: 'AUTO', + spreadMode: 'AUTO', + spreadDirection: 'MIN', windowStartMinutes: 0, windowStartSeconds: 0 }) @@ -170,8 +171,9 @@ const CryptoTailStrategyList: React.FC = () => { maxPrice: record.maxPrice, amountMode: record.amountMode, amountValue: record.amountValue, - minSpreadMode: record.minSpreadMode ?? 'AUTO', - minSpreadValue: record.minSpreadValue ?? undefined, + spreadMode: record.spreadMode ?? 'AUTO', + spreadValue: record.spreadValue ?? undefined, + spreadDirection: record.spreadDirection ?? 'MIN', enabled: record.enabled }) setFormModalOpen(true) @@ -204,8 +206,9 @@ const CryptoTailStrategyList: React.FC = () => { maxPrice: v.maxPrice != null ? String(v.maxPrice) : undefined, amountMode: v.amountMode as string, amountValue: String(v.amountValue ?? 0), - minSpreadMode: (v.minSpreadMode as string) || 'AUTO', - minSpreadValue: v.minSpreadMode === 'FIXED' && v.minSpreadValue != null ? String(v.minSpreadValue) : (v.minSpreadMode === 'AUTO' && v.minSpreadValue != null ? String(v.minSpreadValue) : undefined), + spreadMode: (v.spreadMode as string) || 'AUTO', + 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 } if (editingId) { @@ -218,8 +221,9 @@ const CryptoTailStrategyList: React.FC = () => { maxPrice: payload.maxPrice, amountMode: payload.amountMode, amountValue: payload.amountValue, - minSpreadMode: payload.minSpreadMode, - minSpreadValue: payload.minSpreadValue, + spreadMode: payload.spreadMode, + spreadValue: payload.spreadValue, + spreadDirection: payload.spreadDirection, enabled: payload.enabled }) if (res.data.code === 0) { @@ -232,7 +236,7 @@ const CryptoTailStrategyList: React.FC = () => { } else { const res = await apiService.cryptoTailStrategy.create({ ...payload, - minSpreadValue: payload.minSpreadMode === 'FIXED' ? payload.minSpreadValue : undefined + spreadValue: payload.spreadMode === 'FIXED' ? payload.spreadValue : undefined }) if (res.data.code === 0) { message.success(t('common.success')) @@ -740,7 +744,7 @@ const CryptoTailStrategyList: React.FC = () => { destroyOnClose > -
+