Compare commits

..

7 Commits

Author SHA1 Message Date
WrBug f1ec0a330b feat(account): 按代理地址去重、默认账户名、前端重复提示与 DB 唯一约束
后端:
- 导入去重改为按 proxy_address(existsByProxyAddress),重复时返回 ACCOUNT_ALREADY_EXISTS(4601)
- 未填账户名时默认生成 SAFE/MAGIC-代理地址后4位(无中括号)
- Controller 识别 ACCOUNT_ALREADY_EXISTS 并返回对应错误码

前端:
- 导入失败时 message.error 提示;code=4601 时使用 accountImport.duplicateAccount 多语言
- store 抛出错误时附带 response.data.code 供表单判断

数据库 V33:
- 移除 wallet_address 唯一约束,新增 proxy_address 唯一约束
- 已存在账户的 wallet_type 统一更新为 safe

实体:
- Account.walletAddress 取消 unique,Account.proxyAddress 设为 unique

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 01:40:25 +08:00
WrBug 72de65d670 feat(frontend): 优化导入账户弹窗与文案
- 导入弹窗:增加步骤条、导入方式改为按钮、代理选项卡片紧凑展示
- 代理选项:标题改为「请选择账户类型」,地址完整显示,有资产标绿,小白说明放入卡片内
- 私钥/助记词:输入框默认两行、禁止换行(换行自动转空格/去除)
- 多语言:新增 proxyAddressHelp,更新 selectProxyOption 文案
- Modal 宽度与内边距微调;PositionList 移除未使用 useRef

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 01:23:29 +08:00
WrBug 3405a1cda3 feat: 钱包类型枚举与前端账号类型展示
后端:
- 新增 WalletType 枚举(MAGIC/SAFE),移除 safe/magic 字符串硬编码
- RelayClientService/BlockchainService/AccountService/OrderSigningService 使用枚举
- Builder Relayer API 类型使用常量 RELAYER_TYPE_PROXY/SAFE

前端:
- 账户列表、详情、导入 Modal 显示账号类型(Magic/Safe Tag)
- 导入账户 Modal 移除安全提示 Alert,移除 showAlert 与相关 i18n
- 移除无用 i18n key:securityTip、securityTipDesc、walletTypeMagic、walletTypeSafe、magicNotSupported
- 钱包类型 Tag 简化为仅显示 Magic 或 Safe

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 01:14:04 +08:00
WrBug fc6fa8b419 fix: 修复 Magic 账户赎回失败问题
主要修复:
1. 修复 PROXY ABI 编码问题:
   - 添加缺失的 tuple offset 字段(32字节)
   - 修正 data offset 从 192 改为 128(符合 ABI 规范)
   - 与 builder-relayer-client 保持一致

2. 添加 gas limit 动态估算:
   - 通过 eth_estimateGas 估算合理的 gas limit
   - 避免硬编码 10M 导致 Relay Hub gasleft() 检查失败
   - 估算失败时回退到默认值

3. 优化 API Key 检查逻辑:
   - 仅在涉及 Magic 账户时提前检查 Builder API Key
   - Safe 账户可在未配置 API Key 时使用手动交易

修复的问题:
- Magic 赎回交易失败:Not enough gasleft()
- PROXY 编码格式与官方不一致

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 00:59:39 +08:00
WrBug cf2c8a611c feat: 赎回批量执行与仓位页体验优化
- 赎回:同一账户多市场合并为一笔交易,减少 Relayer 调用次数
- 仓位页:可赎回统计静默刷新,赎回按钮不再常驻 loading
- 账户导入表单、API、多语言等相关改动

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 00:20:41 +08:00
WrBug bd323fca35 feat: 支持 Magic(邮箱/社交登录)账户导入和赎回
- 前端:启用 Magic 账户导入选项(移除 disabled 和"暂不支持"提示)
- 订单签名:根据钱包类型自动设置 signatureType(Magic=1, Safe=2)
- Builder Relayer API:添加 relay-payload 接口和 PROXY 请求类型支持
- RelayClientService:实现 Magic PROXY 赎回流程(Gasless,需 Builder API Key)
  - 获取 relay payload(relay 地址和 nonce)
  - 编码 ProxyFactory.proxy(calls) 调用数据
  - 创建并签名 PROXY 结构哈希
  - 提交 PROXY 类型交易到 Builder Relayer
- BlockchainService:redeemPositions 支持 walletType 参数
- AccountService:移除 Magic 账户赎回限制,传递 walletType 参数

Magic 账户赎回要求配置 Builder API Key(Gasless),Safe 账户保持原有逻辑不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 22:51:18 +08:00
WrBug a067a20a02 feat: 回测任务支持价格区间过滤
- 后端:
  - BacktestTask 实体类添加 minPrice 和 maxPrice 字段
  - BacktestDto 添加价格区间参数和配置字段
  - BacktestService 支持创建和查询价格区间配置
  - BacktestExecutionService 在 taskToCopyTrading 中设置价格区间
  - 添加数据库迁移脚本 V32__add_backtest_price_range_filter.sql

- 前端:
  - BacktestList 添加价格区间输入表单(最低价、最高价)
  - 详情页面显示价格区间配置
  - 一键创建跟单配置时包含价格区间
  - 添加中英繁三语言翻译
  - 修复价格输入框精度显示问题(0.4 不再显示为 0.40000000)

