Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1ec0a330b | |||
| 72de65d670 | |||
| 3405a1cda3 | |||
| fc6fa8b419 | |||
| cf2c8a611c | |||
| bd323fca35 | |||
| a067a20a02 |
+1
-1
@@ -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 Payload(PROXY 类型执行时使用)
|
||||
* 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 Payload(PROXY 执行时获取 relay 地址与 nonce)
|
||||
* 参考: builder-relayer-client types RelayPayload
|
||||
*/
|
||||
data class RelayPayload(
|
||||
@SerializedName("address")
|
||||
val address: String,
|
||||
@SerializedName("nonce")
|
||||
val nonce: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Relayer 交易详情
|
||||
|
||||
+54
-7
@@ -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登录)或 safe(MetaMask浏览器钱包)
|
||||
)
|
||||
|
||||
/**
|
||||
* 检查代理地址选项请求
|
||||
*/
|
||||
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", "Safe(Web3钱包)");
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+223
-28
@@ -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 {
|
||||
// 获取当前语言设置
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+14
-17
@@ -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 Proxy(MetaMask 钱包用户)- 通过合约调用获取地址
|
||||
*
|
||||
* @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 Proxy(MetaMask 用户)
|
||||
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 PROXY(Gasless)执行
|
||||
*
|
||||
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
|
||||
* @param proxyAddress 代理地址(Gnosis Safe 代理钱包地址)
|
||||
* @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址)
|
||||
* @param conditionId 市场条件ID(bytes32,必须是 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)
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
+10
-6
@@ -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"
|
||||
|
||||
+259
-12
@@ -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 PROXY(Magic)合约地址,参考 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 Relayer(Gasless),如果未配置则回退到手动发送交易
|
||||
*
|
||||
* @param privateKey 私钥
|
||||
* @param proxyAddress 代理钱包地址
|
||||
* @param safeTx Safe 交易对象
|
||||
* @param safeTx 交易对象(to/data/value)
|
||||
* @param walletType 钱包类型:MAGIC 使用 PROXY Gasless,SAFE 使用 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 Relayer(Gasless)
|
||||
if (walletType == WalletType.MAGIC) {
|
||||
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
return Result.failure(IllegalStateException("Magic 账户赎回必须配置 Builder API Key(Gasless)"))
|
||||
}
|
||||
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 执行 PROXY(Magic)交易(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表示不限制最高价';
|
||||
+38
@@ -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;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "延迟执行模拟真实跟单延迟",
|
||||
|
||||
@@ -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": "延遲執行模擬真實跟單延遲",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -35,8 +35,6 @@ const AccountImport: React.FC = () => {
|
||||
form={form}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={() => navigate('/accounts')}
|
||||
showAlert={true}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
// 提交赎回
|
||||
|
||||
@@ -206,6 +206,12 @@ export const apiService = {
|
||||
* 账户管理 API
|
||||
*/
|
||||
accounts: {
|
||||
/**
|
||||
* 检查代理地址选项(导入前选择代理类型)
|
||||
*/
|
||||
checkProxyOptions: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/accounts/check-proxy-options', data),
|
||||
|
||||
/**
|
||||
* 导入账户
|
||||
*/
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -46,6 +46,37 @@ export interface AccountImportRequest {
|
||||
walletType?: string // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理地址选项请求
|
||||
*/
|
||||
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[] // 代理地址选项列表
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户更新请求
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user