功能说明:
- 价格区间过滤与跟单配置保持一致,确保回测结果能准确反映实际跟单效果
- 支持只设置最低价、只设置最高价、或同时设置两者
- 留空表示不限制价格

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-11 23:54:49 +08:00
31 changed files with 1276 additions and 186 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ backend/gradle-app.setting
backend/.gradle
# 注意:gradle-wrapper.jar 应该被提交,不要忽略
# backend/gradle/wrapper/gradle-wrapper.jar
polyhub/
# Kotlin
*.kt.bak
*.class
@@ -41,6 +41,17 @@ interface BuilderRelayerApi {
@Query("address") address: String,
@Query("type") type: String
): Response<NoncePayload>
/**
* 获取 Relay PayloadPROXY 类型执行时使用)
* GET /relay-payload?address={address}&type=PROXY
* 参考: builder-relayer-client endpoints GET_RELAY_PAYLOAD
*/
@GET("/relay-payload")
suspend fun getRelayPayload(
@Query("address") address: String,
@Query("type") type: String
): Response<RelayPayload>
/**
* 获取交易状态
@@ -96,6 +107,7 @@ interface BuilderRelayerApi {
/**
* 签名参数
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
* Safe 使用 operation/safeTxnGas/baseGas 等,PROXY 使用 relayHub/relay/relayerFee 等
*/
data class SignatureParams(
@SerializedName("gasPrice")
@@ -114,7 +126,19 @@ interface BuilderRelayerApi {
val gasToken: String? = null,
@SerializedName("refundReceiver")
val refundReceiver: String? = null
val refundReceiver: String? = null,
@SerializedName("relayerFee")
val relayerFee: String? = null,
@SerializedName("gasLimit")
val gasLimit: String? = null,
@SerializedName("relayHub")
val relayHub: String? = null,
@SerializedName("relay")
val relay: String? = null
)
/**
@@ -142,6 +166,17 @@ interface BuilderRelayerApi {
@SerializedName("nonce")
val nonce: String
)
/**
* Relay PayloadPROXY 执行时获取 relay 地址与 nonce
* 参考: builder-relayer-client types RelayPayload
*/
data class RelayPayload(
@SerializedName("address")
val address: String,
@SerializedName("nonce")
val nonce: String
)
/**
* Relayer 交易详情
@@ -23,6 +23,50 @@ class AccountController(
private val logger = LoggerFactory.getLogger(AccountController::class.java)
/**
* 检查代理地址选项(用于导入前选择代理类型)
*/
@PostMapping("/check-proxy-options")
fun checkProxyOptions(@RequestBody request: CheckProxyOptionsRequest): ResponseEntity<ApiResponse<CheckProxyOptionsResponse>> {
return try {
if (request.walletAddress.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_WALLET_ADDRESS_EMPTY, messageSource = messageSource))
}
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "必须提供私钥或助记词", messageSource))
}
val result = runBlocking { accountService.checkProxyOptions(request) }
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))
}
}
/**
* 通过私钥导入账户
*/
@@ -45,14 +89,17 @@ class AccountController(
onFailure = { e ->
logger.error("导入账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
is IllegalArgumentException -> if (e.message == "ACCOUNT_ALREADY_EXISTS") {
ResponseEntity.ok(ApiResponse.error(ErrorCode.ACCOUNT_ALREADY_EXISTS, messageSource = messageSource))
} else {
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
)
)
}
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
}
}
@@ -11,6 +11,37 @@ data class AccountImportRequest(
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
)
/**
* 检查代理地址选项请求
*/
data class CheckProxyOptionsRequest(
val walletAddress: String, // EOA 地址(必需)
val privateKey: String? = null, // 私钥(加密,私钥导入时提供)
val mnemonic: String? = null // 助记词(加密,助记词导入时提供)
)
/**
* 代理地址选项信息
*/
data class ProxyOptionDto(
val walletType: String, // "magic" 或 "safe"
val proxyAddress: String, // 代理地址
val descriptionKey: String, // 说明文案的多语言 key(如 "accountImport.proxyOption.magic.description"
val availableBalance: String, // 可用余额
val positionBalance: String, // 仓位余额
val totalBalance: String, // 总余额
val positionCount: Int, // 持仓数量
val hasAssets: Boolean, // 是否有资产(余额>0 或持仓>0
val error: String? = null // 获取失败时的错误信息(可选)
)
/**
* 检查代理地址选项响应
*/
data class CheckProxyOptionsResponse(
val options: List<ProxyOptionDto> // 代理地址选项列表(私钥导入返回2个,助记词返回1个)
)
/**
* 账户更新请求
*/
@@ -22,6 +22,8 @@ data class BacktestCreateRequest(
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
val pageForResume: Int? = null // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
)
@@ -166,7 +168,9 @@ data class BacktestConfigDto(
val supportSell: Boolean,
val keywordFilterMode: String?,
val keywords: List<String>?,
val maxPositionValue: String?
val maxPositionValue: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? // 最高价格(可选),NULL表示不限制最高价
)
/**
@@ -16,11 +16,11 @@ data class Account(
@Column(name = "private_key", nullable = false, length = 500)
val privateKey: String, // 私钥(AES 加密存储)
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
val walletAddress: String, // 钱包地址(从私钥推导)
@Column(name = "wallet_address", nullable = false, length = 42)
val walletAddress: String, // 钱包地址(从私钥推导),同一 EOA 可有多个账户(不同代理类型)
@Column(name = "proxy_address", nullable = false, length = 42)
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
@Column(name = "proxy_address", unique = true, nullable = false, length = 42)
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须),唯一
@Column(name = "api_key", length = 500)
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
@@ -76,6 +76,12 @@ data class BacktestTask(
@Column(name = "max_position_value", precision = 20, scale = 8)
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
@Column(name = "min_price", precision = 20, scale = 8)
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
@Column(name = "max_price", precision = 20, scale = 8)
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
// 统计字段
@Column(name = "avg_holding_time")
var avgHoldingTime: Long? = null, // 平均持仓时间(毫秒)
@@ -0,0 +1,51 @@
package com.wrbug.polymarketbot.enums
/**
* 钱包类型枚举
*/
enum class WalletType(val value: String, val description: String) {
/**
* Magic 钱包(邮箱/OAuth 登录)
* 使用 PROXY 代理合约,通过 Builder Relayer 执行 Gasless 交易
*/
MAGIC("magic", "Magic(邮箱/OAuth登录)"),
/**
* Safe 钱包(MetaMask 等 Web3 钱包)
* 使用 Gnosis Safe 代理合约,支持 Builder Relayer Gasless 或手动交易
*/
SAFE("safe", "SafeWeb3钱包)");
companion object {
/**
* 从字符串值解析钱包类型(不区分大小写)
*/
fun fromString(value: String?): WalletType {
if (value.isNullOrBlank()) {
return SAFE // 默认返回 SAFE
}
return values().find { it.value.equals(value, ignoreCase = true) }
?: throw IllegalArgumentException("未知的钱包类型: $value")
}
/**
* 安全地从字符串值解析钱包类型(不区分大小写),解析失败返回默认值
*/
fun fromStringOrDefault(value: String?, default: WalletType = SAFE): WalletType {
if (value.isNullOrBlank()) {
return default
}
return values().find { it.value.equals(value, ignoreCase = true) } ?: default
}
/**
* 检查字符串是否为有效的钱包类型
*/
fun isValid(value: String?): Boolean {
if (value.isNullOrBlank()) {
return false
}
return values().any { it.value.equals(value, ignoreCase = true) }
}
}
}
@@ -29,5 +29,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
* 检查钱包地址是否存在
*/
fun existsByWalletAddress(walletAddress: String): Boolean
/**
* 检查代理地址是否存在
*/
fun existsByProxyAddress(proxyAddress: String): Boolean
}
@@ -3,10 +3,12 @@ package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.getEventSlug
import com.wrbug.polymarketbot.service.common.PolymarketClobService
@@ -66,11 +68,6 @@ class AccountService(
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 检查地址是否已存在
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
}
// 3. 验证私钥和地址的对应关系
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
@@ -104,7 +101,8 @@ class AccountService(
// 5. 获取代理地址(必须成功,否则导入失败)
// 根据用户选择的钱包类型计算代理地址
val proxyAddress = runBlocking {
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType)
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, walletTypeEnum)
if (proxyResult.isSuccess) {
val address = proxyResult.getOrNull()
if (address != null) {
@@ -120,25 +118,31 @@ class AccountService(
}
}
// 6. 按代理地址去重:该代理地址已存在则不允许重复导入
if (accountRepository.existsByProxyAddress(proxyAddress)) {
return Result.failure(IllegalArgumentException("ACCOUNT_ALREADY_EXISTS"))
}
// 7. 加密敏感信息
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
// 8. 生成账户名称(如果未提供,使用钱包地址后位)
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
val accountName = if (request.accountName.isNullOrBlank()) {
val walletAddress = request.walletAddress.trim()
// 取地址后四位(去掉 0x 前缀后取后四位)
val addressWithoutPrefix = if (walletAddress.startsWith("0x") || walletAddress.startsWith("0X")) {
walletAddress.substring(2)
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
val typeLabel = walletTypeEnum.name.uppercase()
val proxyWithoutPrefix = if (proxyAddress.startsWith("0x") || proxyAddress.startsWith("0X")) {
proxyAddress.substring(2)
} else {
walletAddress
proxyAddress
}
if (addressWithoutPrefix.length >= 4) {
addressWithoutPrefix.substring(addressWithoutPrefix.length - 4).uppercase()
val suffix = if (proxyWithoutPrefix.length >= 4) {
proxyWithoutPrefix.substring(proxyWithoutPrefix.length - 4).uppercase()
} else {
addressWithoutPrefix.uppercase()
proxyWithoutPrefix.uppercase()
}
"$typeLabel-$suffix"
} else {
request.accountName.trim()
}
@@ -171,6 +175,192 @@ class AccountService(
}
}
/**
* 检查代理地址选项(用于账户导入前选择代理类型)
* 私钥导入:返回 Magic 和 Safe 两个选项
* 助记词导入:仅返回 Safe 选项
*/
suspend fun checkProxyOptions(request: CheckProxyOptionsRequest): Result<CheckProxyOptionsResponse> {
return try {
// 1. 验证钱包地址格式
if (!isValidWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 验证至少提供了私钥或助记词之一
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("必须提供私钥或助记词"))
}
val options = mutableListOf<ProxyOptionDto>()
// 3. 判断导入类型
val isPrivateKeyImport = !request.privateKey.isNullOrBlank()
if (isPrivateKeyImport) {
// 私钥导入:并行获取 Magic 和 Safe 代理地址及资产
coroutineScope {
val magicDeferred = async {
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.MAGIC).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
ProxyOptionDto(
walletType = WalletType.MAGIC.value,
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
} else {
ProxyOptionDto(
walletType = "magic",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Magic 代理地址失败"
)
}
} catch (e: Exception) {
logger.warn("获取 Magic 代理地址或资产失败: ${e.message}", e)
ProxyOptionDto(
walletType = "magic",
proxyAddress = blockchainService.calculateMagicProxyAddress(request.walletAddress),
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
}
}
val safeDeferred = async {
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
ProxyOptionDto(
walletType = WalletType.SAFE.value,
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
} else {
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Safe 代理地址失败"
)
}
} catch (e: Exception) {
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
}
}
val magicOption = magicDeferred.await()
val safeOption = safeDeferred.await()
// Safe 在前,Magic 在后
options.add(safeOption)
options.add(magicOption)
}
} else {
// 助记词导入:仅获取 Safe 代理地址及资产
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
)
} else {
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Safe 代理地址失败"
)
)
}
} catch (e: Exception) {
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
)
}
}
Result.success(CheckProxyOptionsResponse(options = options))
} catch (e: Exception) {
logger.error("检查代理地址选项失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 更新账户信息
*/
@@ -827,7 +1017,7 @@ class AccountService(
"0"
}
// 11. 创建并签名订单(使用计算后的卖出数量)
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType
val signedOrder = try {
orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
@@ -836,7 +1026,7 @@ class AccountService(
side = "SELL",
price = sellPrice,
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
signatureType = 2, // Browser Wallet(与正确订单数据一致)
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = expiration
@@ -1189,13 +1379,6 @@ class AccountService(
*/
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
return try {
// 检查 Builder API Key 是否已配置
if (!relayClientService.isBuilderApiKeyConfigured()) {
return Result.failure(
IllegalStateException("Builder API Key 未配置,无法执行 Gasless 交易。请前往系统设置页面配置 Builder API Key。")
)
}
if (request.positions.isEmpty()) {
return Result.failure(IllegalArgumentException("赎回仓位列表不能为空"))
}
@@ -1217,7 +1400,17 @@ class AccountService(
accounts[accountId] = account
}
// 4. 验证并收集要赎回的仓位信息(按账户分组
// 4. 若涉及 Magic 账户,必须已配置 Builder API Key(提前判断,避免执行到深层再报错
val hasMagicAccount = accounts.values.any {
WalletType.fromStringOrDefault(it.walletType, WalletType.SAFE) == WalletType.MAGIC
}
if (hasMagicAccount && !relayClientService.isBuilderApiKeyConfigured()) {
return Result.failure(
IllegalStateException("Builder API Key 未配置,无法执行 Magic 账户赎回(Gasless)。请前往系统设置页面配置 Builder API Key。")
)
}
// 5. 验证并收集要赎回的仓位信息(按账户分组)
val accountRedeemData = mutableMapOf<Long, MutableList<Pair<AccountPositionDto, BigInteger>>>()
val accountRedeemedInfo =
mutableMapOf<Long, MutableList<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>>()
@@ -1260,7 +1453,7 @@ class AccountService(
accountRedeemedInfo[accountId] = accountInfo
}
// 5. 对每个账户执行赎回
// 6. 对每个账户执行赎回Safe 与 Magic 均支持,Magic 通过 Builder Relayer PROXY Gasless 执行)
val accountTransactions = mutableListOf<com.wrbug.polymarketbot.dto.AccountRedeemTransaction>()
var totalRedeemedValue = BigDecimal.ZERO
@@ -1280,11 +1473,13 @@ class AccountService(
val decryptedPrivateKey = decryptPrivateKey(account)
// 调用区块链服务赎回仓位
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
val redeemResult = blockchainService.redeemPositions(
privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress,
conditionId = marketId,
indexSets = indexSets
indexSets = indexSets,
walletType = walletTypeEnum
)
redeemResult.fold(
@@ -1315,7 +1510,7 @@ class AccountService(
)
}
// 6. 发送赎回推送通知(异步,不阻塞)
// 7. 发送赎回推送通知(异步,不阻塞)
notificationScope.launch {
try {
// 获取当前语言设置
@@ -73,6 +73,8 @@ class BacktestExecutionService(
minOrderDepth = null, // 回测无实时订单簿数据
maxSpread = null, // 回测无实时价差数据
maxPositionValue = task.maxPositionValue,
minPrice = task.minPrice, // 最低价格
maxPrice = task.maxPrice, // 最高价格
keywordFilterMode = task.keywordFilterMode,
keywords = task.keywords,
configName = null,
@@ -82,7 +82,9 @@ class BacktestService(
} else {
null
},
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal()
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal()
)
backtestTaskRepository.save(task)
@@ -192,7 +194,9 @@ class BacktestService(
} else {
emptyList()
},
maxPositionValue = task.maxPositionValue?.toPlainString()
maxPositionValue = task.maxPositionValue?.toPlainString(),
minPrice = task.minPrice?.toPlainString(),
maxPrice = task.maxPrice?.toPlainString()
)
val statistics = BacktestStatisticsDto(
@@ -379,7 +383,9 @@ class BacktestService(
supportSell = source.supportSell,
keywordFilterMode = source.keywordFilterMode,
keywords = source.keywords,
maxPositionValue = source.maxPositionValue
maxPositionValue = source.maxPositionValue,
minPrice = source.minPrice,
maxPrice = source.maxPrice
)
backtestTaskRepository.save(newTask)
@@ -10,6 +10,7 @@ import com.wrbug.polymarketbot.api.ValueResponse
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.dto.PositionDto
import com.wrbug.polymarketbot.dto.WalletBalanceResponse
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
@@ -93,13 +94,13 @@ class BlockchainService(
* 2. Safe ProxyMetaMask 钱包用户- 通过合约调用获取地址
*
* @param walletAddress 用户的钱包地址EOA
* @param walletType 钱包类型"magic"默认 "safe"
* @param walletType 钱包类型MAGIC默认 SAFE
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
suspend fun getProxyAddress(walletAddress: String, walletType: WalletType = WalletType.MAGIC): Result<String> {
return try {
when (walletType.lowercase()) {
"safe" -> {
when (walletType) {
WalletType.SAFE -> {
// Safe ProxyMetaMask 用户)
val safeProxyResult = getSafeProxyAddress(walletAddress)
if (safeProxyResult.isSuccess) {
@@ -110,7 +111,7 @@ class BlockchainService(
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
}
}
else -> {
WalletType.MAGIC -> {
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
@@ -580,39 +581,35 @@ class BlockchainService(
/**
* 赎回仓位
* 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数
*
* 使用 RelayClientService 实现完全参考 TypeScript 项目的实现方式
*
* Safe 账户通过代理 execTransaction 调用Magic 账户通过 Builder Relayer PROXYGasless执行
*
* @param privateKey 私钥原始钱包的私钥用于签名交易
* @param proxyAddress 代理地址Gnosis Safe 代理钱包地址
* @param proxyAddress 代理地址Safe Magic 代理钱包地址
* @param conditionId 市场条件IDbytes32必须是 0x 开头的 66 位十六进制字符串
* @param indexSets 要赎回的索引集合列表每个元素是 2^outcomeIndex例如 [1] 表示 outcome 0[2] 表示 outcome 1
* @param indexSets 要赎回的索引集合列表每个元素是 2^outcomeIndex
* @param walletType 钱包类型MAGIC SAFE用于选择执行路径
* @return 交易哈希
*/
suspend fun redeemPositions(
privateKey: String,
proxyAddress: String,
conditionId: String,
indexSets: List<BigInteger>
indexSets: List<BigInteger>,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
// 验证参数
if (indexSets.isEmpty()) {
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
}
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
}
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 使用 RelayClientService 创建赎回交易并执行
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
relayClientService.execute(privateKey, proxyAddress, redeemTx)
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
} catch (e: Exception) {
logger.error("赎回仓位失败: ${e.message}", e)
Result.failure(e)
@@ -19,9 +19,19 @@ import java.math.RoundingMode
*/
@Service
class OrderSigningService {
private val logger = LoggerFactory.getLogger(OrderSigningService::class.java)
/**
* 根据钱包类型返回 CLOB 订单签名类型
* @param walletType Magic=邮箱/社交登录, Safe=Web3 钱包
* @return 1=POLY_PROXY(Magic), 2=POLY_GNOSIS_SAFE(Safe), 默认 2
*/
fun getSignatureTypeForWalletType(walletType: String?): Int {
val walletTypeEnum = com.wrbug.polymarketbot.enums.WalletType.fromStringOrDefault(walletType, com.wrbug.polymarketbot.enums.WalletType.SAFE)
return if (walletTypeEnum == com.wrbug.polymarketbot.enums.WalletType.MAGIC) 1 else 2
}
// Polygon 主网合约地址
private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
private val CHAIN_ID = 137L
@@ -570,7 +570,8 @@ open class CopyOrderTrackingService(
owner = account.apiKey,
copyTradingId = copyTrading.id!!,
tradeId = trade.id,
feeRateBps = feeRateBps
feeRateBps = feeRateBps,
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
// 处理订单创建失败
@@ -994,7 +995,7 @@ open class CopyOrderTrackingService(
"0"
}
// 9. 创建并签名卖出订单
// 9. 创建并签名卖出订单(按账户钱包类型使用对应 signatureType
val signedOrder = try {
orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
@@ -1003,7 +1004,7 @@ open class CopyOrderTrackingService(
side = "SELL",
price = sellPrice.toString(),
size = totalMatched.toString(),
signatureType = 2, // Browser Wallet
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0"
@@ -1044,7 +1045,8 @@ open class CopyOrderTrackingService(
owner = account.apiKey,
copyTradingId = copyTrading.id,
tradeId = leaderSellTrade.id,
feeRateBps = feeRateBps
feeRateBps = feeRateBps,
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
if (createOrderResult.isFailure) {
@@ -1137,6 +1139,7 @@ open class CopyOrderTrackingService(
* @param copyTradingId 跟单配置ID用于日志
* @param tradeId Leader 交易ID用于日志
* @param feeRateBps 费率基点从API动态获取
* @param signatureType 签名类型1=Magic, 2=Safe
* @return 成功返回订单ID失败返回异常
*/
private suspend fun createOrderWithRetry(
@@ -1150,7 +1153,8 @@ open class CopyOrderTrackingService(
owner: String,
copyTradingId: Long,
tradeId: String,
feeRateBps: String
feeRateBps: String,
signatureType: Int
): Result<String> {
var lastError: Exception? = null
@@ -1165,7 +1169,7 @@ open class CopyOrderTrackingService(
side = side,
price = price,
size = size,
signatureType = 2, // Browser Wallet
signatureType = signatureType,
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0"
@@ -4,6 +4,7 @@ import com.wrbug.polymarketbot.api.BuilderRelayerApi
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.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
@@ -40,6 +41,15 @@ class RelayClientService(
// 空集合ID
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
// Polygon PROXYMagic)合约地址,参考 builder-relayer-client config
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
private val defaultProxyGasLimit = "10000000"
// Builder Relayer API 交易类型常量
private val RELAYER_TYPE_PROXY = "PROXY"
private val RELAYER_TYPE_SAFE = "SAFE"
private val polygonRpcApi: EthereumRpcApi by lazy {
val rpcUrl = rpcNodeService.getHttpUrl()
retrofitFactory.createEthereumRpcApi(rpcUrl)
@@ -201,33 +211,45 @@ class RelayClientService(
}
/**
* 执行 Safe 交易通过 Proxy.execTransaction
* 执行代理交易Safe Magic PROXY
* 参考 TypeScript: RelayClient.execute()
*
* 优先使用 Builder RelayerGasless如果未配置则回退到手动发送交易
*
* @param privateKey 私钥
* @param proxyAddress 代理钱包地址
* @param safeTx Safe 交易对象
* @param safeTx 交易对象to/data/value
* @param walletType 钱包类型MAGIC 使用 PROXY GaslessSAFE 使用 Safe 流程
* @return 交易哈希
*/
suspend fun execute(
privateKey: String,
proxyAddress: String,
safeTx: SafeTransaction
safeTx: SafeTransaction,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
// 验证参数
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 检查 Builder API Key 是否已配置
val builderApiKey = systemConfigService.getBuilderApiKey()
val builderSecret = systemConfigService.getBuilderSecret()
val builderPassphrase = systemConfigService.getBuilderPassphrase()
// 优先使用 Builder RelayerGasless
if (walletType == WalletType.MAGIC) {
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
return Result.failure(IllegalStateException("Magic 账户赎回必须配置 Builder API KeyGasless"))
}
logger.info("使用 Builder Relayer PROXY 执行 Magic 赎回")
return executeViaBuilderRelayerProxy(
privateKey,
proxyAddress,
safeTx,
builderApiKey!!,
builderSecret!!,
builderPassphrase!!
)
}
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
logger.info("使用 Builder Relayer 执行 Gasless 交易")
return executeViaBuilderRelayer(
@@ -240,15 +262,240 @@ class RelayClientService(
)
}
// 回退到手动发送交易(需要用户支付 gas)
logger.info("Builder Relayer 未配置,使用手动发送交易(需要用户支付 gas)")
return executeManually(privateKey, proxyAddress, safeTx)
} catch (e: Exception) {
logger.error("执行 Safe 交易失败: ${e.message}", e)
logger.error("执行交易失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 通过 Builder Relayer 执行 PROXYMagic交易Gasless
* 参考: builder-relayer-client client.ts executeProxyTransactions, builder/proxy.ts
*/
private suspend fun executeViaBuilderRelayerProxy(
privateKey: String,
proxyAddress: String,
safeTx: SafeTransaction,
builderApiKey: String,
builderSecret: String,
builderPassphrase: String
): Result<String> {
val relayerApi = retrofitFactory.createBuilderRelayerApi(
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
apiKey = builderApiKey,
secret = builderSecret,
passphrase = builderPassphrase
)
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
val fromAddress = credentials.address
val relayPayloadResponse = relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY)
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody")
return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody"))
}
val relayPayload = relayPayloadResponse.body()!!
val relayAddress = relayPayload.address
val nonce = relayPayload.nonce
val proxyCallData = encodeProxyTransactionData(safeTx)
// 估算 gas limit(参考 builder-relayer-client builder/proxy.ts getGasLimit
val gasLimit = try {
estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData)
} catch (e: Exception) {
logger.warn("估算 PROXY gas limit 失败,使用默认值: ${e.message}", e)
defaultProxyGasLimit
}
val structHash = createProxyStructHash(
from = fromAddress,
to = proxyFactoryAddress,
data = proxyCallData,
txFee = "0",
gasPrice = "0",
gasLimit = gasLimit,
nonce = nonce,
relayHubAddress = relayHubAddress,
relayAddress = relayAddress
)
val prefix = "\u0019Ethereum Signed Message:\n32".toByteArray(Charsets.UTF_8)
val messageWithPrefix = ByteArray(prefix.size + structHash.size)
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
System.arraycopy(structHash, 0, messageWithPrefix, prefix.size, structHash.size)
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
val hashWithPrefix = ByteArray(keccak256.digestSize)
keccak256.doFinal(hashWithPrefix, 0)
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val signature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
val sigHex = "0x" + org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') +
org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0') +
String.format("%02x", (signature.v as ByteArray).getOrElse(0) { 0 }.toInt() and 0xff)
val request = BuilderRelayerApi.TransactionRequest(
type = RELAYER_TYPE_PROXY,
from = fromAddress,
to = proxyFactoryAddress,
proxyWallet = proxyAddress,
data = proxyCallData,
nonce = nonce,
signature = sigHex,
signatureParams = BuilderRelayerApi.SignatureParams(
gasPrice = "0",
gasLimit = gasLimit,
relayerFee = "0",
relayHub = relayHubAddress,
relay = relayAddress
),
metadata = "Redeem positions via Builder Relayer PROXY"
)
val response = relayerApi.submitTransaction(request)
if (!response.isSuccessful || response.body() == null) {
val errorBody = response.errorBody()?.string() ?: "未知错误"
logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody")
return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody"))
}
val relayerResponse = response.body()!!
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
logger.info("Builder Relayer PROXY 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
return Result.success(txHash)
}
/**
* 编码 ProxyFactory.proxy(calls) 调用数据
* 参考: builder-relayer-client encode/proxy.ts, abis proxyFactory proxy((uint8,address,uint256,bytes)[])
*
* ABI 编码规则 tuple 数组中的 tuple 包含动态类型bytes需要先存储 tuple offset
* 结构
* - selector (4 bytes)
* - array offset (32 bytes) = 32
* - array length (32 bytes) = 1
* - tuple[0] offset (32 bytes) = 32 (指向 tuple 数据开始 array length 之后计算)
* - tuple[0] 数据
* - typeCode (32 bytes) = 1
* - to (32 bytes)
* - value (32 bytes) = 0
* - data offset (32 bytes) = 128 ( tuple 数据开始计算)
* - data length (32 bytes)
* - data (padded to 32-byte boundary)
*/
private fun encodeProxyTransactionData(safeTx: SafeTransaction): String {
val selector = EthereumUtils.getFunctionSelector("proxy((uint8,address,uint256,bytes)[])")
val callData = safeTx.data.removePrefix("0x")
val dataLen = callData.length / 2
val dataLenPadded = (dataLen + 31) / 32 * 32 * 2
val dataPadded = callData.padEnd(dataLenPadded, '0')
// ABI 编码:tuple 数组,tuple 包含动态类型 bytes
// 1. array offset: 32 (指向 array length)
val arrayOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
// 2. array length: 1
val arrayLength = EthereumUtils.encodeUint256(BigInteger.ONE)
// 3. tuple[0] offset: 32 (指向 tuple 数据开始,从 array length 之后计算)
val tupleOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
// 4. tuple[0] 数据:
// - typeCode: 1
val typeCode = EthereumUtils.encodeUint256(BigInteger.ONE)
// - to: address
val toEncoded = EthereumUtils.encodeAddress(safeTx.to)
// - value: 0
val valueEncoded = EthereumUtils.encodeUint256(BigInteger.ZERO)
// - data offset: 128 (从 tuple 数据开始计算,typeCode+to+value = 3*32 = 96,加上 offset 字段 = 128)
val dataOffsetInTuple = BigInteger.valueOf(128)
val dataOffsetEncoded = EthereumUtils.encodeUint256(dataOffsetInTuple)
// - data length
val dataLengthEncoded = EthereumUtils.encodeUint256(BigInteger.valueOf(dataLen.toLong()))
// - data (padded)
return "0x" + selector.removePrefix("0x") + arrayOffset + arrayLength +
tupleOffset + typeCode + toEncoded + valueEncoded + dataOffsetEncoded +
dataLengthEncoded + dataPadded
}
/**
* 估算 PROXY 交易的 gas limit
* 参考: builder-relayer-client builder/proxy.ts getGasLimit
*/
private suspend fun estimateProxyGasLimit(
from: String,
to: String,
data: String
): String {
val rpcApi = polygonRpcApi
val rpcRequest = JsonRpcRequest(
method = "eth_estimateGas",
params = listOf(
mapOf(
"from" to from,
"to" to to,
"data" to data
)
)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
throw Exception("eth_estimateGas 调用失败: ${response.code()} ${response.message()}")
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
throw Exception("eth_estimateGas 返回错误: ${rpcResponse.error.message}")
}
val hexGasLimit = rpcResponse.result?.asString
?: throw Exception("eth_estimateGas 结果为空")
// 将十六进制转换为十进制字符串
val gasLimitBigInt = BigInteger(hexGasLimit.removePrefix("0x"), 16)
return gasLimitBigInt.toString()
}
/**
* 创建 PROXY 结构哈希参考 builder-relayer-client builder/proxy.ts createStructHash
* concat: "rlx:" + from + to + data + txFee + gasPrice + gasLimit + nonce + relayHub + relay, then keccak256
*/
private fun createProxyStructHash(
from: String,
to: String,
data: String,
txFee: String,
gasPrice: String,
gasLimit: String,
nonce: String,
relayHubAddress: String,
relayAddress: String
): ByteArray {
val rlxPrefix = "rlx:".toByteArray(Charsets.UTF_8)
val fromBytes = EthereumUtils.hexToBytes(from.lowercase().removePrefix("0x").padStart(40, '0'))
val toBytes = EthereumUtils.hexToBytes(to.lowercase().removePrefix("0x").padStart(40, '0'))
val dataBytes = EthereumUtils.hexToBytes(data.removePrefix("0x"))
val txFeeBytes = EthereumUtils.encodeUint256(BigInteger(txFee)).let { EthereumUtils.hexToBytes(it) }
val gasPriceBytes = EthereumUtils.encodeUint256(BigInteger(gasPrice)).let { EthereumUtils.hexToBytes(it) }
val gasLimitBytes = EthereumUtils.encodeUint256(BigInteger(gasLimit)).let { EthereumUtils.hexToBytes(it) }
val nonceBytes = EthereumUtils.encodeUint256(BigInteger(nonce)).let { EthereumUtils.hexToBytes(it) }
val relayHubBytes = EthereumUtils.hexToBytes(relayHubAddress.lowercase().removePrefix("0x").padStart(40, '0'))
val relayBytes = EthereumUtils.hexToBytes(relayAddress.lowercase().removePrefix("0x").padStart(40, '0'))
val concat = rlxPrefix + fromBytes + toBytes + dataBytes + txFeeBytes + gasPriceBytes +
gasLimitBytes + nonceBytes + relayHubBytes + relayBytes
return EthereumUtils.keccak256(concat)
}
/**
* 通过 Builder Relayer 执行交易Gasless
* 参考: builder-relayer-client/src/client.ts execute 方法
@@ -278,7 +525,7 @@ class RelayClientService(
val redeemCallData = safeTx.data
// 获取 Proxy 的 nonce(通过 Builder Relayer API
val nonceResponse = relayerApi.getNonce(fromAddress, "SAFE")
val nonceResponse = relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE)
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
@@ -345,7 +592,7 @@ class RelayClientService(
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
val request = BuilderRelayerApi.TransactionRequest(
type = "SAFE",
type = RELAYER_TYPE_SAFE,
from = fromAddress,
to = safeTx.to,
proxyWallet = proxyAddress,
@@ -0,0 +1,9 @@
-- ============================================
-- V32: 添加回测价格区间过滤字段
-- 用于配置价格区间,仅在指定价格区间内的订单才会跟单
-- ============================================
-- 添加价格区间字段到回测任务表
ALTER TABLE backtest_task
ADD COLUMN min_price DECIMAL(20, 8) NULL COMMENT '最低价格(可选),NULL表示不限制最低价',
ADD COLUMN max_price DECIMAL(20, 8) NULL COMMENT '最高价格(可选),NULL表示不限制最高价';
@@ -0,0 +1,38 @@
-- ============================================
-- V33: 唯一约束从 wallet_address 改为 proxy_address
-- 允许同一 EOA 以不同代理类型(Magic/Safe)各导入一个账户,按代理地址去重
-- ============================================
-- 将已存在账户的 wallet_type 统一为 safe(历史数据兼容)
UPDATE wallet_accounts SET wallet_type = 'safe';
-- 删除 wallet_address 上的唯一约束(通过 KEY_COLUMN_USAGE 定位到该列的约束名)
SET @uk_name = (SELECT kcu.CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
ON kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA AND kcu.TABLE_NAME = tc.TABLE_NAME AND kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
WHERE kcu.TABLE_SCHEMA = DATABASE()
AND kcu.TABLE_NAME = 'wallet_accounts'
AND tc.CONSTRAINT_TYPE = 'UNIQUE'
AND kcu.COLUMN_NAME = 'wallet_address'
LIMIT 1);
SET @sql = IF(@uk_name IS NOT NULL,
CONCAT('ALTER TABLE wallet_accounts DROP INDEX ', @uk_name),
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 为 proxy_address 添加唯一约束(若已存在则跳过)
SET @uk_exists = (SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'wallet_accounts'
AND CONSTRAINT_TYPE = 'UNIQUE'
AND CONSTRAINT_NAME = 'uk_wallet_accounts_proxy_address'
LIMIT 1);
SET @sql2 = IF(@uk_exists IS NULL,
'ALTER TABLE wallet_accounts ADD UNIQUE KEY uk_wallet_accounts_proxy_address (proxy_address)',
'SELECT 1');
PREPARE stmt2 FROM @sql2;
EXECUTE stmt2;
DEALLOCATE PREPARE stmt2;
+258 -72
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Form, Input, Button, Radio, Space, Alert, Tooltip } from 'antd'
import { QuestionCircleOutlined } from '@ant-design/icons'
import { useState, useEffect } from 'react'
import { Form, Input, Button, Radio, Space, Card, Spin, message, Alert, Steps, Tag } from 'antd'
import { KeyOutlined, WalletOutlined, UserOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useAccountStore } from '../store/accountStore'
import {
@@ -9,18 +9,19 @@ import {
getPrivateKeyFromMnemonic,
isValidWalletAddress,
isValidPrivateKey,
isValidMnemonic
isValidMnemonic,
formatUSDC
} from '../utils'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import type { ProxyOption } from '../types'
type ImportType = 'privateKey' | 'mnemonic'
type WalletType = 'magic' | 'safe'
interface AccountImportFormProps {
form: any
onSuccess?: (accountId: number) => void
onCancel?: () => void
showAlert?: boolean
showCancelButton?: boolean
}
@@ -28,23 +29,33 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
form,
onSuccess,
onCancel,
showAlert = true,
showCancelButton = true
}) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { importAccount, loading } = useAccountStore()
const [importType, setImportType] = useState<ImportType>('privateKey')
const [walletType, setWalletType] = useState<WalletType>('safe')
const [derivedAddress, setDerivedAddress] = useState<string>('')
const [addressError, setAddressError] = useState<string>('')
const [proxyOptions, setProxyOptions] = useState<ProxyOption[]>([])
const [selectedProxyType, setSelectedProxyType] = useState<string>('')
const [loadingProxyOptions, setLoadingProxyOptions] = useState<boolean>(false)
const [step, setStep] = useState<'input' | 'select'>('input') // 步骤:输入 -> 选择代理地址
// 当私钥输入时,自动推导地址
// 当私钥输入时,自动推导地址(不支持换行,自动去除换行符)
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const privateKey = e.target.value.trim()
const raw = e.target.value
const normalized = raw.replace(/\r?\n/g, '')
if (normalized !== raw) {
form.setFieldsValue({ privateKey: normalized })
}
const privateKey = normalized.trim()
if (!privateKey) {
setDerivedAddress('')
setAddressError('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -52,6 +63,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (!isValidPrivateKey(privateKey)) {
setAddressError(t('accountImport.privateKeyInvalid'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -62,18 +76,34 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
// 自动填充钱包地址字段
form.setFieldsValue({ walletAddress: address })
// 延迟获取代理选项(避免频繁请求)
setTimeout(() => {
fetchProxyOptions(address, privateKey, null)
}, 500)
} catch (error: any) {
setAddressError(error.message || t('accountImport.addressError'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
}
}
// 当助记词输入时,自动推导地址
// 当助记词输入时,自动推导地址(不支持换行,换行符转为空格)
const handleMnemonicChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const mnemonic = e.target.value.trim()
const raw = e.target.value
const normalized = raw.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trimStart()
if (/\r?\n/.test(raw)) {
form.setFieldsValue({ mnemonic: normalized })
}
const mnemonic = normalized.trim()
if (!mnemonic) {
setDerivedAddress('')
setAddressError('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -81,6 +111,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (!isValidMnemonic(mnemonic)) {
setAddressError(t('accountImport.mnemonicInvalid'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -91,14 +124,85 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
// 自动填充钱包地址字段
form.setFieldsValue({ walletAddress: address })
// 延迟获取代理选项(避免频繁请求)
setTimeout(() => {
fetchProxyOptions(address, null, mnemonic)
}, 500)
} catch (error: any) {
setAddressError(error.message || t('accountImport.addressErrorMnemonic'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
}
}
// 获取代理地址选项
const fetchProxyOptions = async (walletAddress: string, privateKey: string | null, mnemonic: string | null) => {
if (!walletAddress || (!privateKey && !mnemonic)) {
return
}
setLoadingProxyOptions(true)
try {
const response = await apiService.accounts.checkProxyOptions({
walletAddress,
privateKey: privateKey || undefined,
mnemonic: mnemonic || undefined
})
if (response.data.code === 0 && response.data.data) {
const options = response.data.data.options || []
setProxyOptions(options)
// 如果有选项,进入选择步骤
if (options.length > 0) {
setStep('select')
// 如果有资产,默认选择第一个有资产的选项
const hasAssetsOption = options.find((opt: ProxyOption) => opt.hasAssets)
if (hasAssetsOption) {
setSelectedProxyType(hasAssetsOption.walletType)
} else {
// 否则选择第一个选项
setSelectedProxyType(options[0].walletType)
}
} else {
setStep('input')
message.warning(t('accountImport.proxyOption.error') || '未获取到代理地址选项')
}
} else {
setProxyOptions([])
setStep('input')
message.error(response.data.msg || '获取代理地址选项失败')
}
} catch (error: any) {
setProxyOptions([])
setStep('input')
message.error(error.message || '获取代理地址选项失败')
} finally {
setLoadingProxyOptions(false)
}
}
// 切换导入方式时重置状态
useEffect(() => {
setDerivedAddress('')
setAddressError('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
form.setFieldsValue({ walletAddress: '', privateKey: '', mnemonic: '' })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importType])
const handleSubmit = async (values: any) => {
try {
// 如果还在输入步骤,需要先选择代理地址
if (step === 'input' || !selectedProxyType) {
return Promise.reject(new Error(t('accountImport.proxyOptionRequired')))
}
let privateKey: string
let walletAddress: string
@@ -124,14 +228,11 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
// 如果用户手动输入了地址,验证是否与推导的地址一致
if (values.walletAddress) {
if (values.walletAddress !== derivedAddressFromMnemonic) {
// 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址)
walletAddress = derivedAddressFromMnemonic
} else {
// 地址匹配,使用用户输入的地址
walletAddress = values.walletAddress
}
} else {
// 如果用户没有输入地址,使用推导的地址
walletAddress = derivedAddressFromMnemonic
}
}
@@ -145,14 +246,13 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
privateKey: privateKey,
walletAddress: walletAddress,
accountName: values.accountName,
walletType: walletType
walletType: selectedProxyType
})
// 等待store更新
await new Promise(resolve => setTimeout(resolve, 100))
// 获取新添加的账户ID(通过API获取,因为store可能还没更新)
const { apiService } = await import('../services/api')
const accountsResponse = await apiService.accounts.list()
if (accountsResponse.data.code === 0 && accountsResponse.data.data) {
const newAccounts = accountsResponse.data.data.list || []
@@ -160,76 +260,53 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (newAccount && onSuccess) {
onSuccess(newAccount.id)
} else if (onSuccess) {
// 如果找不到账户,仍然调用onSuccess(可能在其他地方处理)
onSuccess(0)
}
} else if (onSuccess) {
// API调用失败,仍然调用onSuccess
onSuccess(0)
}
return Promise.resolve()
} catch (error: any) {
} catch (error: unknown) {
const err = error as Error & { code?: number }
const isDuplicate = err?.code === 4601
message.error(isDuplicate ? t('accountImport.duplicateAccount') : (err?.message ?? t('accountImport.importFailed')))
return Promise.reject(error)
}
}
const currentStep = step === 'input' ? 0 : 1
return (
<>
{showAlert && (
<Alert
message={t('accountImport.securityTip')}
description={t('accountImport.securityTipDesc')}
type="warning"
showIcon
style={{ marginBottom: '24px' }}
/>
)}
<div style={{ padding: isMobile ? '0 4px' : '0 8px' }}>
<Steps
current={currentStep}
size="small"
style={{ marginBottom: 24 }}
items={[
{ title: t('accountImport.importMethod'), icon: <KeyOutlined /> },
{ title: t('accountImport.selectProxyOption'), icon: <WalletOutlined /> },
{ title: t('accountImport.accountName'), icon: <UserOutlined /> }
]}
/>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
size={isMobile ? 'middle' : 'large'}
>
<Form.Item label={t('accountImport.importMethod')}>
<Form.Item label={t('accountImport.importMethod')} style={{ marginBottom: 16 }}>
<Radio.Group
value={importType}
onChange={(e) => {
setImportType(e.target.value)
setDerivedAddress('')
setAddressError('')
form.setFieldsValue({ walletAddress: '' })
}}
optionType="button"
buttonStyle="solid"
size={isMobile ? 'middle' : 'large'}
>
<Radio value="privateKey">{t('accountImport.privateKey')}</Radio>
<Radio value="mnemonic">{t('accountImport.mnemonic')}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
label={
<span>
{t('accountImport.walletType')}{' '}
<Tooltip
title={t('accountImport.walletTypeHelp')}
overlayInnerStyle={{ whiteSpace: 'pre-line', maxWidth: '300px' }}
>
<QuestionCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
>
<Radio.Group
value={walletType}
onChange={(e) => setWalletType(e.target.value)}
>
<Radio value="safe">
{t('accountImport.walletTypeSafe')}
</Radio>
<Radio value="magic" disabled>
{t('accountImport.walletTypeMagic')} {t('accountImport.magicNotSupported')}
</Radio>
<Radio.Button value="privateKey">{t('accountImport.privateKey')}</Radio.Button>
<Radio.Button value="mnemonic">{t('accountImport.mnemonic')}</Radio.Button>
</Radio.Group>
</Form.Item>
@@ -250,13 +327,15 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
}
}
]}
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
help={addressError || ''}
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
>
<Input.TextArea
rows={3}
rows={2}
placeholder={t('accountImport.privateKeyPlaceholder')}
onChange={handlePrivateKeyChange}
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
disabled={loadingProxyOptions}
/>
</Form.Item>
@@ -282,6 +361,7 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
<Input
placeholder={t('accountImport.walletAddressPlaceholder')}
readOnly={!!derivedAddress}
disabled={loadingProxyOptions}
/>
</Form.Item>
</>
@@ -302,13 +382,15 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
}
}
]}
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
help={addressError || ''}
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
>
<Input.TextArea
rows={4}
rows={2}
placeholder={t('accountImport.mnemonicPlaceholder')}
onChange={handleMnemonicChange}
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
disabled={loadingProxyOptions}
/>
</Form.Item>
@@ -334,39 +416,143 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
<Input
placeholder={t('accountImport.walletAddressPlaceholder')}
readOnly={!!derivedAddress}
disabled={loadingProxyOptions}
/>
</Form.Item>
</>
)}
{/* 请求代理地址时的 loading 提示 */}
{loadingProxyOptions && step === 'input' && (
<Form.Item>
<Alert
message={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Spin size="small" />
<span>{t('accountImport.loadingProxyOptions')}</span>
</div>
}
type="info"
showIcon={false}
style={{ marginBottom: 16 }}
/>
</Form.Item>
)}
{/* 代理地址选项选择 */}
{step === 'select' && (
<Form.Item
label={t('accountImport.selectProxyOption')}
required
rules={[
{
validator: () => {
if (!selectedProxyType) {
return Promise.reject(new Error(t('accountImport.proxyOptionRequired')))
}
return Promise.resolve()
}
}
]}
style={{ marginBottom: 20 }}
>
{loadingProxyOptions ? (
<div style={{ padding: '32px 0', textAlign: 'center' }}>
<Spin tip={t('accountImport.loadingProxyOptions')} />
</div>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
{proxyOptions.map((option) => {
const isSelected = selectedProxyType === option.walletType
const typeLabel = option.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'
return (
<Card
key={option.walletType}
hoverable
onClick={() => setSelectedProxyType(option.walletType)}
size="small"
style={{
cursor: 'pointer',
borderColor: isSelected ? 'var(--ant-color-primary)' : undefined,
borderWidth: isSelected ? 2 : 1,
backgroundColor: isSelected ? 'var(--ant-color-primary-bg)' : undefined,
transition: 'border-color 0.2s, background-color 0.2s'
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Space size="middle">
<Radio checked={isSelected} />
<Tag color={option.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
{typeLabel}
</Tag>
{option.hasAssets && (
<span style={{ color: '#52c41a', fontSize: 12 }}>
<CheckCircleOutlined /> {t('accountImport.proxyOption.hasAssets')}
</span>
)}
{option.error && (
<span style={{ color: 'var(--ant-color-error)', fontSize: 12 }}>
<ExclamationCircleOutlined /> {t('accountImport.proxyOption.error')}
</span>
)}
</Space>
{!option.error && (
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ant-color-primary)' }}>
{formatUSDC(option.totalBalance)} USDC
</span>
)}
</div>
<div style={{ marginTop: 8, marginLeft: 28, fontSize: 12, color: 'var(--ant-color-text-secondary)', wordBreak: 'break-all' }}>
{option.proxyAddress ? (
<span style={{ fontFamily: 'monospace' }}>{option.proxyAddress}</span>
) : (
'-'
)}
{option.error && (
<span style={{ color: 'var(--ant-color-error)', marginLeft: 8 }}>{option.error}</span>
)}
</div>
<div style={{ marginTop: 8, marginLeft: 28, fontSize: 12, color: 'var(--ant-color-text-secondary)', lineHeight: 1.5 }}>
{t('accountImport.proxyOption.proxyAddressHelp')}
</div>
</Card>
)
})}
</Space>
)}
</Form.Item>
)}
<Form.Item
label={t('accountImport.accountName')}
name="accountName"
style={{ marginBottom: 24 }}
>
<Input placeholder={t('accountImport.accountNamePlaceholder')} />
</Form.Item>
<Form.Item>
<Space>
<Form.Item style={{ marginBottom: 0 }}>
<Space size="middle">
<Button
type="primary"
htmlType="submit"
loading={loading}
disabled={step !== 'select' || !selectedProxyType || loadingProxyOptions}
size={isMobile ? 'middle' : 'large'}
style={isMobile ? { minHeight: 44 } : undefined}
>
{t('accountImport.importAccount')}
</Button>
{showCancelButton && onCancel && (
<Button onClick={onCancel}>
<Button onClick={onCancel} size={isMobile ? 'middle' : 'large'}>
{t('common.cancel')}
</Button>
)}
</Space>
</Form.Item>
</Form>
</>
</div>
)
}
export default AccountImportForm
+29 -5
View File
@@ -53,6 +53,7 @@
"accountId": "Account ID",
"accountName": "Account Name",
"walletAddress": "Wallet Address",
"walletType": "Wallet Type",
"balance": "Account Balance",
"refreshBalance": "Refresh Balance",
"apiCredentials": "API Credentials Configuration",
@@ -122,6 +123,7 @@
"importAccount": "Import Account",
"accountName": "Account Name",
"walletAddress": "Wallet Address",
"walletType": "Wallet Type",
"proxyAddress": "Proxy Wallet Address",
"apiCredentials": "API Credentials",
"balance": "Balance",
@@ -178,8 +180,6 @@
"accountImport": {
"title": "Import Account",
"back": "Back",
"securityTip": "Security Tip",
"securityTipDesc": "Private keys will be stored in the backend database. Please ensure database access is secure. HTTPS is recommended.",
"importMethod": "Import Method",
"privateKey": "Private Key",
"mnemonic": "Mnemonic",
@@ -213,9 +213,29 @@
"addressErrorMnemonic": "Cannot derive address from mnemonic",
"walletType": "Wallet Type",
"walletTypeHelp": "Web3 Wallet: Polymarket accounts connected via browser wallets like MetaMask\nMagic: Polymarket accounts logged in via email or social accounts (Google, Twitter, etc.)",
"walletTypeMagic": "Magic (Email/Social Login)",
"walletTypeSafe": "Web3 Wallet",
"magicNotSupported": "(Not Supported)"
"loadingProxyOptions": "Loading proxy addresses and asset information...",
"selectProxyOption": "Please select account type",
"proxyOptionRequired": "Please select a proxy address",
"proxyOption": {
"magic": {
"description": "Email/Social Login Account (Magic)",
"title": "Magic Proxy Address"
},
"safe": {
"description": "MetaMask Browser Wallet Account (Safe)",
"title": "Safe Proxy Address"
},
"proxyAddress": "Proxy Address",
"proxyAddressHelp": "The proxy address is the wallet address you actually use on Polymarket: Magic for email/social login accounts, Safe for browser wallet (e.g. MetaMask) accounts. Please select the one that matches how you log in to Polymarket.",
"availableBalance": "Available Balance",
"positionBalance": "Position Balance",
"totalBalance": "Total Balance",
"positionCount": "Position Count",
"noAssets": "No Assets",
"hasAssets": "Has Assets",
"error": "Failed to fetch",
"select": "Select this proxy address"
}
},
"leader": {
"title": "Leader Management",
@@ -1363,6 +1383,10 @@
"fixedAmountRequired": "Please enter fixed amount",
"fixedAmountInvalid": "Fixed amount must be greater than 0",
"priceFilters": "Price Filters",
"priceRange": "Price Range",
"priceRangeTooltip": "Only copy Leader orders with price within the specified range. Leave empty to disable. Example: Enter 0.11 and 0.89 to only copy orders with price between 0.11 and 0.89; enter only max price 0.89 to only copy orders with price below 0.89; enter only min price 0.11 to only copy orders with price above 0.11.",
"minPricePlaceholder": "Min price (leave empty to disable)",
"maxPricePlaceholder": "Max price (leave empty to disable)",
"keywordsPlaceholder": "Please enter keywords, press Enter to add",
"maxPositionValuePlaceholder": "Leave empty to disable max position limit",
"delaySecondsHint": "Delay execution to simulate real copy trading delay",
+28 -5
View File
@@ -121,6 +121,7 @@
"importAccount": "导入账户",
"accountName": "账户名称",
"walletAddress": "钱包地址",
"walletType": "钱包类型",
"proxyAddress": "代理钱包地址",
"apiCredentials": "API 凭证",
"balance": "余额",
@@ -178,8 +179,6 @@
"accountImport": {
"title": "导入账户",
"back": "返回",
"securityTip": "安全提示",
"securityTipDesc": "私钥将存储在后端数据库中,请确保数据库访问安全。建议使用 HTTPS 连接。",
"importMethod": "导入方式",
"privateKey": "私钥",
"mnemonic": "助记词",
@@ -213,9 +212,29 @@
"addressErrorMnemonic": "无法从助记词推导地址",
"walletType": "钱包类型",
"walletTypeHelp": "Web3钱包:使用 MetaMask 等浏览器钱包连接的 Polymarket 账户\nMagic:通过邮箱或社交账号(如 Google、Twitter)登录的 Polymarket 账户",
"walletTypeMagic": "Magic(邮箱/社交账号登录)",
"walletTypeSafe": "Web3钱包",
"magicNotSupported": "(暂不支持)"
"loadingProxyOptions": "正在获取代理地址和资产信息...",
"selectProxyOption": "请选择账户类型",
"proxyOptionRequired": "请选择一个代理地址",
"proxyOption": {
"magic": {
"description": "邮箱/社交账号登录账户(Magic",
"title": "Magic 代理地址"
},
"safe": {
"description": "MetaMask 等浏览器钱包账户(Safe",
"title": "Safe 代理地址"
},
"proxyAddress": "代理地址",
"proxyAddressHelp": "代理地址是您在 Polymarket 上实际使用的钱包地址:Magic 为邮箱/社交登录账户,Safe 为浏览器钱包(如 MetaMask)账户。请选择与您 Polymarket 登录方式一致的一项。",
"availableBalance": "可用余额",
"positionBalance": "仓位余额",
"totalBalance": "总余额",
"positionCount": "持仓数量",
"noAssets": "无资产",
"hasAssets": "有资产",
"error": "获取失败",
"select": "选择此代理地址"
}
},
"leader": {
"title": "Leader 管理",
@@ -1363,6 +1382,10 @@
"fixedAmountRequired": "请输入固定金额",
"fixedAmountInvalid": "固定金额必须大于 0",
"priceFilters": "价格过滤",
"priceRange": "价格区间",
"priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。",
"minPricePlaceholder": "最低价(留空不限制)",
"maxPricePlaceholder": "最高价(留空不限制)",
"keywordsPlaceholder": "请输入关键字,按回车添加",
"maxPositionValuePlaceholder": "留空表示不启用最大仓位限制",
"delaySecondsHint": "延迟执行模拟真实跟单延迟",
+29 -5
View File
@@ -53,6 +53,7 @@
"accountId": "賬戶ID",
"accountName": "賬戶名稱",
"walletAddress": "錢包地址",
"walletType": "錢包類型",
"balance": "賬戶餘額",
"refreshBalance": "刷新餘額",
"apiCredentials": "API 憑證配置",
@@ -122,6 +123,7 @@
"importAccount": "導入賬戶",
"accountName": "賬戶名稱",
"walletAddress": "錢包地址",
"walletType": "錢包類型",
"proxyAddress": "代理錢包地址",
"apiCredentials": "API 憑證",
"balance": "餘額",
@@ -178,8 +180,6 @@
"accountImport": {
"title": "導入賬戶",
"back": "返回",
"securityTip": "安全提示",
"securityTipDesc": "私鑰將存儲在後端數據庫中,請確保數據庫訪問安全。建議使用 HTTPS 連接。",
"importMethod": "導入方式",
"privateKey": "私鑰",
"mnemonic": "助記詞",
@@ -213,9 +213,29 @@
"addressErrorMnemonic": "無法從助記詞推導地址",
"walletType": "錢包類型",
"walletTypeHelp": "Web3錢包:使用 MetaMask 等瀏覽器錢包連接的 Polymarket 帳戶\nMagic:透過郵箱或社群帳號(如 Google、Twitter)登入的 Polymarket 帳戶",
"walletTypeMagic": "Magic(郵箱/社群帳號登入)",
"walletTypeSafe": "Web3錢包",
"magicNotSupported": "(暫不支持)"
"loadingProxyOptions": "正在獲取代理地址和資產信息...",
"selectProxyOption": "請選擇帳戶類型",
"proxyOptionRequired": "請選擇一個代理地址",
"proxyOption": {
"magic": {
"description": "郵箱/社群帳號登入帳戶(Magic",
"title": "Magic 代理地址"
},
"safe": {
"description": "MetaMask 等瀏覽器錢包帳戶(Safe",
"title": "Safe 代理地址"
},
"proxyAddress": "代理地址",
"proxyAddressHelp": "代理地址是您在 Polymarket 上實際使用的錢包地址:Magic 為郵箱/社群登入帳戶,Safe 為瀏覽器錢包(如 MetaMask)帳戶。請選擇與您 Polymarket 登入方式一致的一項。",
"availableBalance": "可用餘額",
"positionBalance": "倉位餘額",
"totalBalance": "總餘額",
"positionCount": "持倉數量",
"noAssets": "無資產",
"hasAssets": "有資產",
"error": "獲取失敗",
"select": "選擇此代理地址"
}
},
"leader": {
"title": "Leader 管理",
@@ -1363,6 +1383,10 @@
"fixedAmountRequired": "請輸入固定金額",
"fixedAmountInvalid": "固定金額必須大於 0",
"priceFilters": "價格過濾",
"priceRange": "價格區間",
"priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。",
"minPricePlaceholder": "最低價(留空不限制)",
"maxPricePlaceholder": "最高價(留空不限制)",
"keywordsPlaceholder": "請輸入關鍵字,按回車添加",
"maxPositionValuePlaceholder": "留空表示不啟用最大倉位限制",
"delaySecondsHint": "延遲執行模擬真實跟單延遲",
+7
View File
@@ -181,6 +181,13 @@ const AccountDetail: React.FC = () => {
<Descriptions.Item label={t('account.accountName')}>
{account.accountName || '-'}
</Descriptions.Item>
{account.walletType && (
<Descriptions.Item label={t('account.walletType')}>
<Tag color={account.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
{account.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
</Tag>
</Descriptions.Item>
)}
<Descriptions.Item label={t('account.walletAddress')} span={isMobile ? 1 : 2}>
<span style={{
fontFamily: 'monospace',
-2
View File
@@ -35,8 +35,6 @@ const AccountImport: React.FC = () => {
form={form}
onSuccess={handleSuccess}
onCancel={() => navigate('/accounts')}
showAlert={true}
showCancelButton={true}
/>
</Card>
</div>
+32 -5
View File
@@ -303,6 +303,20 @@ const AccountList: React.FC = () => {
)
}
},
{
title: t('accountList.walletType'),
dataIndex: 'walletType',
key: 'walletType',
render: (walletType: string) => {
if (!walletType) return '-'
const type = walletType.toLowerCase()
return (
<Tag color={type === 'magic' ? 'purple' : 'blue'}>
{type === 'magic' ? 'Magic' : 'Safe'}
</Tag>
)
}
},
{
title: t('accountList.balance'),
dataIndex: 'balance',
@@ -392,7 +406,7 @@ const AccountList: React.FC = () => {
style={{ marginLeft: '4px', padding: '0 4px' }}
/>
</div>
<div>
<div style={{ marginBottom: '4px' }}>
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress ? `${record.proxyAddress.slice(0, 6)}...${record.proxyAddress.slice(-4)}` : '-'}
<Button
type="text"
@@ -405,6 +419,14 @@ const AccountList: React.FC = () => {
style={{ marginLeft: '4px', padding: '0 4px' }}
/>
</div>
{record.walletType && (
<div style={{ marginBottom: '4px' }}>
<strong>{t('accountList.walletType')}:</strong>{' '}
<Tag color={record.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'} style={{ marginLeft: '4px' }}>
{record.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
</Tag>
</div>
)}
</div>
<div style={{
fontSize: '14px',
@@ -654,6 +676,13 @@ const AccountList: React.FC = () => {
/>
</Space>
</Descriptions.Item>
{detailAccount.walletType && (
<Descriptions.Item label={t('accountList.walletType')}>
<Tag color={detailAccount.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
{detailAccount.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
</Tag>
</Descriptions.Item>
)}
<Descriptions.Item label={t('accountList.totalBalance')} span={isMobile ? 1 : 2}>
{detailBalanceLoading ? (
<Spin size="small" />
@@ -854,9 +883,9 @@ const AccountList: React.FC = () => {
accountImportForm.resetFields()
}}
footer={null}
width={isMobile ? '95%' : 600}
width={isMobile ? '95%' : 640}
style={{ top: isMobile ? 20 : 50 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
bodyStyle={{ padding: isMobile ? '16px 20px' : '24px 28px', maxHeight: 'calc(100vh - 140px)', overflow: 'auto' }}
destroyOnClose
maskClosable
closable
@@ -868,8 +897,6 @@ const AccountList: React.FC = () => {
setAccountImportModalVisible(false)
accountImportForm.resetFields()
}}
showAlert={true}
showCancelButton={true}
/>
</Modal>
</div>
+65 -1
View File
@@ -272,7 +272,9 @@ const BacktestList: React.FC = () => {
supportSell: values.supportSell,
keywordFilterMode: values.keywordFilterMode,
keywords: values.keywords,
maxPositionValue: values.maxPositionValue
maxPositionValue: values.maxPositionValue,
minPrice: values.minPrice,
maxPrice: values.maxPrice
}
const response = await backtestService.create(request)
@@ -324,6 +326,8 @@ const BacktestList: React.FC = () => {
keywordFilterMode: taskConfig.keywordFilterMode || 'DISABLED',
keywords: taskConfig.keywords || [],
maxPositionValue: taskConfig.maxPositionValue,
minPrice: taskConfig.minPrice,
maxPrice: taskConfig.maxPrice,
configName: `回测任务-${taskDetail.taskName}`
}
@@ -992,6 +996,50 @@ const BacktestList: React.FC = () => {
/>
</Form.Item>
<Form.Item
label={t('backtest.priceRange')}
tooltip={t('backtest.priceRangeTooltip')}
>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="minPrice" noStyle>
<InputNumber
style={{ width: '100%' }}
placeholder={t('backtest.minPricePlaceholder') || '最低价(留空不限制)'}
min={0.01}
max={0.99}
step={0.0001}
precision={4}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="maxPrice" noStyle>
<InputNumber
style={{ width: '100%' }}
placeholder={t('backtest.maxPricePlaceholder') || '最高价(留空不限制)'}
min={0.01}
max={0.99}
step={0.0001}
precision={4}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
</Col>
</Row>
</Form.Item>
<Form.Item
label={t('backtest.supportSell')}
name="supportSell"
@@ -1078,6 +1126,8 @@ const BacktestList: React.FC = () => {
keywordFilterMode: detailConfig.keywordFilterMode,
keywords: detailConfig.keywords || [],
maxPositionValue: detailConfig.maxPositionValue,
minPrice: detailConfig.minPrice,
maxPrice: detailConfig.maxPrice,
configName: `回测任务-${detailTask.taskName}`
}
setPreFilledConfig(preFilled)
@@ -1268,6 +1318,20 @@ const BacktestList: React.FC = () => {
{formatUSDC(detailConfig.maxPositionValue)} USDC
</Descriptions.Item>
)}
{(detailConfig.minPrice || detailConfig.maxPrice) && (
<Descriptions.Item label={t('backtest.priceRange')}>
{detailConfig.minPrice !== undefined && detailConfig.minPrice !== null && detailConfig.minPrice !== ''
? `${parseFloat(detailConfig.minPrice).toFixed(4)}`
: ''}
{(detailConfig.minPrice !== undefined && detailConfig.minPrice !== null && detailConfig.minPrice !== '') &&
(detailConfig.maxPrice !== undefined && detailConfig.maxPrice !== null && detailConfig.maxPrice !== '')
? ' ~ '
: ''}
{detailConfig.maxPrice !== undefined && detailConfig.maxPrice !== null && detailConfig.maxPrice !== ''
? `${parseFloat(detailConfig.maxPrice).toFixed(4)}`
: ''}
</Descriptions.Item>
)}
</Descriptions>
</Card>
)}
@@ -1106,9 +1106,9 @@ const AddModal: React.FC<AddModalProps> = ({
accountImportForm.resetFields()
}}
footer={null}
width={isMobile ? '95%' : 600}
width={isMobile ? '95%' : 640}
style={{ top: isMobile ? 20 : 50 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
bodyStyle={{ padding: isMobile ? '16px 20px' : '24px 28px', maxHeight: 'calc(100vh - 140px)', overflow: 'auto' }}
destroyOnClose
maskClosable
closable
@@ -1120,8 +1120,6 @@ const AddModal: React.FC<AddModalProps> = ({
setAccountImportModalVisible(false)
accountImportForm.resetFields()
}}
showAlert={true}
showCancelButton={true}
/>
</Modal>
+17 -4
View File
@@ -62,10 +62,10 @@ const PositionList: React.FC = () => {
}
}, [])
// 当仓位数据变化时,更新可赎回统计
// 当仓位数据变化时,静默更新可赎回统计(不显示loading状态)
useEffect(() => {
if (currentPositions.length > 0) {
fetchRedeemableSummary()
fetchRedeemableSummarySilently()
}
}, [currentPositions, selectedAccountId])
@@ -74,7 +74,19 @@ const PositionList: React.FC = () => {
setCurrentPage(1)
}, [positionFilter, selectedAccountId, searchKeyword])
// 获取可赎回仓位统计
// 静默获取可赎回仓位统计(不显示loading状态)
const fetchRedeemableSummarySilently = async () => {
try {
const response = await apiService.accounts.getRedeemableSummary({ accountId: selectedAccountId })
if (response.data.code === 0 && response.data.data) {
setRedeemableSummary(response.data.data)
}
} catch (error: any) {
console.error('获取可赎回统计失败:', error)
}
}
// 获取可赎回仓位统计(带loading状态,用于用户主动操作)
const fetchRedeemableSummary = async () => {
setLoadingRedeemableSummary(true)
try {
@@ -91,8 +103,9 @@ const PositionList: React.FC = () => {
// 处理赎回按钮点击
const handleRedeemClick = async () => {
await fetchRedeemableSummary()
setRedeemModalVisible(true)
// 打开模态框时重新获取最新数据
fetchRedeemableSummary()
}
// 提交赎回
+6
View File
@@ -206,6 +206,12 @@ export const apiService = {
* API
*/
accounts: {
/**
*
*/
checkProxyOptions: (data: any) =>
apiClient.post<ApiResponse<any>>('/accounts/check-proxy-options', data),
/**
*
*/
+3 -1
View File
@@ -60,8 +60,10 @@ export const useAccountStore = create<AccountStore>((set, get) => ({
if (response.data.code === 0) {
await get().fetchAccounts()
} else {
const err = new Error(response.data.msg || '导入账户失败')
;(err as Error & { code?: number }).code = response.data.code
set({ error: response.data.msg || '导入账户失败', loading: false })
throw new Error(response.data.msg || '导入账户失败')
throw err
}
} catch (error: any) {
set({ error: error.message || '导入账户失败', loading: false })
+31
View File
@@ -46,6 +46,37 @@ export interface AccountImportRequest {
walletType?: string // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
}
/**
*
*/
export interface CheckProxyOptionsRequest {
walletAddress: string // EOA 地址
privateKey?: string // 私钥(加密,私钥导入时提供)
mnemonic?: string // 助记词(加密,助记词导入时提供)
}
/**
*
*/
export interface ProxyOption {
walletType: string // "magic" 或 "safe"
proxyAddress: string // 代理地址
descriptionKey: string // 说明文案的多语言 key
availableBalance: string // 可用余额
positionBalance: string // 仓位余额
totalBalance: string // 总余额
positionCount: number // 持仓数量
hasAssets: boolean // 是否有资产
error?: string // 获取失败时的错误信息
}
/**
*
*/
export interface CheckProxyOptionsResponse {
options: ProxyOption[] // 代理地址选项列表
}
/**
*
*/