diff --git a/.gitignore b/.gitignore index 0c705f8..feb1af5 100644 --- a/.gitignore +++ b/.gitignore @@ -112,4 +112,5 @@ __pycache__/ clob-client/ builder-relayer-client/ landing-page/ - +clob-client-v2/ +settings.local.json \ No newline at end of file diff --git a/backend/CLOB_V2_MIGRATION.md b/backend/CLOB_V2_MIGRATION.md new file mode 100644 index 0000000..4d90458 --- /dev/null +++ b/backend/CLOB_V2_MIGRATION.md @@ -0,0 +1,1084 @@ +# Polymarket CLOB V2 迁移指南 + +## 📋 迁移概述 + +Polymarket正在升级其整个交易基础设施,包括新的Exchange合约、重写的CLOB后端和新的抵押品代币(pUSD)。本文档详细说明了如何将backend代码从CLOB V1迁移到V2。 + +**迁移截止日期:** 2026年4月28日 ~11:00 UTC +**预计停机时间:** 约1小时 +**重要提示:** V2发布后,V1 SDK将立即停止工作,无向后兼容性。 + +--- + +## 🔄 主要变更概览 + +| 变更项 | V1 | V2 | +|--------|-----|-----| +| SDK包名 | `@polymarket/clob-client` | `@polymarket/clob-client-v2` | +| 构造函数 | 位置参数 | 选项对象 (`chainId` → `chain`) | +| 订单字段(移除) | `nonce`, `feeRateBps`, `taker`, `expiration` | 已移除,不再使用 | +| 订单字段(新增) | - | `timestamp`(毫秒), `metadata`(bytes32), `builder`(bytes32) | +| 费用设置 | 订单中嵌入 (`feeRateBps`) | 匹配时由协议设定,通过 `getClobMarketInfo()` 查询 | +| 抵押品代币 | USDC.e | pUSD (Polymarket USD) | +| Builder认证 | HMAC headers | 单个 `builderCode` 字段 | +| EIP-712版本 | `"1"` | `"2"` | +| Exchange合约地址 | V1地址 | V2地址 (见下文) | +| 订单取消 | 链上 `cancel()` | 运营商控制的 `pauseUser` / `unpauseUser` | +| CLOB URL | `clob.polymarket.com` | `clob-v2.polymarket.com` | + +--- + +## 🏗️ 合约地址变更 + +### 标准CTF Exchange +```kotlin +// V1 (旧) +private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E" + +// V2 (新) +private val EXCHANGE_CONTRACT_V2 = "0xE111180000d2663C0091e4f400237545B87B996B" +``` + +### Neg Risk CTF Exchange +```kotlin +// V1 (旧) +private val NEG_RISK_EXCHANGE_CONTRACT = "0xC5d563A36AE78145C45a50134d48A1215220f80a" + +// V2 (新) +private val NEG_RISK_EXCHANGE_CONTRACT_V2 = "0xe2222d279d744050d28e00520010520000310F59" +``` + +--- + +## 📝 详细代码改动 + +### 1. EIP-712编码器更新 (`Eip712Encoder.kt`) + +#### 1.1 更新域分隔符编码方法 + +**位置:** `Eip712Encoder.kt:174-201` + +**直接替换现有方法:** + +```kotlin +/** + * 编码 ExchangeOrder V2 域分隔符 + * Domain: { name: "Polymarket CTF Exchange", version: "2", chainId: chainId, verifyingContract: exchangeContract } + */ +fun encodeExchangeDomain( + chainId: Long, + verifyingContract: String +): ByteArray { + val domainTypeHash = encodeType( + "EIP712Domain", + listOf( + "name" to "string", + "version" to "string", + "chainId" to "uint256", + "verifyingContract" to "address" + ) + ) + + val nameHash = encodeString("Polymarket CTF Exchange") + val versionHash = encodeString("2") // V2:版本从 "1" 改为 "2" + val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId)) + val contractBytes = encodeAddress(verifyingContract) + + val encoded = ByteArray(32 + 32 + 32 + 32 + 32) + System.arraycopy(domainTypeHash, 0, encoded, 0, 32) + System.arraycopy(nameHash, 0, encoded, 32, 32) + System.arraycopy(versionHash, 0, encoded, 64, 32) + System.arraycopy(chainIdBytes, 0, encoded, 96, 32) + System.arraycopy(contractBytes, 0, encoded, 128, 32) + + return keccak256(encoded) +} +``` + +#### 1.2 替换订单编码方法 + +**位置:** `Eip712Encoder.kt:208-280` + +**直接替换现有方法:** + +```kotlin +/** + * 编码 ExchangeOrder V2 消息哈希 + * 参考: CLOB V2 迁移指南 + * Order V2: { salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp, metadata, builder } + */ +fun encodeExchangeOrder( + salt: Long, + maker: String, + signer: String, + tokenId: String, + makerAmount: String, + takerAmount: String, + side: String, + signatureType: Int, + timestamp: String, // V2:订单创建时间(毫秒) + metadata: String, // V2:bytes32 元数据 + builder: String // V2:bytes32 builder代码 +): ByteArray { + val orderTypeHash = encodeType( + "Order", + listOf( + "salt" to "uint256", + "maker" to "address", + "signer" to "address", + "tokenId" to "uint256", + "makerAmount" to "uint256", + "takerAmount" to "uint256", + "side" to "uint8", + "signatureType" to "uint8", + "timestamp" to "uint256", // V2字段 + "metadata" to "bytes32", // V2字段 + "builder" to "bytes32" // V2字段 + ) + ) + + // 编码订单字段 + val saltBytes = encodeUint256(BigInteger.valueOf(salt)) + val makerBytes = encodeAddress(maker) + val signerBytes = encodeAddress(signer) + val tokenIdBytes = encodeUint256(BigInteger(tokenId)) + val makerAmountBytes = encodeUint256(BigInteger(makerAmount)) + val takerAmountBytes = encodeUint256(BigInteger(takerAmount)) + + // side: BUY = 0, SELL = 1 + val sideValue = when (side.uppercase()) { + "BUY" -> 0 + "SELL" -> 1 + else -> throw IllegalArgumentException("side 必须是 BUY 或 SELL") + } + val sideBytes = encodeUint256(BigInteger.valueOf(sideValue.toLong())) + val signatureTypeBytes = encodeUint256(BigInteger.valueOf(signatureType.toLong())) + + // V2 字段编码 + val timestampBytes = encodeUint256(BigInteger(timestamp)) + val metadataBytes = Numeric.hexStringToByteArray(metadata.removePrefix("0x").padStart(64, '0')) + val builderBytes = Numeric.hexStringToByteArray(builder.removePrefix("0x").padStart(64, '0')) + + // 组合所有字段 (typeHash + 11个字段 = 12个slot) + val encoded = ByteArray(32 * 12) + var offset = 0 + + System.arraycopy(orderTypeHash, 0, encoded, offset, 32); offset += 32 + System.arraycopy(saltBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(makerBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(signerBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(tokenIdBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(makerAmountBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(takerAmountBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(sideBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(signatureTypeBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(timestampBytes, 0, encoded, offset, 32); offset += 32 // V2 新增 + System.arraycopy(metadataBytes, 0, encoded, offset, 32); offset += 32 // V2 新增 + System.arraycopy(builderBytes, 0, encoded, offset, 32) // V2 新增 + + return keccak256(encoded) +} +``` + +--- + + + +### 2. 订单对象结构更新 (`PolymarketClobApi.kt`) + +#### 2.1 创建新的V2订单对象 + +**位置:** `PolymarketClobApi.kt` + +```kotlin +/** + * 签名的订单对象 V2 + * 参考: https://docs.polymarket.com/v2-migration + * + * V2 移除了以下 V1 字段: taker, expiration, nonce, feeRateBps + * V2 新增了以下字段: timestamp, metadata, builder + */ +data class SignedOrderObject( + val salt: Long, // random salt + val maker: String, // maker address (funder) + val signer: String, // signing address + val tokenId: String, // ERC1155 token ID + val makerAmount: String, // maximum amount maker is willing to spend + val takerAmount: String, // minimum amount taker will pay + val side: String, // "BUY" or "SELL" + val signatureType: Int, // signature type enum index + val timestamp: String, // 订单创建时间戳(毫秒)V2新增 + val metadata: String, // bytes32 元数据 V2新增 + val builder: String, // bytes32 builder代码 V2新增 + val signature: String // hex encoded signature +) +``` + +#### 2.2 更新API请求对象 + +**位置:** `PolymarketClobApi.kt:200-205` + +**直接替换现有类:** + +```kotlin +/** + * 创建订单请求 (V2) + */ +data class NewOrderRequest( + val order: SignedOrderObject, // V2 signed object + val owner: String, // api key of order owner + val orderType: String // "FOK", "GTC", "GTD", "FAK" +) +``` + +--- + +### 3. 订单签名服务更新 (`OrderSigningService.kt`) + +#### 3.1 替换合约地址常量 + +**位置:** `OrderSigningService.kt:45-47` + +**直接替换现有常量:** + +```kotlin +// V2 合约地址 +private val EXCHANGE_CONTRACT = "0xE111180000d2663C0091e4f400237545B87B996B" +private val NEG_RISK_EXCHANGE_CONTRACT = "0xe2222d279d744050d28e00520010520000310F59" +``` + +#### 3.2 简化getExchangeContract方法 + +**位置:** `OrderSigningService.kt:30-32` + +**直接替换现有方法:** + +```kotlin +/** + * 根据是否为 Neg Risk 市场返回签约用 exchange 合约地址 + * @param negRisk true 时使用 Neg Risk CTF Exchange,否则使用标准 CTF Exchange + */ +fun getExchangeContract(negRisk: Boolean): String { + return if (negRisk) NEG_RISK_EXCHANGE_CONTRACT else EXCHANGE_CONTRACT +} +``` + +#### 3.3 替换createAndSignOrder方法 + +**位置:** `OrderSigningService.kt:174-259` + +**直接替换现有方法:** + +```kotlin +/** + * 创建并签名订单 (V2) + * + * @param privateKey 私钥 + * @param makerAddress maker 地址 + * @param tokenId token ID + * @param side BUY 或 SELL + * @param price 价格 + * @param size 数量 + * @param signatureType 签名类型(1: Email/Magic, 2: Browser Wallet) + * @param exchangeContract 签约用 exchange 合约地址;null 时用标准 CTF Exchange + * @param builderCode builder代码(bytes32,可选) + * @param metadata 元数据(bytes32,默认为零) + * @return V2 签名的订单对象 + */ +fun createAndSignOrder( + privateKey: String, + makerAddress: String, + tokenId: String, + side: String, + price: String, + size: String, + signatureType: Int = 2, + exchangeContract: String? = null, + builderCode: String? = null, + metadata: String = "0x0000000000000000000000000000000000000000000000000000000000000000" +): SignedOrderObject { + try { + // 1. 从私钥获取签名地址 + val cleanPrivateKey = privateKey.removePrefix("0x") + val privateKeyBigInt = BigInteger(cleanPrivateKey, 16) + val credentials = Credentials.create(privateKeyBigInt.toString(16)) + val signerAddress = credentials.address.lowercase() + + // 2. 计算订单金额(复用现有逻辑) + val amounts = calculateOrderAmounts(side, size, price) + + // 3. 生成 salt 和 timestamp(毫秒) + // V2: 使用 timestamp 替代 nonce 保证订单唯一性 + val salt = generateSalt() + val timestamp = System.currentTimeMillis().toString() + + // 4. 处理 builder 和 metadata + val builder = builderCode?.takeIf { it.isNotBlank() } + ?: "0x0000000000000000000000000000000000000000000000000000000000000000" + val metadataClean = if (metadata.isNotBlank()) metadata + else "0x0000000000000000000000000000000000000000000000000000000000000000" + + // 5. 确保 maker 地址是小写格式 + val makerAddressLower = makerAddress.lowercase() + + logger.debug("========== 订单签名前参数 (V2) ==========") + logger.debug("订单方向: $side, 价格: $price, 数量: $size") + logger.debug("Token ID: $tokenId") + logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}") + logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}") + logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}") + logger.debug("Salt: $salt, Timestamp: $timestamp") + logger.debug("Builder: $builder, Metadata: $metadataClean") + logger.debug("Signature Type: $signatureType") + + // 6. 使用合约地址 + val contract = exchangeContract?.takeIf { it.isNotBlank() } ?: EXCHANGE_CONTRACT + + // 7. 构建V2签名 + val signature = signOrder( + privateKey = privateKey, + exchangeContract = contract, + chainId = CHAIN_ID, + salt = salt, + maker = makerAddressLower, + signer = signerAddress, + tokenId = tokenId, + makerAmount = amounts.makerAmount, + takerAmount = amounts.takerAmount, + side = side.uppercase(), + signatureType = signatureType, + timestamp = timestamp, + metadata = metadataClean, + builder = builder + ) + + // 8. 创建V2签名订单对象 + // 注意: V2不再包含 taker, expiration, nonce, feeRateBps 字段 + return SignedOrderObject( + salt = salt, + maker = makerAddressLower, + signer = signerAddress, + tokenId = tokenId, + makerAmount = amounts.makerAmount, + takerAmount = amounts.takerAmount, + side = side.uppercase(), + signatureType = signatureType, + timestamp = timestamp, + metadata = metadataClean, + builder = builder, + signature = signature + ) + } catch (e: Exception) { + logger.error("创建并签名订单失败 (V2)", e) + throw RuntimeException("创建并签名订单失败 (V2): ${e.message}", e) + } +} +``` + +#### 3.4 替换signOrder方法 + +**位置:** `OrderSigningService.kt:266-334` + +**直接替换现有方法:** + +```kotlin +/** + * 签名订单 V2(EIP-712) + * V2 Order: salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp, metadata, builder + */ +private fun signOrder( + privateKey: String, + exchangeContract: String, + chainId: Long, + salt: Long, + maker: String, + signer: String, + tokenId: String, + makerAmount: String, + takerAmount: String, + side: String, + signatureType: Int, + timestamp: String, + metadata: String, + builder: String +): String { + try { + // 1. 私钥与密钥对 + val cleanPrivateKey = privateKey.removePrefix("0x") + val privateKeyBigInt = BigInteger(cleanPrivateKey, 16) + val credentials = Credentials.create(privateKeyBigInt.toString(16)) + val ecKeyPair = credentials.ecKeyPair + + // 2. 编码域分隔符(V2:版本为 "2") + val domainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeDomain( + chainId = chainId, + verifyingContract = exchangeContract.lowercase() + ) + + // 3. 编码V2订单消息哈希(11个字段,不含V1的taker/expiration/nonce/feeRateBps) + val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder( + salt = salt, + maker = maker, + signer = signer, + tokenId = tokenId, + makerAmount = makerAmount, + takerAmount = takerAmount, + side = side, + signatureType = signatureType, + timestamp = timestamp, + metadata = metadata, + builder = builder + ) + + // 4. 计算完整 EIP-712 结构化数据哈希 + val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData( + domainSeparator = domainSeparator, + messageHash = orderHash + ) + + // 5. 使用私钥签名 + val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false) + + // 6. 组合 r + s + v + val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') + val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0') + val vBytes = signature.v + val vInt = if (vBytes.isNotEmpty()) vBytes[0].toInt() and 0xff else 0 + val vHex = "%02x".format(vInt) + + return "0x$rHex$sHex$vHex" + } catch (e: Exception) { + logger.error("订单签名失败 (V2)", e) + throw RuntimeException("订单签名失败 (V2): ${e.message}", e) + } +} +``` + +--- + +### 4. Builder认证系统清理 + +#### 4.1 移除BuilderAuthInterceptor + +**位置:** `BuilderAuthInterceptor.kt` + +**删除整个文件:** +```bash +# V2不再需要HMAC builder认证,直接删除此文件 +rm BuilderAuthInterceptor.kt +``` + +#### 4.2 更新Builder配置 + +**创建新的Builder配置类:** + +```kotlin +/** + * V2 Builder配置 + */ +data class BuilderConfigV2( + val builderCode: String, // bytes32格式的builder代码 + val enabled: Boolean = true +) +``` + +#### 4.3 更新使用Builder认证的代码 + +**检查并更新所有使用 `BuilderAuthInterceptor` 的地方:** + +```kotlin +// V1 (旧) +val builderApi = RetrofitFactory.createRetrofit( + baseUrl = BUILDER_RELAYER_URL, + interceptors = listOf( + BuilderAuthInterceptor(apiKey, secret, passphrase) + ) +).create(BuilderRelayerApi::class.java) + +// V2 (新) - 移除Builder认证拦截器 +val builderApi = RetrofitFactory.createRetrofit( + baseUrl = BUILDER_RELAYER_URL, + interceptors = listOf() // V2不需要额外的认证拦截器 +).create(BuilderRelayerApi::class.java) + +// builderCode直接在订单中设置 +val order = orderSigningService.createAndSignOrder( + // ... 其他参数 + builderCode = builderConfig.builderCode +) +``` + +--- + +### 5. API端点更新 + +#### 5.1 移除Builder相关端点 + +**移除以下可能不再需要的端点:** + +```kotlin +// 删除这些端点(V2不再需要) +// @POST("/auth/builder-api-key") +// suspend fun createBuilderApiKey(): Response + +// @GET("/auth/builder-api-key") +// suspend fun getBuilderApiKeys(): Response> +``` + +#### 5.2 更新费率查询端点(如需要) + +**检查是否需要更新费率查询:** + +```kotlin +// V2可能使用新的端点(需要根据实际API文档确认) +@GET("/clob-market-info") +suspend fun getClobMarketInfo(@Query("market") market: String): Response + +// V2 响应结构 +data class ClobMarketInfoResponse( + val feeRate: Int, // 市场费率 + val feeExponent: Double, // 费用指数 + val builderFeeRate: Int? // Builder费率(如果有) +) +``` + +#### 5.3 更新CLOB API基础URL + +**V2使用新的CLOB URL:** + +```kotlin +// V1 (旧) +const val CLOB_BASE_URL = "https://clob.polymarket.com" + +// V2 (新) +const val CLOB_BASE_URL = "https://clob-v2.polymarket.com" +``` + +#### 5.4 更新订单取消方式 + +**V2取消了链上 `cancel()` 操作,改为运营商控制:** + +```kotlin +// V1: 链上取消订单 +// cancelOrder(orderId) -> DELETE /orders/{orderId} + +// V2: 运营商控制的 pauseUser / unpauseUser +// 取消订单的API端点可能保持不变(DELETE /orders/{orderId}) +// 但链上取消机制已移除,改为运营商暂停/恢复用户 +``` + +> **注意:** 需要验证 `DELETE /orders/{orderId}` 和 `DELETE /orders/batch` 端点在 V2 中是否仍可用。 + +--- + +### 6. Nonce追踪逻辑移除 + +V2 使用 `timestamp`(毫秒)替代 `nonce` 保证订单唯一性。需要移除所有 nonce 相关逻辑: + +```kotlin +// 移除以下V1代码: +// - nonce 参数传递 +// - nonce 追踪/缓存逻辑 +// - getNonce() 调用 + +// V2: 直接使用 timestamp +val timestamp = System.currentTimeMillis().toString() +``` + +--- + +### 7. 抵押品代币更新 (USDC.e → pUSD) + +V2 使用 pUSD (Polymarket USD) 替代 USDC.e: + +```kotlin +// V1 (旧) +const val COLLATERAL_TOKEN = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" // USDC.e + +// V2 (新) +const val COLLATERAL_TOKEN_PUSD = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" // pUSD + +// USDC.e 仍保留用于 wrap 操作 +const val USDCE_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + +// CollateralOnramp (USDC.e → pUSD) +const val COLLATERAL_ONRAMP = "0x93070a847efEf7F70739046A929D47a521F5B8ee" +// 参考: https://docs.polymarket.com/concepts/pusd +``` + +#### 7.1 USDC.e → pUSD Wrap 实现 + +**后端已实现自动 wrap 功能:** + +- `RelayClientService.createUsdceApproveForWrapTx()` — 创建 USDC.e approve 交易 +- `RelayClientService.createWrapToPusdTx()` — 创建 wrap 交易 +- `BlockchainService.wrapUsdcToPusd()` — 组合 approve + wrap 为 MultiSend 原子执行 +- `BlockchainService.queryUsdceBalance()` — 查询 USDC.e 余额 + +**前端已添加迁移按钮:** + +- 账户管理列表中每个账户的操作栏增加了 `SwapOutlined` 迁移按钮 +- 点击后查询 USDC.e 余额,有余额时弹出确认框 +- 确认后自动执行 approve + wrap 原子交易 + +**后端 API 端点:** +- `POST /api/accounts/wrap-to-pusd` — 执行 wrap +- `POST /api/accounts/usdce-balance` — 查询 USDC.e 余额 + +--- + +### 8. 迁移期间重要注意事项 + +**迁移当天(2026年4月28日 ~11:00 UTC)的关键变化:** + +- **所有挂单将被清空。** 迁移完成后必须重新下单。 +- V1 SDK/客户端将立即停止工作,无向后兼容性。 +- 预计约1小时停机时间,交易暂停。 +- 通过 Discord、Telegram 和 status.polymarket.com 获取确切维护窗口开始时间。 + +```kotlin +// 迁移前:停止自动交易策略 +tradingService.pauseAllTrading("CLOB V2迁移准备") + +// 迁移后:更新配置并重新启动 +configService.updateClobBaseUrl(CLOB_BASE_URL_V2) +configService.updateExchangeContracts(V2_CONTRACTS) +tradingService.resumeAllTrading("CLOB V2迁移完成") + +// 重新下挂单 +orderRecoveryService.replayOpenOrders() +``` + +### 1. 单元测试 + +#### 1.1 EIP-712编码测试 + +```kotlin +@Test +fun `test V2 EIP-712 encoding matches expected format`() { + val domain = Eip712Encoder.encodeExchangeDomain( + chainId = 137L, + verifyingContract = "0xE111180000d2663C0091e4f400237545B87B996B" + ) + + // 验证域分隔符 + val expectedDomainHash = "0x..." // 从官方文档或TypeScript SDK获取 + assertEquals(expectedDomainHash, Numeric.toHexString(domain)) +} + +@Test +fun `test V2 order encoding includes new fields`() { + val orderHash = Eip712Encoder.encodeExchangeOrder( + salt = 12345L, + maker = "0x...", + signer = "0x...", + tokenId = "12345", + makerAmount = "1000000", + takerAmount = "2000000", + side = "BUY", + signatureType = 2, + timestamp = "1713398400000", + metadata = "0x0000000000000000000000000000000000000000000000000000000000000000", + builder = "0x0000000000000000000000000000000000000000000000000000000000000000" + ) + + // 验证订单哈希格式 + assertNotNull(orderHash) + assertEquals(32, orderHash.size) +} +``` + +#### 1.2 订单签名测试 + +```kotlin +@Test +fun `test order signing produces valid signature`() { + val testPrivateKey = "0x..." // 测试私钥 + val order = orderSigningService.createAndSignOrder( + privateKey = testPrivateKey, + makerAddress = "0x...", + tokenId = "12345", + side = "BUY", + price = "0.5", + size = "10" + ) + + // 验证订单结构 + assertTrue(order.timestamp.isNotBlank()) + assertTrue(order.metadata.startsWith("0x")) + assertTrue(order.builder.startsWith("0x")) + assertEquals(132, order.signature.length) // 0x(2) + r(64) + s(64) + v(2) +} +``` + +### 2. 集成测试 + +#### 2.1 订单创建测试 + +```kotlin +@Test +@DisplayName("订单创建和提交测试 (V2)") +fun `test create and post order`() { + // 1. 创建订单 + val order = orderSigningService.createAndSignOrder( + privateKey = testConfig.privateKey, + makerAddress = testConfig.makerAddress, + tokenId = testTokenId, + side = "BUY", + price = "0.5", + size = "10" + ) + + // 2. 提交到API + val response = polymarketClobApi.createOrder( + NewOrderRequest( + order = order, + owner = testConfig.apiKey, + orderType = "GTC" + ) + ) + + // 3. 验证响应 + assertTrue(response.isSuccessful) + assertNotNull(response.body()?.orderId) +} +``` + +#### 2.2 Builder功能测试 + +```kotlin +@Test +@DisplayName("Builder代码测试 (V2)") +fun `test builder code field`() { + val builderCode = "0x1234...5678" // 有效的builder代码 + + val order = orderSigningService.createAndSignOrder( + privateKey = testConfig.privateKey, + makerAddress = testConfig.makerAddress, + tokenId = testTokenId, + side = "SELL", + price = "0.6", + size = "5", + builderCode = builderCode + ) + + // 验证builder字段设置正确 + assertEquals(builderCode.lowercase(), order.builder.lowercase()) +} +``` + +### 3. 测试市场数据 + +根据官方文档,使用以下市场进行测试: + +```kotlin +// 测试市场数据 +val TEST_MARKETS = listOf( + TestMarket( + name = "US / Iran nuclear deal in 2027?", + eventId = "73106", + orderbookTokenId = "102936...7216" + ), + TestMarket( + name = "Highest grossing movie in 2026?", + eventId = "79831", + orderbookTokenIds = listOf( + "81662...2777", + "17546...1707", + "28161...2479", + "89576...4694", + "21556...6607", + "51020...2516" + ) + ) +) +``` + +--- + +## 📋 迁移检查清单 + +### 阶段1:准备阶段(完成时间:迁移前2周) + +- [ ] 备份现有代码库 +- [ ] 创建迁移分支 `feature/clob-v2-migration` +- [ ] 设置测试环境和测试账户 +- [ ] 获取V2测试网络的builder code(如需要) +- [ ] 准备测试数据和测试用例 + +### 阶段2:代码迁移(完成时间:迁移前1周) + +- [ ] 更新EIP-712编码器(替换为V2方法,版本号 `"1"` → `"2"`) +- [ ] 更新订单对象结构(移除 `taker`, `expiration`, `nonce`, `feeRateBps`,新增 `timestamp`, `metadata`, `builder`) +- [ ] 更新订单签名服务(替换为V2签名,移除 `expiration` 和 `nonce` 参数) +- [ ] 更新合约地址常量(标准 Exchange 和 Neg Risk Exchange) +- [ ] 移除所有 nonce 追踪/缓存逻辑 +- [ ] 移除Builder认证相关代码(`BuilderAuthInterceptor` 和 HMAC headers) +- [ ] 更新API端点方法和CLOB基础URL(`clob-v2.polymarket.com`) +- [ ] 更新抵押品代币余额检查逻辑(USDC.e → pUSD) +- [ ] 验证订单取消API端点(`DELETE /orders/{orderId}`)是否仍可用 +- [ ] 更新依赖配置(如有需要) + +### 阶段3:测试验证(完成时间:迁移前3天) + +- [ ] 编写V2单元测试 +- [ ] 编写V2集成测试 +- [ ] 在测试环境执行完整测试 +- [ ] 验证订单签名正确性 +- [ ] 验证订单提交成功率 +- [ ] 测试builder code功能 +- [ ] 压力测试和性能验证 + +### 阶段4:部署准备(完成时间:迁移前1天) + +- [ ] 代码审查 +- [ ] 更新API文档 +- [ ] 准备回滚方案 +- [ ] 通知用户迁移计划 +- [ ] 准备监控和告警 + +### 阶段5:正式迁移(完成时间:2026年4月28日) + +- [ ] 在迁移窗口开始时停止交易 +- [ ] **备份所有挂单数据**(迁移后所有挂单将被清空) +- [ ] 部署更新后的代码到生产环境 +- [ ] 更新CLOB基础URL到 `clob-v2.polymarket.com` +- [ ] 执行冒烟测试 +- [ ] **重新下单**(迁移完成后必须重新提交所有挂单) +- [ ] 重新启用交易功能 +- [ ] 监控系统运行状态 +- [ ] 验证关键功能正常 + +### 阶段6:迁移后验证(完成时间:迁移后1周) + +- [ ] 持续监控系统性能 +- [ ] 收集用户反馈 +- [ ] 修复发现的问题 +- [ ] 优化V2实现 +- [ ] 清理临时测试代码 + +--- + +## 🚨 风险和注意事项 + +### 1. 关键风险点 + +#### 1.1 签名不兼容风险 +- **风险:** V2签名与V1不兼容,混用会导致订单被拒绝 +- **缓解措施:** + - 明确区分V1和V2方法 + - 添加版本检查和验证 + - 在切换时确保所有组件同步更新 + +#### 1.2 合约地址错误风险 +- **风险:** 使用V1地址签名V2订单会被拒绝 +- **缓解措施:** + - 在代码中添加地址版本检查 + - 使用配置文件管理合约地址 + - 添加地址验证测试 + +#### 1.3 停机时间风险 +- **风险:** 迁移期间约1小时的停机 +- **缓解措施:** + - 提前通知用户 + - 暂停自动交易策略 + - 准备快速回滚方案 + +### 2. 业务逻辑变更 + +#### 2.1 费用计算变更 +- **V1:** 费用嵌入在订单中(`feeRateBps`) +- **V2:** 费用由协议在匹配时决定,公式:`fee = C × feeRate × p × (1 - p)` +- **关键:** Maker 不收取费用,仅 Taker 付费 +- **影响:** 无法提前知道确切费用,订单中不再设置 `feeRateBps` +- **应对:** 更新费用预估逻辑,使用 `getClobMarketInfo()` 获取市场费率 + +#### 2.2 Builder功能变更 +- **V1:** 使用HMAC headers认证 +- **V2:** 使用订单中的`builderCode`字段 +- **影响:** 需要转换现有的builder配置 +- **应对:** 联系Polymarket获取builder code,更新配置 + +#### 2.3 抵押品代币变更 +- **V1:** USDC.e +- **V2:** pUSD (Polymarket USD) +- **影响:** 需要处理代币转换 +- **应对:** 更新代币余额检查逻辑,支持pUSD + +### 3. 监控和告警 + +#### 3.1 关键监控指标 + +```kotlin +// 添加V2特定的监控指标 +object ClobV2Metrics { + // 订单创建成功率 + val orderCreationSuccess = Counter.build() + .name("clob_v2_order_creation_success_total") + .help("V2订单创建成功次数") + .register() + + // 订单提交成功率 + val orderSubmissionSuccess = Counter.build() + .name("clob_v2_order_submission_success_total") + .help("V2订单提交成功次数") + .register() + + // 签名验证失败率 + val signatureValidationFailure = Counter.build() + .name("clob_v2_signature_validation_failure_total") + .help("V2签名验证失败次数") + .register() + + // API响应时间 + val apiResponseTime = Histogram.build() + .name("clob_v2_api_response_time_seconds") + .help("V2 API响应时间") + .register() +} +``` + +#### 3.2 告警配置 + +```yaml +# Prometheus告警规则示例 +groups: + - name: clob_v2_alerts + rules: + - alert: HighV2OrderFailureRate + expr: rate(clob_v2_order_submission_failure_total[5m]) > 0.1 + for: 2m + labels: + severity: critical + annotations: + summary: "V2订单失败率过高" + description: "过去5分钟内V2订单失败率超过10%" + + - alert: V2SignatureValidationFailure + expr: rate(clob_v2_signature_validation_failure_total[5m]) > 0.05 + for: 1m + labels: + severity: warning + annotations: + summary: "V2签名验证失败" + description: "检查EIP-712编码和签名逻辑" +``` + +--- + +## 🔄 回滚方案 + +### 1. 回滚触发条件 + +- V2订单失败率超过20% +- 系统出现严重错误影响交易 +- 签名验证大规模失败 +- API响应时间超过阈值(如10秒) + +### 2. 回滚步骤 + +1. **立即停止交易** + ```kotlin + tradingService.pauseAllTrading("执行V2回滚") + ``` + +2. **切换回V1代码** + ```bash + git checkout main # 回到迁移前的分支 + ./deploy.sh # 重新部署 + ``` + +3. **验证V1功能** + ```bash + curl -X POST https://api.example.com/health/check + ``` + +4. **恢复交易** + ```kotlin + tradingService.resumeAllTrading("回滚完成,恢复V1交易") + ``` + +### 3. 回滚后验证 + +- [ ] 检查系统日志 +- [ ] 验证V1订单功能正常 +- [ ] 检查数据库一致性 +- [ ] 通知用户回滚完成 +- [ ] 分析失败原因 + +--- + +## 📚 参考资源 + +### 官方文档 +- [Polymarket CLOB V2迁移指南](https://docs.polymarket.com/v2-migration) +- [CLOB API文档](https://docs.polymarket.com/developers/CLOB) +- [EIP-712标准](https://eips.ethereum.org/EIPS/eip-712) + +### TypeScript SDK参考 +- [clob-client-v2 GitHub](https://github.com/Polymarket/clob-client-v2) +- [V2订单类型定义](https://github.com/Polymarket/clob-client-v2/blob/main/src/types/ordersV2.ts) + +### 合约地址 +- [V2合约地址列表](https://docs.polymarket.com/contracts) +- [pUSD合约信息](https://docs.polymarket.com/pusd) + +### 测试资源 +- [测试市场列表](https://docs.polymarket.com/v2-migration#test-markets) +- [Polymarket Discord](https://discord.gg/polymarket) +- [状态页面](https://status.polymarket.com) + +--- + +## 🆘 支持和联系 + +### 技术支持 +- **Discord:** https://discord.gg/polymarket +- **Telegram:** Polymarket官方频道 +- **Email:** support@polymarket.com + +### 问题报告 +- **GitHub Issues:** https://github.com/Polymarket/clob-client-v2/issues +- **Bug报告:** 在Discord技术支持频道报告 + +### 更新通知 +- **Twitter:** @Polymarket +- **Blog:** https://polymarket.com/blog +- **状态页:** https://status.polymarket.com + +--- + +## 📝 变更日志 + +### 文档版本 +- **v1.0** (2025-01-20): 初始版本,包含完整的迁移指南 +- **v1.1** (2025-01-XX): 添加测试市场信息和监控配置示例 + +### 重大更新 +- 2025-01-20: 创建迁移文档,基于Polymarket官方V2迁移指南 + +--- + +## ⚠️ 最终检查清单 + +在执行迁移前,请确认: + +- [ ] 已仔细阅读官方迁移文档 +- [ ] 已在测试环境完成所有测试(使用 `clob-v2.polymarket.com` 测试市场) +- [ ] 已备份生产数据库 +- [ ] 已备份所有挂单数据(**迁移后挂单将被清空,必须重新下单**) +- [ ] 已移除所有 V1 字段(`taker`, `expiration`, `nonce`, `feeRateBps`) +- [ ] 已移除 nonce 追踪逻辑 +- [ ] 已移除 Builder HMAC 认证相关代码 +- [ ] 已更新合约地址和 EIP-712 domain version +- [ ] 已通知所有相关用户 +- [ ] 已准备回滚方案 +- [ ] 已设置监控和告警 +- [ ] 团队成员已了解迁移流程 +- [ ] 已准备迁移期间的客服支持 + +**记住:一旦迁移开始,无法中止。V1将立即停止工作,无向后兼容性。确保所有准备工作完成后再开始!** + +--- + +*本文档基于Polymarket官方V2迁移指南编写,如有疑问请参考官方文档或联系技术支持。* diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt index ab63872..1892925 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt @@ -73,7 +73,7 @@ interface PolymarketClobApi { */ @POST("/orders/batch") suspend fun createOrdersBatch( - @Body request: CreateOrdersBatchRequest + @Body request: List ): Response> /** @@ -174,22 +174,25 @@ interface PolymarketClobApi { // 请求和响应数据类 /** - * 签名的订单对象(根据官方文档) - * 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order + * V2 签名的订单对象 + * EIP-712 签名字段: salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp, metadata, builder + * API payload 额外字段: taker, expiration (不在 EIP-712 签名中,但 API 请求需要) + * 参考: clob-client-v2/src/types/ordersV2.ts NewOrderV2 */ data class SignedOrderObject( val salt: Long, // random salt used to create unique order val maker: String, // maker address (funder) val signer: String, // signing address - val taker: String, // taker address (operator) + val taker: String, // taker address (zero address for public orders, NOT in EIP-712 signing) val tokenId: String, // ERC1155 token ID of conditional token being traded val makerAmount: String, // maximum amount maker is willing to spend val takerAmount: String, // minimum amount taker will pay the maker in return - val expiration: String, // unix expiration timestamp - val nonce: String, // maker's exchange nonce of the order is associated - val feeRateBps: String, // fee rate basis points as required by the operator val side: String, // buy or sell enum index ("BUY" or "SELL") val signatureType: Int, // signature type enum index + val timestamp: String, // order creation time in milliseconds (V2) + val expiration: String, // expiration timestamp unix seconds, "0" = no expiration (NOT in EIP-712 signing) + val metadata: String, // bytes32 metadata (V2) + val builder: String, // bytes32 builder code (V2) val signature: String // hex encoded signature ) @@ -198,10 +201,11 @@ data class SignedOrderObject( * 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order */ data class NewOrderRequest( - val order: SignedOrderObject, // signed object + val order: SignedOrderObject, // V2 signed object val owner: String, // api key of order owner val orderType: String, // order type ("FOK", "GTC", "GTD", "FAK") - val deferExec: Boolean = false // defer execution flag + val deferExec: Boolean = false, // defer execution + val postOnly: Boolean = false // post only (maker-only) ) /** @@ -235,26 +239,6 @@ data class NewOrderResponse( } } -/** - * 旧的订单请求格式(已废弃,保留用于兼容) - * @deprecated 使用 NewOrderRequest 代替 - */ -@Deprecated("使用 NewOrderRequest 代替,需要签名的订单对象") -data class CreateOrderRequest( - val market: String? = null, // condition ID(可选,如果提供tokenId则不需要) - val token_id: String? = null, // token ID(可选,如果提供market则不需要) - val side: String, // "BUY" or "SELL" - val price: String, - val size: String, - val type: String = "LIMIT", - val expiration: Long? = null -) - -@Deprecated("使用 NewOrderRequest 代替") -data class CreateOrdersBatchRequest( - val orders: List -) - data class CancelOrdersBatchRequest( val orderIds: List ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt index 3886adb..bb16f48 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt @@ -572,5 +572,53 @@ class AccountController( } } + /** + * 将 USDC.e wrap 为 pUSD(V2 迁移) + */ + @PostMapping("/wrap-to-pusd") + fun wrapToPusd(@RequestBody request: Map): ResponseEntity>> { + return try { + val accountId = (request["accountId"] as? Number)?.toLong() + ?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource)) + val result = runBlocking { accountService.wrapUsdcToPusd(accountId) } + result.fold( + onSuccess = { txHash -> + ResponseEntity.ok(ApiResponse.success(mapOf("transactionHash" to txHash))) + }, + onFailure = { e -> + logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("USDC.e → pUSD wrap 异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } + + /** + * 查询 USDC.e 余额(V2 迁移用) + */ + @PostMapping("/usdce-balance") + fun getUsdceBalance(@RequestBody request: Map): ResponseEntity>> { + return try { + val accountId = (request["accountId"] as? Number)?.toLong() + ?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource)) + val result = runBlocking { accountService.getUsdceBalance(accountId) } + result.fold( + onSuccess = { balance -> + ResponseEntity.ok(ApiResponse.success(mapOf("balance" to balance.toPlainString()))) + }, + onFailure = { e -> + logger.error("查询 USDC.e 余额失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("查询 USDC.e 余额异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } + } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt index 87ea5b5..4bfc122 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt @@ -267,7 +267,7 @@ class CryptoTailStrategyController( "当前周期已下单" -> ErrorCode.PARAM_ERROR "价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR "数量不能少于 1" -> ErrorCode.PARAM_ERROR - "总金额不能少于 1 USDC" -> ErrorCode.PARAM_ERROR + "总金额不能少于 $1" -> ErrorCode.PARAM_ERROR "总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR else -> ErrorCode.SERVER_ERROR } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt index 8ab6a91..dd89010 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt @@ -365,14 +365,14 @@ class AccountService( } /** - * Polymarket 代币批准检查:USDC.e 需授权的 spender 合约地址(Polygon 主网) + * Polymarket 代币批准检查:pUSD 需授权的 spender 合约地址(Polygon 主网) * 来源:Polymarket/magic-safe-builder-example README §6 Token Approvals * 及 neg-risk-ctf-adapter 仓库 addresses.json (chainId 137) */ private val setupApprovalSpenders = mapOf( "CTF_CONTRACT" to "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", // Conditional Tokens - "CTF_EXCHANGE" to "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E", // 普通市场交易所 - "NEG_RISK_EXCHANGE" to "0xC5d563A36AE78145C45a50134d48A1215220f80a", // 负风险市场交易所 + "CTF_EXCHANGE" to "0xE111180000d2663C0091e4f400237545B87B996B", // 普通市场交易所 + "NEG_RISK_EXCHANGE" to "0xe2222d279d744050d28e00520010520000310F59", // 负风险市场交易所 "NEG_RISK_ADAPTER" to "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" // 负风险适配器(非 WCOL 地址) ) @@ -941,7 +941,7 @@ class AccountService( } /** - * 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包为 USDC.e。 + * 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包。 * 由 WcolUnwrapJobService 每 20 秒调用,赎回后无需在赎回流程内等待确认与解包。 */ suspend fun runWcolUnwrapForAllAccounts() { @@ -1244,22 +1244,9 @@ class AccountService( else -> "GTC" } - // GTC 和 FOK 订单的 expiration 必须为 "0" - // 只有 GTD 订单才需要设置具体的过期时间 - val expiration = "0" - // 7. 解密私钥 val decryptedPrivateKey = decryptPrivateKey(account) - // 获取费率(根据 Polymarket Maker Rebates Program 要求) - val feeRateResult = clobService.getFeeRate(tokenId) - val feeRateBps = if (feeRateResult.isSuccess) { - feeRateResult.getOrNull()?.toString() ?: "0" - } else { - logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}") - "0" - } - // 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType) val signedOrder = try { orderSigningService.createAndSignOrder( @@ -1269,10 +1256,7 @@ class AccountService( side = "SELL", price = sellPrice, size = sellQuantity.toPlainString(), // 使用计算后的卖出数量 - signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType), - nonce = "0", - feeRateBps = feeRateBps, // 使用动态获取的费率 - expiration = expiration + signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType) ) } catch (e: Exception) { logger.error("创建并签名订单失败", e) @@ -1284,8 +1268,7 @@ class AccountService( val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest( order = signedOrder, owner = account.apiKey, // API Key - orderType = orderType, - deferExec = false + orderType = orderType ) // 13. 解密 API 凭证并使用账户的API凭证创建订单 @@ -1925,6 +1908,32 @@ class AccountService( false } } + + /** + * 将账户的 USDC.e wrap 为 pUSD + */ + suspend fun wrapUsdcToPusd(accountId: Long): Result { + val account = accountRepository.findById(accountId).orElse(null) + ?: return Result.failure(IllegalArgumentException("账户不存在")) + if (account.proxyAddress.isBlank()) { + return Result.failure(IllegalStateException("账户代理地址不存在")) + } + val privateKey = cryptoUtils.decrypt(account.privateKey) + val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE) + return blockchainService.wrapUsdcToPusd(privateKey, account.proxyAddress, walletType) + } + + /** + * 查询 USDC.e 余额(用于迁移提示) + */ + suspend fun getUsdceBalance(accountId: Long): Result { + val account = accountRepository.findById(accountId).orElse(null) + ?: return Result.failure(IllegalArgumentException("账户不存在")) + if (account.proxyAddress.isBlank()) { + return Result.failure(IllegalStateException("账户代理地址不存在")) + } + return blockchainService.queryUsdceBalance(account.proxyAddress) + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/WcolUnwrapJobService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/WcolUnwrapJobService.kt index 82e4f97..39fccf9 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/WcolUnwrapJobService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/WcolUnwrapJobService.kt @@ -11,7 +11,7 @@ import org.springframework.stereotype.Service /** * WCOL 解包轮询任务 - * 每 20 秒轮询一次,遍历所有账户的代理地址:若 WCOL 余额 > 0 则解包为 USDC.e。 + * 每 20 秒轮询一次,遍历所有账户的代理地址:若 WCOL 余额 > 0 则执行解包。 * 同一时间仅允许单次执行;若上次执行未结束则本次忽略(与现有轮询逻辑一致)。 * 若未配置 Builder API Key,直接跳过本轮(解包依赖 Relayer Gasless,未配置则无法执行)。 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt index c94effc..4acf89b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt @@ -39,8 +39,8 @@ class BlockchainService( private val logger = LoggerFactory.getLogger(BlockchainService::class.java) - // USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon) - private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + // pUSD 合约地址(Polygon 主网,Polymarket 使用 Polygon) + private val usdcContractAddress = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" // Polymarket Safe 代理工厂合约地址(Polygon 主网,用于 MetaMask 用户) // 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b @@ -56,7 +56,7 @@ class BlockchainService( // ConditionalTokens 合约地址(Polygon 主网) private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" - // Neg Risk WrappedCollateral 合约地址(Polygon,解包后得 USDC.e) + // Neg Risk WrappedCollateral 合约地址(Polygon) private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2" // 空集合ID(用于计算collectionId) @@ -812,11 +812,11 @@ class BlockchainService( } /** - * 将代理钱包内的 WCOL 解包为 USDC.e(解包后转入代理地址) - * 赎回 Neg Risk 仓位后到账为 WCOL,调用此方法可转为 USDC.e 以便显示/使用。 + * 将代理钱包内的 WCOL 执行解包(解包后转入代理地址) + * 赎回 Neg Risk 仓位后到账为 WCOL,调用此方法可执行解包后续资产处理。 * * Safe 与 Magic 使用同一套逻辑:同一 [createUnwrapWcolTx] + [RelayClientService.execute]; - * Safe 走 execTransaction,Magic 走 PROXY 编码,最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount),USDC.e 转入 proxyAddress。 + * Safe 走 execTransaction,Magic 走 PROXY 编码,最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount)。 * * @param privateKey 主钱包私钥 * @param proxyAddress 代理地址(Safe 或 Magic 代理) @@ -855,6 +855,99 @@ class BlockchainService( } } + // USDC.e 合约地址(仅用于 wrap 查询) + private val usdceContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + + /** + * 查询 USDC.e 余额(用于 wrap 前检查) + */ + suspend fun queryUsdceBalance(walletAddress: String): Result { + return try { + val rpcApi = polygonRpcApi + val functionSelector = "0x70a08231" + val paddedAddress = walletAddress.removePrefix("0x").lowercase().padStart(64, '0') + val data = functionSelector + paddedAddress + val rpcRequest = JsonRpcRequest( + method = "eth_call", + params = listOf(mapOf("to" to usdceContractAddress, "data" to data), "latest") + ) + val response = rpcApi.call(rpcRequest) + if (!response.isSuccessful || response.body() == null) { + return Result.failure(Exception("RPC 请求失败")) + } + val hexBalance = response.body()!!.result?.asString ?: return Result.failure(Exception("result 为空")) + val balanceWei = BigInteger(hexBalance.removePrefix("0x"), 16) + Result.success(BigDecimal(balanceWei).divide(BigDecimal("1000000"))) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * 将 USDC.e wrap 为 pUSD + * 步骤:1) 检查 USDC.e 余额 → 2) approve CollateralOnramp → 3) wrap + */ + suspend fun wrapUsdcToPusd( + privateKey: String, + proxyAddress: String, + walletType: WalletType + ): Result { + return try { + val balanceResult = queryUsdceBalance(proxyAddress) + val balance = balanceResult.getOrElse { + logger.warn("查询 USDC.e 余额失败: ${it.message}") + return Result.failure(it) + } + if (balance <= BigDecimal.ZERO) { + return Result.success(null) + } + val wrapAmountWei = balance.movePointRight(6).toBigInteger() + logger.info("开始 wrap USDC.e → pUSD: proxy=${proxyAddress.take(10)}..., amount=$balance") + + val unlimitedAllowance = BigInteger.valueOf(2).pow(256).minus(BigInteger.ONE) + val approveTx = relayClientService.createUsdceApproveForWrapTx(unlimitedAllowance) + val wrapTx = relayClientService.createWrapToPusdTx(proxyAddress, wrapAmountWei) + if (walletType == WalletType.MAGIC) { + // MAGIC 账户走 PROXY 时,不使用 Safe MultiSend(delegatecall); + // 改为顺序执行两笔 CALL,避免内层 delegatecall 回滚导致“外层成功但业务失败”。 + val approveResult = relayClientService.execute(privateKey, proxyAddress, approveTx, walletType) + val approveHash = approveResult.getOrElse { + logger.error("USDC.e approve 失败: ${it.message}", it) + return Result.failure(it) + } + logger.info("USDC.e approve 成功: txHash=$approveHash") + + val wrapResult = relayClientService.execute(privateKey, proxyAddress, wrapTx, walletType) + wrapResult.fold( + onSuccess = { txHash -> + logger.info("USDC.e → pUSD wrap 成功: txHash=$txHash") + Result.success(txHash) + }, + onFailure = { e -> + logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e) + Result.failure(e) + } + ) + } else { + val safeTx = relayClientService.createMultiSendTx(listOf(approveTx, wrapTx)) + val executeResult = relayClientService.execute(privateKey, proxyAddress, safeTx, walletType) + executeResult.fold( + onSuccess = { txHash -> + logger.info("USDC.e → pUSD wrap 成功: txHash=$txHash") + Result.success(txHash) + }, + onFailure = { e -> + logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e) + Result.failure(e) + } + ) + } + } catch (e: Exception) { + logger.error("USDC.e → pUSD wrap 异常: ${e.message}", e) + Result.failure(e) + } + } + /** * 获取代理钱包的 nonce(用于构建 Safe 交易) */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt index a630791..08dcae8 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketClobService.kt @@ -223,15 +223,7 @@ class PolymarketClobService( } } - /** - * 创建订单(已废弃,使用 createSignedOrder 代替) - * @deprecated 使用 createSignedOrder 代替,需要签名的订单对象 - */ - @Deprecated("使用 createSignedOrder 代替") - suspend fun createOrder(request: CreateOrderRequest): Result { - return Result.failure(UnsupportedOperationException("已废弃,请使用 createSignedOrder 方法")) - } - + /** * 创建签名的订单 * 注意:此方法需要完整的订单签名逻辑,当前为占位实现 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsUtils.kt index fb22cc6..c8444c1 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsUtils.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/OnChainWsUtils.kt @@ -45,7 +45,9 @@ object OnChainWsUtils { } // 合约地址 - const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + const val PUSD_CONTRACT = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" // V2 pUSD + const val USDC_CONTRACT = PUSD_CONTRACT // 默认使用 pUSD + private val COLLATERAL_CONTRACTS = setOf(PUSD_CONTRACT.lowercase()) const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045" const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62" @@ -96,8 +98,8 @@ object OnChainWsUtils { val t0 = topics[0].lowercase() val data = log.get("data")?.asString ?: "0x" - // USDC ERC20 Transfer - if (address == USDC_CONTRACT.lowercase() && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) { + // 抵押品 ERC20 Transfer(当前仅匹配 pUSD) + if (address in COLLATERAL_CONTRACTS && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) { val from = topicToAddress(topics[1]) val to = topicToAddress(topics[2]) val value = hexToBigInt(data) @@ -149,6 +151,42 @@ object OnChainWsUtils { return Pair(erc20, erc1155) } + /** + * 通过 CLOB 交易历史获取 Leader 真实成交价(用于无 USDC 转账的 ERC1155-only 场景) + * 查询该 token 最近的成交记录,匹配 side 方向的第一条作为成交价 + * 返回 usdcRaw = price × sizeRaw + */ + private suspend fun fetchEstimatedUsdcRaw( + tokenId: String, + side: String, + sizeRaw: BigInteger, + retrofitFactory: RetrofitFactory + ): BigInteger? { + return try { + val clobApi = retrofitFactory.createClobApiWithoutAuth() + val response = clobApi.getTrades(asset_id = tokenId) + if (!response.isSuccessful || response.body() == null) { + logger.warn("CLOB 交易历史查询失败: tokenId=$tokenId, code=${response.code()}") + return null + } + val trades = response.body()!!.data + val clobSide = side.lowercase() + val matchedTrade = trades.firstOrNull { it.side.equals(clobSide, ignoreCase = true) } + if (matchedTrade == null) { + logger.warn("CLOB 交易历史中未找到匹配成交: tokenId=$tokenId, side=$clobSide, totalTrades=${trades.size}") + return null + } + val price = matchedTrade.price.toBigDecimal() + val usdcRaw = price.multiply(sizeRaw.toBigDecimal()) + .setScale(0, java.math.RoundingMode.DOWN).toBigInteger() + logger.debug("CLOB 交易历史估算: tokenId=$tokenId, side=$clobSide, tradePrice=${matchedTrade.price}, tradeSize=${matchedTrade.size}, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw") + usdcRaw + } catch (e: Exception) { + logger.warn("获取 CLOB 交易历史失败: tokenId=$tokenId, side=$side, error=${e.message}") + null + } + } + /** * 从 Transfer 日志解析交易信息 */ @@ -205,6 +243,32 @@ object OnChainWsUtils { asset = bestOutId sizeRaw = bestOutVal usdcRaw = usdcIn + } else if (bestInId != null && bestInVal > BigInteger.ZERO && bestOutId == null + && usdcOut == BigInteger.ZERO && usdcIn == BigInteger.ZERO + ) { + // BUY(无 USDC): 只收到 ERC1155 token,无 USDC 流动(CLOB 内部结算等场景) + side = "BUY" + asset = bestInId + sizeRaw = bestInVal + usdcRaw = fetchEstimatedUsdcRaw(bestInId.toString(), "BUY", bestInVal, retrofitFactory) + ?: run { + logger.warn("无法获取估算价格(ERC1155-only BUY): txHash=$txHash, tokenId=$bestInId") + return null + } + logger.debug("ERC1155-only BUY: txHash=$txHash, tokenId=$bestInId, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw") + } else if (bestOutId != null && bestOutVal > BigInteger.ZERO && bestInId == null + && usdcOut == BigInteger.ZERO && usdcIn == BigInteger.ZERO + ) { + // SELL(无 USDC): 只发出 ERC1155 token,无 USDC 流动 + side = "SELL" + asset = bestOutId + sizeRaw = bestOutVal + usdcRaw = fetchEstimatedUsdcRaw(bestOutId.toString(), "SELL", bestOutVal, retrofitFactory) + ?: run { + logger.warn("无法获取估算价格(ERC1155-only SELL): txHash=$txHash, tokenId=$bestOutId") + return null + } + logger.debug("ERC1155-only SELL: txHash=$txHash, tokenId=$bestOutId, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw") } else { // 无法判断交易方向 logger.debug("无法判断交易方向: txHash=$txHash, bestInId=$bestInId, bestInVal=$bestInVal, bestOutId=$bestOutId, bestOutVal=$bestOutVal, usdcOut=$usdcOut, usdcIn=$usdcIn") diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt index aab8c2d..d0c679e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt @@ -41,10 +41,9 @@ class OrderSigningService { return if (walletTypeEnum == com.wrbug.polymarketbot.enums.WalletType.MAGIC) 1 else 2 } - // Polygon 主网合约地址(标准 CTF Exchange) - private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E" - // Neg Risk CTF Exchange(neg risk 市场需用此合约签约,否则服务端返回 invalid signature) - private val NEG_RISK_EXCHANGE_CONTRACT = "0xC5d563A36AE78145C45a50134d48A1215220f80a" + // V2 合约地址 + private val EXCHANGE_CONTRACT = "0xE111180000d2663C0091e4f400237545B87B996B" + private val NEG_RISK_EXCHANGE_CONTRACT = "0xe2222d279d744050d28e00520010520000310F59" private val CHAIN_ID = 137L // USDC 有 6 位小数 @@ -156,8 +155,8 @@ class OrderSigningService { } /** - * 创建并签名订单 - * + * 创建并签名订单 (V2) + * * @param privateKey 私钥(十六进制字符串) * @param makerAddress maker 地址(funder,通常是 proxyAddress) * @param tokenId token ID @@ -165,9 +164,6 @@ class OrderSigningService { * @param price 价格 * @param size 数量 * @param signatureType 签名类型(1: Email/Magic, 2: Browser Wallet, 0: EOA) - * @param nonce nonce(默认 "0") - * @param feeRateBps 费率基点(默认 "0") - * @param expiration 过期时间戳(秒,0 表示永不过期) * @param exchangeContract 签约用 exchange 合约地址;null 时用标准 CTF Exchange,neg risk 市场需传 Neg Risk Exchange * @return 签名的订单对象 */ @@ -178,10 +174,7 @@ class OrderSigningService { side: String, price: String, size: String, - signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致) - nonce: String = "0", - feeRateBps: String = "0", - expiration: String = "0", + signatureType: Int = 2, exchangeContract: String? = null ): SignedOrderObject { try { @@ -189,33 +182,32 @@ class OrderSigningService { val cleanPrivateKey = privateKey.removePrefix("0x") val privateKeyBigInt = BigInteger(cleanPrivateKey, 16) val credentials = Credentials.create(privateKeyBigInt.toString(16)) - // 统一转换为小写,确保与 EIP-712 编码时使用的地址格式一致 - // EIP-712 编码时地址会被转换为小写,所以订单对象中的地址也应该是小写 val signerAddress = credentials.address.lowercase() - + // 2. 计算订单金额 val amounts = calculateOrderAmounts(side, size, price) - - // 3. 生成 salt(使用时间戳,毫秒) + + // 3. 生成 salt 和 timestamp(V2: timestamp 替代 nonce 保证唯一性) val salt = generateSalt() - - // 4. taker 地址(默认使用零地址) - val taker = "0x0000000000000000000000000000000000000000" - + val timestamp = System.currentTimeMillis().toString() + + // 4. V2 字段默认值 + val metadata = "0x0000000000000000000000000000000000000000000000000000000000000000" + val builder = "0x0000000000000000000000000000000000000000000000000000000000000000" + // 5. 确保 maker 地址也是小写格式 val makerAddressLower = makerAddress.lowercase() - - // 打印签名前的订单参数(DEBUG 级别,避免敏感信息泄露) - logger.debug("========== 订单签名前参数 ==========") + + logger.debug("========== 订单签名前参数 (V2) ==========") logger.debug("订单方向: $side, 价格: $price, 数量: $size") logger.debug("Token ID: $tokenId") logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}") logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}") logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}") - logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps") + logger.debug("Salt: $salt, Timestamp: $timestamp") logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID") - - // 6. 构建订单数据并签名(neg risk 市场需用 NEG_RISK_EXCHANGE_CONTRACT) + + // 6. 构建订单数据并签名 val contract = exchangeContract?.takeIf { it.isNotBlank() } ?: EXCHANGE_CONTRACT val signature = signOrder( privateKey = privateKey, @@ -224,44 +216,41 @@ class OrderSigningService { salt = salt, maker = makerAddressLower, signer = signerAddress, - taker = taker, tokenId = tokenId, makerAmount = amounts.makerAmount, takerAmount = amounts.takerAmount, - expiration = expiration, - nonce = nonce, - feeRateBps = feeRateBps, side = side.uppercase(), - signatureType = signatureType + signatureType = signatureType, + timestamp = timestamp, + metadata = metadata, + builder = builder ) - - // 7. 创建签名的订单对象 - // 注意:所有地址字段都使用小写格式,确保与签名时使用的地址一致 + + // 7. 创建 V2 签名订单对象 return SignedOrderObject( salt = salt, maker = makerAddressLower, signer = signerAddress, - taker = taker, + taker = "0x0000000000000000000000000000000000000000", tokenId = tokenId, makerAmount = amounts.makerAmount, takerAmount = amounts.takerAmount, - expiration = expiration, - nonce = nonce, - feeRateBps = feeRateBps, side = side.uppercase(), signatureType = signatureType, + timestamp = timestamp, + expiration = "0", + metadata = metadata, + builder = builder, signature = signature ) } catch (e: Exception) { - logger.error("创建并签名订单失败", e) - throw RuntimeException("创建并签名订单失败: ${e.message}", e) + logger.error("创建并签名订单失败 (V2)", e) + throw RuntimeException("创建并签名订单失败 (V2): ${e.message}", e) } } /** - * 签名订单(EIP-712) - * - * 参考: @polymarket/order-utils 的 ExchangeOrderBuilder + * 签名订单 V2(EIP-712) */ private fun signOrder( privateKey: String, @@ -270,56 +259,47 @@ class OrderSigningService { salt: Long, maker: String, signer: String, - taker: String, tokenId: String, makerAmount: String, takerAmount: String, - expiration: String, - nonce: String, - feeRateBps: String, side: String, - signatureType: Int + signatureType: Int, + timestamp: String, + metadata: String, + builder: String ): String { try { - // 1. 私钥与密钥对 val cleanPrivateKey = privateKey.removePrefix("0x") val privateKeyBigInt = BigInteger(cleanPrivateKey, 16) val credentials = Credentials.create(privateKeyBigInt.toString(16)) val ecKeyPair = credentials.ecKeyPair - // 2. 编码域分隔符(verifyingContract 显式小写,与 EIP-712 约定一致) val domainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeDomain( chainId = chainId, verifyingContract = exchangeContract.lowercase() ) - // 3. 编码订单消息哈希 - // signatureType:1 = POLY_PROXY (Magic), 2 = POLY_GNOSIS_SAFE (Safe), 0 = EOA val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder( salt = salt, maker = maker, signer = signer, - taker = taker, tokenId = tokenId, makerAmount = makerAmount, takerAmount = takerAmount, - expiration = expiration, - nonce = nonce, - feeRateBps = feeRateBps, side = side, - signatureType = signatureType + signatureType = signatureType, + timestamp = timestamp, + metadata = metadata, + builder = builder ) - // 4. 计算完整 EIP-712 结构化数据哈希 val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData( domainSeparator = domainSeparator, messageHash = orderHash ) - // 5. 使用私钥签名(needToHash=false,对 32 字节 hash 直接签名) val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false) - // 6. 组合 r + s + v val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0') val vBytes = signature.v @@ -328,8 +308,8 @@ class OrderSigningService { return "0x$rHex$sHex$vHex" } catch (e: Exception) { - logger.error("订单签名失败", e) - throw RuntimeException("订单签名失败: ${e.message}", e) + logger.error("订单签名失败 (V2)", e) + throw RuntimeException("订单签名失败 (V2): ${e.message}", e) } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt index cfec395..a9a719b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt @@ -562,16 +562,7 @@ open class CopyOrderTrackingService( // 解密私钥 val decryptedPrivateKey = decryptPrivateKey(account) - // 获取费率(根据 Polymarket Maker Rebates Program 要求) - val feeRateResult = clobService.getFeeRate(tokenId) - val feeRateBps = if (feeRateResult.isSuccess) { - feeRateResult.getOrNull()?.toString() ?: "0" - } else { - logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}") - "0" - } - - logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity, baseFee=$feeRateBps") + logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity") // Neg Risk 市场需用 Neg Risk Exchange 签约,否则服务端返回 invalid signature val negRisk = marketService.getNegRiskByConditionId(effectiveMarketId) == true @@ -594,7 +585,6 @@ open class CopyOrderTrackingService( owner = account.apiKey, copyTradingId = copyTrading.id!!, tradeId = trade.id, - feeRateBps = feeRateBps, signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType) ) @@ -1018,15 +1008,6 @@ open class CopyOrderTrackingService( // 8. 解密私钥(在方法开始时解密一次,后续复用) val decryptedPrivateKey = decryptPrivateKey(account) - // 获取费率(根据 Polymarket Maker Rebates Program 要求) - val feeRateResult = clobService.getFeeRate(tokenId) - val feeRateBps = if (feeRateResult.isSuccess) { - feeRateResult.getOrNull()?.toString() ?: "0" - } else { - logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}") - "0" - } - // 9. Neg Risk 市场需用 Neg Risk Exchange 签约 val negRiskSell = marketService.getNegRiskByConditionId(leaderSellTrade.market) == true val exchangeContractSell = orderSigningService.getExchangeContract(negRiskSell) @@ -1042,9 +1023,6 @@ open class CopyOrderTrackingService( price = sellPrice.toString(), size = totalMatched.toString(), signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType), - nonce = "0", - feeRateBps = feeRateBps, // 使用动态获取的费率 - expiration = "0", exchangeContract = exchangeContractSell ) } catch (e: Exception) { @@ -1058,8 +1036,7 @@ open class CopyOrderTrackingService( val orderRequest = NewOrderRequest( order = signedOrder, owner = account.apiKey, - orderType = "FAK", // Fill-And-Kill - deferExec = false + orderType = "FAK" // Fill-And-Kill ) // 12. 创建带认证的CLOB API客户端(使用解密后的凭证) @@ -1084,7 +1061,6 @@ open class CopyOrderTrackingService( owner = account.apiKey, copyTradingId = copyTrading.id, tradeId = leaderSellTrade.id, - feeRateBps = feeRateBps, signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType) ) @@ -1179,7 +1155,6 @@ open class CopyOrderTrackingService( * @param owner API Key(用于owner字段) * @param copyTradingId 跟单配置ID(用于日志) * @param tradeId Leader 交易ID(用于日志) - * @param feeRateBps 费率基点(从API动态获取) * @param signatureType 签名类型(1=Magic, 2=Safe) * @return 成功返回订单ID,失败返回异常 */ @@ -1196,7 +1171,6 @@ open class CopyOrderTrackingService( owner: String, copyTradingId: Long, tradeId: String, - feeRateBps: String, signatureType: Int ): Result { var lastError: Exception? = null @@ -1213,9 +1187,6 @@ open class CopyOrderTrackingService( price = price, size = size, signatureType = signatureType, - nonce = "0", - feeRateBps = feeRateBps, // 使用动态获取的费率 - expiration = "0", exchangeContract = exchangeContract ) @@ -1232,8 +1203,7 @@ open class CopyOrderTrackingService( val orderRequest = NewOrderRequest( order = signedOrder, owner = owner, - orderType = "FAK", // Fill-And-Kill - deferExec = false + orderType = "FAK" // Fill-And-Kill ) // 调用 API 创建订单 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt index 46fe3fb..31289b5 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt @@ -815,9 +815,11 @@ class OrderStatusUpdateService( val actualPrice = orderDetail.price?.toSafeBigDecimal() ?: order.price val actualSize = orderDetail.originalSize?.toSafeBigDecimal() ?: order.quantity val actualOutcome = orderDetail.outcome + // 使用交易所订单的实际创建时间(API返回秒级,转为毫秒) + val actualCreatedAt = if (orderDetail.createdAt > 0) orderDetail.createdAt * 1000 else order.createdAt // 更新订单数据(如果实际数据与临时数据不同) - val needUpdate = actualPrice != order.price || actualSize != order.quantity + val needUpdate = actualPrice != order.price || actualSize != order.quantity || actualCreatedAt != order.createdAt // 先保存更新后的订单,标记 notificationSent = true // 这样可以防止其他并发任务重复发送通知 @@ -838,7 +840,7 @@ class OrderStatusUpdateService( status = order.status, notificationSent = true, // 标记为已发送通知 source = order.source, // 保留原始订单来源 - createdAt = order.createdAt, + createdAt = actualCreatedAt, updatedAt = System.currentTimeMillis() ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt index 31b514d..09414de 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt @@ -61,7 +61,6 @@ private data class PeriodContext( val apiSecretDecrypted: String, val apiPassphraseDecrypted: String, val clobApi: PolymarketClobApi, - val feeRateByTokenId: Map, val signatureType: Int, val tokenIds: List, val marketTitle: String? @@ -152,9 +151,6 @@ class CryptoTailStrategyExecutionService( } val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress) - val feeRateByTokenId = tokenIds.associate { tokenId -> - tokenId to (clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0") - } val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType) if (strategy.amountMode.uppercase() != "RATIO" && strategy.amountValue < MIN_ORDER_USDC) return null @@ -167,7 +163,6 @@ class CryptoTailStrategyExecutionService( apiSecretDecrypted = apiSecret, apiPassphraseDecrypted = apiPassphrase, clobApi = clobApi, - feeRateByTokenId = feeRateByTokenId, signatureType = signatureType, tokenIds = tokenIds, marketTitle = marketTitle @@ -397,7 +392,6 @@ class CryptoTailStrategyExecutionService( } val priceStr = price.toPlainString() val size = computeSize(amountUsdc, price) - val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0" val signedOrder = orderSigningService.createAndSignOrder( privateKey = ctx.decryptedPrivateKey, makerAddress = ctx.account.proxyAddress, @@ -405,16 +399,12 @@ class CryptoTailStrategyExecutionService( side = "BUY", price = priceStr, size = size, - signatureType = ctx.signatureType, - nonce = "0", - feeRateBps = feeRateBps, - expiration = "0" + signatureType = ctx.signatureType ) val orderRequest = NewOrderRequest( order = signedOrder, owner = ctx.account.apiKey!!, - orderType = "FAK", - deferExec = false + orderType = "FAK" ) submitOrderAndSaveRecord( ctx.clobApi, @@ -609,7 +599,6 @@ class CryptoTailStrategyExecutionService( "" } val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress) - val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0" val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType) val signedOrder = orderSigningService.createAndSignOrder( @@ -619,16 +608,12 @@ class CryptoTailStrategyExecutionService( side = "BUY", price = priceStr, size = size, - signatureType = signatureType, - nonce = "0", - feeRateBps = feeRateBps, - expiration = "0" + signatureType = signatureType ) val orderRequest = NewOrderRequest( order = signedOrder, owner = account.apiKey!!, - orderType = "FAK", - deferExec = false + orderType = "FAK" ) submitOrderAndSaveRecord( clobApi, @@ -717,7 +702,7 @@ class CryptoTailStrategyExecutionService( val amountUsdc = priceRounded.multi(size).setScale(2, RoundingMode.HALF_UP) if (amountUsdc < BigDecimal.ONE) { - return Result.failure(IllegalArgumentException("总金额不能少于 1 USDC")) + return Result.failure(IllegalArgumentException("总金额不能少于 \$1")) } val mutex = getTriggerMutex(strategy.id!!, request.periodStartUnix) @@ -745,7 +730,6 @@ class CryptoTailStrategyExecutionService( val priceStr = priceRounded.toPlainString() val sizeStr = size.toPlainString() - val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0" val signedOrder = orderSigningService.createAndSignOrder( privateKey = ctx.decryptedPrivateKey, @@ -754,17 +738,13 @@ class CryptoTailStrategyExecutionService( side = "BUY", price = priceStr, size = sizeStr, - signatureType = ctx.signatureType, - nonce = "0", - feeRateBps = feeRateBps, - expiration = "0" + signatureType = ctx.signatureType ) val orderRequest = NewOrderRequest( order = signedOrder, owner = ctx.account.apiKey!!, - orderType = "FAK", - deferExec = false + orderType = "FAK" ) val orderResult = submitOrderForManualOrder( diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/NotificationTemplateService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/NotificationTemplateService.kt index 28f95f1..6c43b0c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/NotificationTemplateService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/NotificationTemplateService.kt @@ -169,9 +169,9 @@ class NotificationTemplateService( • 方向: {{side}} • 价格: {{price}} • 数量: {{quantity}} shares -• 金额: {{amount}} USDC +• 金额: ${'$'}{{amount}} • 账户: {{account_name}} -• 可用余额: {{available_balance}} USDC +• 可用余额: ${'$'}{{available_balance}} ⏰ 时间: {{time}} """.trimIndent(), @@ -184,7 +184,7 @@ class NotificationTemplateService( • 方向: {{side}} • 价格: {{price}} • 数量: {{quantity}} shares -• 金额: {{amount}} USDC +• 金额: ${'$'}{{amount}} • 账户: {{account_name}} ⚠️ 错误信息: @@ -201,7 +201,7 @@ class NotificationTemplateService( • 方向: {{side}} • 价格: {{price}} • 数量: {{quantity}} shares -• 金额: {{amount}} USDC +• 金额: ${'$'}{{amount}} • 账户: {{account_name}} ⚠️ 过滤类型: {{filter_type}} @@ -222,7 +222,7 @@ class NotificationTemplateService( • 方向: {{side}} • 价格: {{price}} • 数量: {{quantity}} shares -• 金额: {{amount}} USDC +• 金额: ${'$'}{{amount}} • 账户: {{account_name}} ⏰ 时间: {{time}} @@ -233,8 +233,8 @@ class NotificationTemplateService( 📊 赎回信息: • 账户: {{account_name}} • 交易哈希: {{transaction_hash}} -• 赎回总价值: {{total_value}} USDC -• 可用余额: {{available_balance}} USDC +• 赎回总价值: ${'$'}{{total_value}} +• 可用余额: ${'$'}{{available_balance}} ⏰ 时间: {{time}} """.trimIndent(), @@ -246,7 +246,7 @@ class NotificationTemplateService( • 账户: {{account_name}} • 交易哈希: {{transaction_hash}} -• 可用余额: {{available_balance}} USDC +• 可用余额: ${'$'}{{available_balance}} ⏰ 时间: {{time}} """.trimIndent() diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt index 107e624..0e7d5b8 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt @@ -39,8 +39,14 @@ class RelayClientService( // ConditionalTokens 合约地址 private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" - // USDC.e 合约地址(普通市场抵押品) - private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + // pUSD 合约地址(普通市场抵押品) + private val usdcContractAddress = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" + + // USDC.e 合约地址(仅用于 wrap 到 pUSD) + private val usdceContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + + // CollateralOnramp 合约地址(USDC.e → pUSD) + private val collateralOnrampAddress = "0x93070a847efEf7F70739046A929D47a521F5B8ee" // Neg Risk 市场使用的 WrappedCollateral 合约地址(Polygon,neg-risk-ctf-adapter) private val negRiskWrappedCollateralAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2" @@ -51,7 +57,9 @@ class RelayClientService( // Polygon PROXY(Magic)合约地址,参考 builder-relayer-client config private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052" private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494" - private val defaultProxyGasLimit = "10000000" + // PROXY relayCall 内层 gasLimit(签名参数)不能给过大值,否则 RelayHub 会因 gasleft 校验失败回滚。 + private val defaultProxyGasLimit = "2400000" + private val maxProxyGasLimit = BigInteger.valueOf(2400000) // Safe MultiSend 合约地址(Polygon 主网) private val safeMultisendAddress = "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761" @@ -282,14 +290,14 @@ class RelayClientService( } /** - * 创建 WCOL 解包交易(将 Wrapped Collateral 解包为 USDC.e) + * 创建 WCOL 解包交易(将 Wrapped Collateral 执行解包) * 合约: Neg Risk WrappedCollateral 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2 - * 方法: unwrap(address _to, uint256 _amount),解包后 USDC.e 转到 _to + * 方法: unwrap(address _to, uint256 _amount) * * Safe 与 Magic 共用此交易对象:Safe 走 [executeViaBuilderRelayer] / [executeManually](execTransaction), * Magic 走 [executeViaBuilderRelayerProxy](encodeProxyTransactionData),语义一致。 * - * @param toAddress 接收 USDC.e 的地址(通常为 proxy 自身,使余额留在代理钱包) + * @param toAddress 接收解包资产的地址(通常为 proxy 自身,使余额留在代理钱包) * @param amountWei WCOL 数量(6 位小数对应的 raw 值,与 balanceOf 返回一致) * @return Safe 交易对象 */ @@ -323,6 +331,41 @@ class RelayClientService( ) } + /** + * 创建 USDC.e approve 交易(用于 wrap 到 pUSD) + * 授权 CollateralOnramp 合约花费用户的 USDC.e + */ + fun createUsdceApproveForWrapTx(amount: BigInteger): SafeTransaction { + val functionSelector = EthereumUtils.getFunctionSelector("approve(address,uint256)") + val encodedSpender = EthereumUtils.encodeAddress(collateralOnrampAddress) + val encodedAmount = EthereumUtils.encodeUint256(amount) + val callData = "0x" + functionSelector.removePrefix("0x") + encodedSpender + encodedAmount + return SafeTransaction( + to = usdceContractAddress, + operation = 0, + data = callData, + value = "0" + ) + } + + /** + * 创建 USDC.e → pUSD wrap 交易 + * CollateralOnramp.wrap(address _asset, address _to, uint256 _amount) + */ + fun createWrapToPusdTx(recipientAddress: String, amount: BigInteger): SafeTransaction { + val functionSelector = EthereumUtils.getFunctionSelector("wrap(address,address,uint256)") + val asset = EthereumUtils.encodeAddress(usdceContractAddress) + val to = EthereumUtils.encodeAddress(recipientAddress) + val amt = EthereumUtils.encodeUint256(amount) + val callData = "0x" + functionSelector.removePrefix("0x") + asset + to + amt + return SafeTransaction( + to = collateralOnrampAddress, + operation = 0, + data = callData, + value = "0" + ) + } + /** * 创建 MultiSend 交易(合并多个 SafeTransaction 为一笔交易) * 参考 TypeScript: builder-relayer-client/src/encode/safe.ts createSafeMultisendTransaction @@ -489,7 +532,16 @@ class RelayClientService( // 估算 gas limit(参考 builder-relayer-client builder/proxy.ts getGasLimit) val gasLimit = try { - estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData) + val estimatedGasLimit = estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData) + val estimatedBigInt = BigInteger(estimatedGasLimit) + if (estimatedBigInt > maxProxyGasLimit) { + logger.warn( + "估算 PROXY gas limit 过大,进行截断: estimated=$estimatedGasLimit, capped=$maxProxyGasLimit" + ) + maxProxyGasLimit.toString() + } else { + estimatedGasLimit + } } catch (e: Exception) { logger.warn("估算 PROXY gas limit 失败,使用默认值: ${e.message}", e) defaultProxyGasLimit diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt index c473a88..64bd282 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt @@ -691,7 +691,7 @@ class TelegramNotificationService( • $sideLabel: $sideDisplay • $priceLabel: $priceDisplay • $quantityLabel: $sizeDisplay shares -• $amountLabel: $amountDisplay USDC +• $amountLabel: ${'$'}$amountDisplay • $accountLabel: $escapedAccountInfo ⚠️ $filterTypeLabel: $filterTypeDisplay @@ -1169,9 +1169,9 @@ class TelegramNotificationService( } else { balanceDecimal.stripTrailingZeros() } - "\n• $availableBalanceLabel: ${formatted.toPlainString()} USDC" + "\n• $availableBalanceLabel: ${'$'}${formatted.toPlainString()}" } catch (e: Exception) { - "\n• $availableBalanceLabel: $availableBalance USDC" + "\n• $availableBalanceLabel: ${'$'}$availableBalance" } } else { "" @@ -1185,7 +1185,7 @@ class TelegramNotificationService( • $sideLabel: $sideDisplay • $priceLabel: $priceDisplay • $quantityLabel: $sizeDisplay shares -• $amountLabel: $amountDisplay USDC +• $amountLabel: ${'$'}$amountDisplay • $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo$availableBalanceDisplay ⏰ $timeLabel: $time""" @@ -1264,7 +1264,7 @@ class TelegramNotificationService( • $sideLabel: $sideDisplay • $priceLabel: $priceDisplay • $quantityLabel: $sizeDisplay shares -• $amountLabel: $amountDisplay USDC +• $amountLabel: ${'$'}$amountDisplay • $accountLabel: $escapedAccountInfo ⏰ $timeLabel: $time""" @@ -1381,7 +1381,7 @@ class TelegramNotificationService( • $sideLabel: $sideDisplay • $priceLabel: $priceDisplay • $quantityLabel: $sizeDisplay shares -• $amountLabel: $amountDisplay USDC +• $amountLabel: ${'$'}$amountDisplay • $accountLabel: $escapedAccountInfo ⚠️ $errorInfo: @@ -1515,7 +1515,7 @@ class TelegramNotificationService( } catch (e: Exception) { position.value } - " • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC" + " • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = ${'$'}$valueDisplay" } // 格式化可用余额 @@ -1527,9 +1527,9 @@ class TelegramNotificationService( } else { balanceDecimal.stripTrailingZeros() } - "\n• $availableBalanceLabel: ${formatted.toPlainString()} USDC" + "\n• $availableBalanceLabel: ${'$'}${formatted.toPlainString()}" } catch (e: Exception) { - "\n• $availableBalanceLabel: $availableBalance USDC" + "\n• $availableBalanceLabel: ${'$'}$availableBalance" } } else { "" @@ -1540,7 +1540,7 @@ class TelegramNotificationService( 📊 $redeemInfo: • $accountLabel: $escapedAccountInfo • $transactionHashLabel: $escapedTxHash -• $totalValueLabel: $totalValueDisplay USDC$availableBalanceDisplay +• $totalValueLabel: ${'$'}$totalValueDisplay$availableBalanceDisplay 📦 $positionsLabel: $positionsText @@ -1642,9 +1642,9 @@ $positionsText } else { balanceDecimal.stripTrailingZeros() } - "\n• $availableBalanceLabel: ${formatted.toPlainString()} USDC" + "\n• $availableBalanceLabel: ${'$'}${formatted.toPlainString()}" } catch (e: Exception) { - "\n• $availableBalanceLabel: $availableBalance USDC" + "\n• $availableBalanceLabel: ${'$'}$availableBalance" } } else { "" diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt index 97c4dd8..9838198 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt @@ -167,9 +167,8 @@ object Eip712Encoder { } /** - * 编码 ExchangeOrder 域分隔符 - * 参考: @polymarket/order-utils 的 ExchangeOrderBuilder - * Domain: { name: "Polymarket CTF Exchange", version: "1", chainId: chainId, verifyingContract: exchangeContract } + * 编码 ExchangeOrder V2 域分隔符 + * Domain: { name: "Polymarket CTF Exchange", version: "2", chainId: chainId, verifyingContract: exchangeContract } */ fun encodeExchangeDomain( chainId: Long, @@ -184,9 +183,9 @@ object Eip712Encoder { "verifyingContract" to "address" ) ) - + val nameHash = encodeString("Polymarket CTF Exchange") - val versionHash = encodeString("1") + val versionHash = encodeString("2") val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId)) val contractBytes = encodeAddress(verifyingContract) @@ -201,23 +200,21 @@ object Eip712Encoder { } /** - * 编码 ExchangeOrder 消息哈希 - * 参考: @polymarket/order-utils 的 ExchangeOrderBuilder - * Order: { salt, maker, signer, taker, tokenId, makerAmount, takerAmount, expiration, nonce, feeRateBps, side, signatureType } + * 编码 ExchangeOrder V2 消息哈希 + * V2 Order: { salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp, metadata, builder } */ fun encodeExchangeOrder( salt: Long, maker: String, signer: String, - taker: String, tokenId: String, makerAmount: String, takerAmount: String, - expiration: String, - nonce: String, - feeRateBps: String, side: String, - signatureType: Int + signatureType: Int, + timestamp: String, + metadata: String, + builder: String ): ByteArray { val orderTypeHash = encodeType( "Order", @@ -225,57 +222,51 @@ object Eip712Encoder { "salt" to "uint256", "maker" to "address", "signer" to "address", - "taker" to "address", "tokenId" to "uint256", "makerAmount" to "uint256", "takerAmount" to "uint256", - "expiration" to "uint256", - "nonce" to "uint256", - "feeRateBps" to "uint256", "side" to "uint8", - "signatureType" to "uint8" + "signatureType" to "uint8", + "timestamp" to "uint256", + "metadata" to "bytes32", + "builder" to "bytes32" ) ) - - // 编码订单字段 + val saltBytes = encodeUint256(BigInteger.valueOf(salt)) val makerBytes = encodeAddress(maker) val signerBytes = encodeAddress(signer) - val takerBytes = encodeAddress(taker) val tokenIdBytes = encodeUint256(BigInteger(tokenId)) val makerAmountBytes = encodeUint256(BigInteger(makerAmount)) val takerAmountBytes = encodeUint256(BigInteger(takerAmount)) - val expirationBytes = encodeUint256(BigInteger(expiration)) - val nonceBytes = encodeUint256(BigInteger(nonce)) - val feeRateBpsBytes = encodeUint256(BigInteger(feeRateBps)) - - // side: BUY = 0, SELL = 1 (uint8,但需要编码为 32 字节) + val sideValue = when (side.uppercase()) { "BUY" -> 0 "SELL" -> 1 else -> throw IllegalArgumentException("side 必须是 BUY 或 SELL") } - // uint8 类型,但 EIP-712 编码时仍需要 32 字节 val sideBytes = encodeUint256(BigInteger.valueOf(sideValue.toLong())) val signatureTypeBytes = encodeUint256(BigInteger.valueOf(signatureType.toLong())) - - // 组合所有字段 - val encoded = ByteArray(32 * 13) // 13 个字段,每个 32 字节 + + val timestampBytes = encodeUint256(BigInteger(timestamp)) + val metadataBytes = Numeric.hexStringToByteArray(metadata.removePrefix("0x").padStart(64, '0')) + val builderBytes = Numeric.hexStringToByteArray(builder.removePrefix("0x").padStart(64, '0')) + + val encoded = ByteArray(32 * 12) // typeHash + 11 个字段 var offset = 0 System.arraycopy(orderTypeHash, 0, encoded, offset, 32); offset += 32 System.arraycopy(saltBytes, 0, encoded, offset, 32); offset += 32 System.arraycopy(makerBytes, 0, encoded, offset, 32); offset += 32 System.arraycopy(signerBytes, 0, encoded, offset, 32); offset += 32 - System.arraycopy(takerBytes, 0, encoded, offset, 32); offset += 32 System.arraycopy(tokenIdBytes, 0, encoded, offset, 32); offset += 32 System.arraycopy(makerAmountBytes, 0, encoded, offset, 32); offset += 32 System.arraycopy(takerAmountBytes, 0, encoded, offset, 32); offset += 32 - System.arraycopy(expirationBytes, 0, encoded, offset, 32); offset += 32 - System.arraycopy(nonceBytes, 0, encoded, offset, 32); offset += 32 - System.arraycopy(feeRateBpsBytes, 0, encoded, offset, 32); offset += 32 System.arraycopy(sideBytes, 0, encoded, offset, 32); offset += 32 - System.arraycopy(signatureTypeBytes, 0, encoded, offset, 32) - + System.arraycopy(signatureTypeBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(timestampBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(metadataBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(builderBytes, 0, encoded, offset, 32) + return keccak256(encoded) } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt index f4fc82d..a8c4ffa 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt @@ -10,7 +10,7 @@ import java.math.BigInteger object EthereumUtils { // Polymarket 合约地址(Polygon 主网) - private val COLLATERAL_TOKEN_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" // USDC + private val COLLATERAL_TOKEN_ADDRESS = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" // pUSD private val CONDITIONAL_TOKENS_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" // ConditionalTokens /** diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index caeaf1e..ae16f14 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,6 +41,7 @@ import { wsManager } from './services/websocket' import type { OrderPushMessage } from './types' import { apiService } from './services/api' import { hasToken } from './utils' +import ClobMigrationModal, { STORAGE_KEY as CLOB_MIGRATION_KEY } from './components/ClobMigrationModal' /** * 路由保护组件 @@ -64,6 +65,7 @@ function App() { const { t, i18n } = useTranslation() const [isFirstUse, setIsFirstUse] = useState(null) const [checking, setChecking] = useState(true) + const [clobMigrationVisible, setClobMigrationVisible] = useState(false) // 根据当前语言设置 Ant Design 的 locale const getAntdLocale = () => { @@ -199,6 +201,13 @@ function App() { wsManager.disconnect() } }, [checking, isFirstUse]) + + // 已登录且未查看过 CLOB V2 迁移提醒时显示弹窗 + useEffect(() => { + if (!checking && isFirstUse === false && hasToken() && !localStorage.getItem(CLOB_MIGRATION_KEY)) { + setClobMigrationVisible(true) + } + }, [checking, isFirstUse]) // 订阅订单推送并显示全局通知 useEffect(() => { @@ -284,6 +293,7 @@ function App() { {/* 默认重定向到登录页 */} } /> + setClobMigrationVisible(false)} /> ) diff --git a/frontend/src/components/AccountImportForm.tsx b/frontend/src/components/AccountImportForm.tsx index 265283a..464f8ad 100644 --- a/frontend/src/components/AccountImportForm.tsx +++ b/frontend/src/components/AccountImportForm.tsx @@ -525,7 +525,7 @@ const AccountImportForm: React.FC = ({ {!option.error && ( - {formatUSDC(option.totalBalance)} USDC + ${formatUSDC(option.totalBalance)} )} diff --git a/frontend/src/components/AccountSetupStatusBlock.tsx b/frontend/src/components/AccountSetupStatusBlock.tsx index 49b92dd..67f1315 100644 --- a/frontend/src/components/AccountSetupStatusBlock.tsx +++ b/frontend/src/components/AccountSetupStatusBlock.tsx @@ -247,7 +247,7 @@ const AccountSetupStatusBlock: React.FC = ({ const displayText = isUnlimited ? t('accountSetup.approvalDetails.unlimited') : isApproved - ? `${parseFloat(allowance).toFixed(2)} USDC` + ? `$${parseFloat(allowance).toFixed(2)}` : t('accountSetup.approvalDetails.notApproved') return (
void +} + +const ClobMigrationModal: React.FC = ({ open, onClose }) => { + const { t } = useTranslation() + const navigate = useNavigate() + + const handleClose = (dontRemind = false) => { + if (dontRemind) { + localStorage.setItem(STORAGE_KEY, 'true') + } + onClose() + } + + const handleGoToAccounts = () => { + localStorage.setItem(STORAGE_KEY, 'true') + onClose() + navigate('/accounts') + } + + return ( + + + {t('clobMigration.title')} +
+ } + open={open} + onCancel={() => handleClose()} + footer={ + + + + + + } + closable + maskClosable={false} + > +

+ {t('clobMigration.description')} +

+ + ) +} + +export default ClobMigrationModal +export { STORAGE_KEY } diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 48dae45..edb5e7a 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -1801,5 +1801,14 @@ "maxSizeUpdated": "Updated to max size", "periodChanged": "Period has changed, popup closed" } + }, + "clobMigration": { + "title": "CLOB 2.0 Migration Notice", + "description": "Polymarket CLOB has been upgraded to V2. Please go to the Accounts page to complete the USDC migration to ensure trading functions properly.", + "goToAccounts": "Go to Accounts", + "dontRemind": "Don't remind again", + "accountGuide": "CLOB V2 requires pUSD for trading. Click the USDC.e → pUSD button in the account actions to complete the migration.", + "accountGuideButton": "USDC.e → pUSD", + "dismissGuide": "Got it" } } \ No newline at end of file diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index a39316d..741a5f6 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1801,5 +1801,14 @@ "maxSizeUpdated": "已更新为最大数量", "periodChanged": "周期已切换,弹窗已关闭" } + }, + "clobMigration": { + "title": "CLOB 2.0 迁移提醒", + "description": "Polymarket CLOB 已升级至 V2 版本,您需要前往账户页完成 USDC 迁移操作,以确保交易功能正常使用。", + "goToAccounts": "前往账户页", + "dontRemind": "不再提醒", + "accountGuide": "CLOB V2 需要 pUSD 进行交易,请点击账户操作栏中的 USDC.e → pUSD 按钮完成迁移。", + "accountGuideButton": "USDC.e → pUSD", + "dismissGuide": "知道了" } } \ No newline at end of file diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index beebeaa..c236561 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1801,5 +1801,14 @@ "maxSizeUpdated": "已更新為最大數量", "periodChanged": "週期已切換,彈窗已關閉" } + }, + "clobMigration": { + "title": "CLOB 2.0 遷移提醒", + "description": "Polymarket CLOB 已升級至 V2 版本,您需要前往帳戶頁完成 USDC 遷移操作,以確保交易功能正常使用。", + "goToAccounts": "前往帳戶頁", + "dontRemind": "不再提醒", + "accountGuide": "CLOB V2 需要 pUSD 進行交易,請點擊帳戶操作欄中的 USDC.e → pUSD 按鈕完成遷移。", + "accountGuideButton": "USDC.e → pUSD", + "dismissGuide": "知道了" } } \ No newline at end of file diff --git a/frontend/src/pages/AccountDetail.tsx b/frontend/src/pages/AccountDetail.tsx index 4baf6af..4662074 100644 --- a/frontend/src/pages/AccountDetail.tsx +++ b/frontend/src/pages/AccountDetail.tsx @@ -202,7 +202,7 @@ const AccountDetail: React.FC = () => { ) : balance ? ( - {formatUSDC(balance)} USDC + ${formatUSDC(balance)} ) : ( - @@ -274,7 +274,7 @@ const AccountDetail: React.FC = () => { fontWeight: 'bold', color: account.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a' }}> - {formatUSDC(account.totalPnl)} USDC + ${formatUSDC(account.totalPnl)} )} diff --git a/frontend/src/pages/AccountList.tsx b/frontend/src/pages/AccountList.tsx index e9b5f7f..505878a 100644 --- a/frontend/src/pages/AccountList.tsx +++ b/frontend/src/pages/AccountList.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert, Tooltip, List, Empty } from 'antd' -import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined, EyeOutlined, DeleteOutlined, WalletOutlined } from '@ant-design/icons' +import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined, EyeOutlined, DeleteOutlined, WalletOutlined, SwapOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { useAccountStore } from '../store/accountStore' import type { Account } from '../types' @@ -8,6 +8,7 @@ import { useMediaQuery } from 'react-responsive' import { formatUSDC } from '../utils' import AccountImportForm from '../components/AccountImportForm' import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock' +import apiService from '../services/api' const { Title } = Typography @@ -27,11 +28,57 @@ const AccountList: React.FC = () => { const [editLoading, setEditLoading] = useState(false) const [accountImportModalVisible, setAccountImportModalVisible] = useState(false) const [accountImportForm] = Form.useForm() + const [wrapLoading, setWrapLoading] = useState>({}) + const [migrationGuideVisible, setMigrationGuideVisible] = useState(false) + + const ACCOUNT_GUIDE_KEY = 'clob_v2_account_guide_dismissed' + const handleWrapToPusd = async (account: Account) => { + try { + setWrapLoading(prev => ({ ...prev, [account.id]: true })) + const res = await apiService.accounts.getUsdceBalance(account.id) + if (res.data.code !== 0 || !res.data.data) { + message.error(res.data.msg || '查询 USDC.e 余额失败') + return + } + const balance = parseFloat(res.data.data.balance) + if (balance <= 0) { + message.info('USDC.e 余额为 0,无需迁移') + return + } + Modal.confirm({ + title: 'USDC.e → pUSD 迁移', + content: `检测到 ${balance.toFixed(2)} USDC.e,将全部 wrap 为 pUSD。确认继续?`, + okText: '确认迁移', + cancelText: '取消', + onOk: async () => { + const wrapRes = await apiService.accounts.wrapToPusd(account.id) + if (wrapRes.data.code === 0) { + const txHash = wrapRes.data.data?.transactionHash + message.success(txHash ? `迁移成功,交易: ${txHash.slice(0, 10)}...` : '迁移成功(无需操作)') + fetchAccountBalance(account.id) + } else { + message.error(wrapRes.data.msg || '迁移失败') + } + } + }) + } catch (e: any) { + message.error(e.message || '迁移失败') + } finally { + setWrapLoading(prev => ({ ...prev, [account.id]: false })) + } + } useEffect(() => { fetchAccounts() }, [fetchAccounts]) + // 首次进入且有账户时显示迁移引导 + useEffect(() => { + if (!loading && accounts.length > 0 && !localStorage.getItem(ACCOUNT_GUIDE_KEY)) { + setMigrationGuideVisible(true) + } + }, [loading, accounts.length]) + const handleAccountImportSuccess = async () => { message.success(t('accountImport.importSuccess')) setAccountImportModalVisible(false) @@ -325,7 +372,7 @@ const AccountList: React.FC = () => { } const balanceObj = balanceMap[record.id] const balance = balanceObj?.total || record.balance || '-' - return balance && balance !== '-' && typeof balance === 'string' ? `${formatUSDC(balance)} USDC` : '-' + return balance && balance !== '-' && typeof balance === 'string' ? `$${formatUSDC(balance)}` : '-' } }, { @@ -374,6 +421,26 @@ const AccountList: React.FC = () => { + +
handleWrapToPusd(record)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '32px', + height: '32px', + cursor: wrapLoading[record.id] ? 'wait' : 'pointer', + borderRadius: '6px', + transition: 'background-color 0.2s' + }} + onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff7e6'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + +
+
+ { + {migrationGuideVisible && !loading && accounts.length > 0 && ( + } + closable + onClose={() => { + localStorage.setItem(ACCOUNT_GUIDE_KEY, 'true') + setMigrationGuideVisible(false) + }} + afterClose={() => { + localStorage.setItem(ACCOUNT_GUIDE_KEY, 'true') + setMigrationGuideVisible(false) + }} + style={{ marginBottom: 16, ...(isMobile ? { margin: '0 8px 12px' } : {}) }} + action={ + + } + /> + )} + { {t('accountList.totalBalance')}
- {balance?.total && balance.total !== '-' ? `${formatUSDC(balance.total)} USDC` : '- USDC'} + {balance?.total && balance.total !== '-' ? `$${formatUSDC(balance.total)}` : '-'}
@@ -568,6 +667,16 @@ const AccountList: React.FC = () => {
+ +
handleWrapToPusd(account)} + style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: wrapLoading[account.id] ? 'wait' : 'pointer', padding: '4px 8px' }} + > + + {t('clobMigration.accountGuideButton')} +
+
+ { ) : detailBalance ? ( - {formatUSDC(detailBalance.total)} USDC + ${formatUSDC(detailBalance.total)} ) : ( - @@ -739,7 +848,7 @@ const AccountList: React.FC = () => { ) : detailBalance ? ( - {formatUSDC(detailBalance.available)} USDC + ${formatUSDC(detailBalance.available)} ) : ( - @@ -750,7 +859,7 @@ const AccountList: React.FC = () => { ) : detailBalance ? ( - {formatUSDC(detailBalance.position)} USDC + ${formatUSDC(detailBalance.position)} ) : ( - @@ -806,7 +915,7 @@ const AccountList: React.FC = () => { fontWeight: 'bold', color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a' }}> - {formatUSDC(detailAccount.totalPnl)} USDC + ${formatUSDC(detailAccount.totalPnl)} )} diff --git a/frontend/src/pages/BacktestChart.tsx b/frontend/src/pages/BacktestChart.tsx index 7d53a48..e7c493a 100644 --- a/frontend/src/pages/BacktestChart.tsx +++ b/frontend/src/pages/BacktestChart.tsx @@ -75,9 +75,9 @@ const BacktestChart: React.FC = ({ trades }) => { return `
${t('backtest.tradeTime')}: ${param.name}
-
${t('backtest.balanceAfter')}: ${value} USDC
+
${t('backtest.balanceAfter')}: $${value}
- ${t('backtest.profitLoss')}: ${diff} USDC (${diffPercent}%) + ${t('backtest.profitLoss')}: $${diff} (${diffPercent}%)
` @@ -121,7 +121,7 @@ const BacktestChart: React.FC = ({ trades }) => { }, yAxis: { type: 'value', - name: 'USDC', + name: '$', nameLocation: 'end', nameGap: 10, axisLabel: { diff --git a/frontend/src/pages/BacktestDetail.tsx b/frontend/src/pages/BacktestDetail.tsx index 572aebf..8584999 100644 --- a/frontend/src/pages/BacktestDetail.tsx +++ b/frontend/src/pages/BacktestDetail.tsx @@ -279,14 +279,14 @@ const BacktestDetail: React.FC = () => { render: (value: string) => parseFloat(value).toFixed(4) }, { - title: t('backtest.amount') + ' (USDC)', + title: t('backtest.amount') + ' ($)', dataIndex: 'amount', key: 'amount', width: 120, render: (value: string) => formatUSDC(value) }, { - title: t('backtest.balanceAfter') + ' (USDC)', + title: t('backtest.balanceAfter') + ' ($)', dataIndex: 'balanceAfter', key: 'balanceAfter', width: 120, @@ -346,14 +346,14 @@ const BacktestDetail: React.FC = () => { {task.leaderName || task.leaderAddress} - {formatUSDC(task.initialBalance)} USDC + ${formatUSDC(task.initialBalance)} - {task.finalBalance ? formatUSDC(task.finalBalance) + ' USDC' : '-'} + {task.finalBalance ? '$' + formatUSDC(task.finalBalance) : '-'} = 0 ? '#52c41a' : '#ff4d4f' }}> - {task.profitAmount ? formatUSDC(task.profitAmount) + ' USDC' : '-'} + {task.profitAmount ? '$' + formatUSDC(task.profitAmount) : '-'} diff --git a/frontend/src/pages/BacktestList.tsx b/frontend/src/pages/BacktestList.tsx index 4dbb142..6af99c4 100644 --- a/frontend/src/pages/BacktestList.tsx +++ b/frontend/src/pages/BacktestList.tsx @@ -461,14 +461,14 @@ const BacktestList: React.FC = () => { render: (value: string) => parseFloat(value).toFixed(4) }, { - title: t('backtest.amount') + ' (USDC)', + title: t('backtest.amount') + ' ($)', dataIndex: 'amount', key: 'amount', width: 120, render: (value: string) => formatUSDC(value) }, { - title: t('backtest.balanceAfter') + ' (USDC)', + title: t('backtest.balanceAfter') + ' ($)', dataIndex: 'balanceAfter', key: 'balanceAfter', width: 120, @@ -815,7 +815,7 @@ const BacktestList: React.FC = () => {
{t('backtest.profitAmount')}
{task.profitAmount != null ? ( -
= 0 ? '#52c41a' : '#ff4d4f' }}>{formatUSDC(task.profitAmount)} USDC
+
= 0 ? '#52c41a' : '#ff4d4f' }}>${formatUSDC(task.profitAmount)}
) : (
-
)} @@ -997,7 +997,7 @@ const BacktestList: React.FC = () => { { {copyMode === 'FIXED' && ( { @@ -1110,7 +1110,7 @@ const BacktestList: React.FC = () => { @@ -1122,7 +1122,7 @@ const BacktestList: React.FC = () => { @@ -1141,7 +1141,7 @@ const BacktestList: React.FC = () => { { {detailTask.leaderName || `Leader ${detailTask.leaderId}`} - {formatUSDC(detailTask.initialBalance)} USDC + ${formatUSDC(detailTask.initialBalance)} - {detailTask.finalBalance ? formatUSDC(detailTask.finalBalance) + ' USDC' : '-'} + {detailTask.finalBalance ? '$' + formatUSDC(detailTask.finalBalance) : '-'} = 0 ? '#52c41a' : '#ff4d4f' }}> - {detailTask.profitAmount ? formatUSDC(detailTask.profitAmount) + ' USDC' : '-'} + {detailTask.profitAmount ? '$' + formatUSDC(detailTask.profitAmount) : '-'} @@ -1439,17 +1439,17 @@ const BacktestList: React.FC = () => { {detailConfig.copyMode === 'RATIO' ? `${t('backtest.copyModeRatio')} ${parseFloat(detailConfig.copyRatio) * 100}%` - : `${t('backtest.copyModeFixed')} ${formatUSDC(detailConfig.fixedAmount)} USDC` + : `${t('backtest.copyModeFixed')} $${formatUSDC(detailConfig.fixedAmount)}` } - {formatUSDC(detailConfig.maxOrderSize)} USDC + {'$' + formatUSDC(detailConfig.maxOrderSize)} - {formatUSDC(detailConfig.minOrderSize)} USDC + {'$' + formatUSDC(detailConfig.minOrderSize)} - {formatUSDC(detailConfig.maxDailyLoss)} USDC + {'$' + formatUSDC(detailConfig.maxDailyLoss)} {detailConfig.maxDailyOrders} @@ -1471,7 +1471,7 @@ const BacktestList: React.FC = () => { )} {detailConfig.maxPositionValue && ( - {formatUSDC(detailConfig.maxPositionValue)} USDC + {'$' + formatUSDC(detailConfig.maxPositionValue)} )} {(detailConfig.minPrice || detailConfig.maxPrice) && ( diff --git a/frontend/src/pages/CopyTradingBuyOrders.tsx b/frontend/src/pages/CopyTradingBuyOrders.tsx index fb51a83..c162d97 100644 --- a/frontend/src/pages/CopyTradingBuyOrders.tsx +++ b/frontend/src/pages/CopyTradingBuyOrders.tsx @@ -150,7 +150,7 @@ const CopyTradingBuyOrdersPage: React.FC = () => { const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString() return ( - {isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`} + {isMobile ? formatUSDC(amount) : `$${formatUSDC(amount)}`} ) } @@ -305,7 +305,7 @@ const CopyTradingBuyOrdersPage: React.FC = () => { 数量: {formatUSDC(order.quantity)} | 价格: {formatUSDC(order.price)}
- 金额: {formatUSDC(amount)} USDC + 金额: ${formatUSDC(amount)}
diff --git a/frontend/src/pages/CopyTradingList.tsx b/frontend/src/pages/CopyTradingList.tsx index 5e204f8..e8a041d 100644 --- a/frontend/src/pages/CopyTradingList.tsx +++ b/frontend/src/pages/CopyTradingList.tsx @@ -279,7 +279,7 @@ const CopyTradingList: React.FC = () => { fontSize: isMobile ? 12 : 14 }}> {getPnlIcon(stats.totalPnl)} - {isMobile ? formatUSDC(stats.totalPnl) : `${formatUSDC(stats.totalPnl)} USDC`} + {isMobile ? formatUSDC(stats.totalPnl) : `$${formatUSDC(stats.totalPnl)}`} {!isMobile && (
{
{record.copyMode === 'RATIO' ? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%` - : `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')} USDC` + : `${t('copyTradingList.fixedAmountMode') || '固定'} $${formatUSDC(record.fixedAmount || '0')}` }
@@ -549,7 +549,7 @@ const CopyTradingList: React.FC = () => { gap: '4px' }}> {getPnlIcon(stats.totalPnl)} - {formatUSDC(stats.totalPnl)} USDC + ${formatUSDC(stats.totalPnl)} ) : loadingStatistics.has(record.id) ? ( diff --git a/frontend/src/pages/CopyTradingMatchedOrders.tsx b/frontend/src/pages/CopyTradingMatchedOrders.tsx index 77c27ce..812aed1 100644 --- a/frontend/src/pages/CopyTradingMatchedOrders.tsx +++ b/frontend/src/pages/CopyTradingMatchedOrders.tsx @@ -130,7 +130,7 @@ const CopyTradingMatchedOrdersPage: React.FC = () => { fontWeight: 500, fontSize: isMobile ? 12 : 14 }}> - {isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`} + {isMobile ? formatUSDC(value) : `$${formatUSDC(value)}`} ) }, @@ -262,7 +262,7 @@ const CopyTradingMatchedOrdersPage: React.FC = () => { fontWeight: 'bold', color: getPnlColor(order.realizedPnl) }}> - {formatUSDC(order.realizedPnl)} USDC + ${formatUSDC(order.realizedPnl)} diff --git a/frontend/src/pages/CopyTradingOrders/AddModal.tsx b/frontend/src/pages/CopyTradingOrders/AddModal.tsx index 78b3b89..11c8ba2 100644 --- a/frontend/src/pages/CopyTradingOrders/AddModal.tsx +++ b/frontend/src/pages/CopyTradingOrders/AddModal.tsx @@ -518,7 +518,7 @@ const AddModal: React.FC = ({ value={parseFloat(leaderAssetInfo.total)} precision={4} valueStyle={{ color: '#52c41a', fontWeight: 'bold', fontSize: '16px' }} - suffix="USDC" + prefix="$" formatter={(value) => formatUSDC(value?.toString() || '0')} /> @@ -528,7 +528,7 @@ const AddModal: React.FC = ({ value={parseFloat(leaderAssetInfo.available)} precision={4} valueStyle={{ color: '#1890ff', fontSize: '14px' }} - suffix="USDC" + prefix="$" formatter={(value) => formatUSDC(value?.toString() || '0')} /> @@ -538,7 +538,7 @@ const AddModal: React.FC = ({ value={parseFloat(leaderAssetInfo.position)} precision={4} valueStyle={{ color: '#722ed1', fontSize: '14px' }} - suffix="USDC" + prefix="$" formatter={(value) => formatUSDC(value?.toString() || '0')} /> @@ -606,7 +606,7 @@ const AddModal: React.FC = ({ {copyMode === 'FIXED' && ( = ({ {copyMode === 'RATIO' && ( <> @@ -665,7 +665,7 @@ const AddModal: React.FC = ({ = 1'} rules={[ @@ -700,7 +700,7 @@ const AddModal: React.FC = ({ )} @@ -709,7 +709,7 @@ const AddModal: React.FC = ({ step={0.0001} precision={4} style={{ width: '100%' }} - placeholder={t('copyTradingAdd.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'} + placeholder={t('copyTradingAdd.maxDailyLossPlaceholder') || '默认 10000 $(可选)'} formatter={(value) => { if (!value && value !== 0) return '' const num = parseFloat(value.toString()) @@ -767,7 +767,7 @@ const AddModal: React.FC = ({ @@ -853,7 +853,7 @@ const AddModal: React.FC = ({ {t('copyTradingAdd.positionLimitFilter') || '最大仓位限制'} @@ -1082,7 +1082,7 @@ const AddModal: React.FC = ({ {record.copyMode === 'RATIO' ? `${t('copyTradingAdd.ratioMode') || '比例'} ${record.copyRatio}x` - : `${t('copyTradingAdd.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')} USDC` + : `${t('copyTradingAdd.fixedAmountMode') || '固定'} $${formatUSDC(record.fixedAmount || '0')}` } ) diff --git a/frontend/src/pages/CopyTradingOrders/BuyOrdersTab.tsx b/frontend/src/pages/CopyTradingOrders/BuyOrdersTab.tsx index a171506..ffbf626 100644 --- a/frontend/src/pages/CopyTradingOrders/BuyOrdersTab.tsx +++ b/frontend/src/pages/CopyTradingOrders/BuyOrdersTab.tsx @@ -261,7 +261,7 @@ const BuyOrdersTab: React.FC = ({ copyTradingId, active = fal const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString() return ( - {isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`} + {isMobile ? formatUSDC(amount) : `$${formatUSDC(amount)}`} ) } @@ -384,7 +384,7 @@ const BuyOrdersTab: React.FC = ({ copyTradingId, active = fal
{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count} - {t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC + {t('copyTradingOrders.totalAmount') || '总金额'}: ${formatUSDC(group.stats.totalAmount)} {t('copyTradingOrders.statusBreakdown') || '状态'}: {group.stats.fullyMatchedCount > 0 && ` ${t('copyTradingOrders.allFullySold') || '全部卖出'} ${group.stats.fullyMatchedCount}`} @@ -460,7 +460,7 @@ const BuyOrdersTab: React.FC = ({ copyTradingId, active = fal
{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}
-
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
+
{t('copyTradingOrders.amount') || '金额'}: ${formatUSDC(amount)}
{t('copyTradingOrders.matched') || '已匹配'}: {formatUSDC(order.matchedQuantity)} | {t('copyTradingOrders.remaining') || '剩余'}: {formatUSDC(order.remainingQuantity)}
{formattedDate}
@@ -557,7 +557,7 @@ const BuyOrdersTab: React.FC = ({ copyTradingId, active = fal {t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}
- {t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC + {t('copyTradingOrders.amount') || '金额'}: ${formatUSDC(amount)}
diff --git a/frontend/src/pages/CopyTradingOrders/EditModal.tsx b/frontend/src/pages/CopyTradingOrders/EditModal.tsx index c94f021..ea66a39 100644 --- a/frontend/src/pages/CopyTradingOrders/EditModal.tsx +++ b/frontend/src/pages/CopyTradingOrders/EditModal.tsx @@ -356,7 +356,7 @@ const EditModal: React.FC = ({ value={parseFloat(leaderAssetInfo.total)} precision={4} valueStyle={{ color: '#52c41a', fontWeight: 'bold', fontSize: '16px' }} - suffix="USDC" + prefix="$" formatter={(value) => formatUSDC(value?.toString() || '0')} /> @@ -366,7 +366,7 @@ const EditModal: React.FC = ({ value={parseFloat(leaderAssetInfo.available)} precision={4} valueStyle={{ color: '#1890ff', fontSize: '14px' }} - suffix="USDC" + prefix="$" formatter={(value) => formatUSDC(value?.toString() || '0')} /> @@ -376,7 +376,7 @@ const EditModal: React.FC = ({ value={parseFloat(leaderAssetInfo.position)} precision={4} valueStyle={{ color: '#722ed1', fontSize: '14px' }} - suffix="USDC" + prefix="$" formatter={(value) => formatUSDC(value?.toString() || '0')} /> @@ -456,7 +456,7 @@ const EditModal: React.FC = ({ {copyMode === 'FIXED' && ( = ({ {copyMode === 'RATIO' && ( <> @@ -515,7 +515,7 @@ const EditModal: React.FC = ({ = 1'} rules={[ @@ -550,7 +550,7 @@ const EditModal: React.FC = ({ )} @@ -559,7 +559,7 @@ const EditModal: React.FC = ({ step={0.0001} precision={4} style={{ width: '100%' }} - placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'} + placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 $(可选)'} formatter={(value) => { if (!value && value !== 0) return '' const num = parseFloat(value.toString()) @@ -617,7 +617,7 @@ const EditModal: React.FC = ({ @@ -703,7 +703,7 @@ const EditModal: React.FC = ({ {t('copyTradingEdit.positionLimitFilter') || '最大仓位限制'} diff --git a/frontend/src/pages/CopyTradingOrders/MatchedOrdersTab.tsx b/frontend/src/pages/CopyTradingOrders/MatchedOrdersTab.tsx index 787f9d1..b55259b 100644 --- a/frontend/src/pages/CopyTradingOrders/MatchedOrdersTab.tsx +++ b/frontend/src/pages/CopyTradingOrders/MatchedOrdersTab.tsx @@ -228,7 +228,7 @@ const MatchedOrdersTab: React.FC = ({ copyTradingId, acti fontWeight: 500, fontSize: isMobile ? 12 : 14 }}> - {isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`} + {isMobile ? formatUSDC(value) : `$${formatUSDC(value)}`} ) }, @@ -408,7 +408,7 @@ const MatchedOrdersTab: React.FC = ({ copyTradingId, acti fontWeight: 'bold', color: getPnlColor(order.realizedPnl) }}> - {formatUSDC(order.realizedPnl)} USDC + ${formatUSDC(order.realizedPnl)} diff --git a/frontend/src/pages/CopyTradingOrders/SellOrdersTab.tsx b/frontend/src/pages/CopyTradingOrders/SellOrdersTab.tsx index 4645a8b..d507dd8 100644 --- a/frontend/src/pages/CopyTradingOrders/SellOrdersTab.tsx +++ b/frontend/src/pages/CopyTradingOrders/SellOrdersTab.tsx @@ -258,7 +258,7 @@ const SellOrdersTab: React.FC = ({ copyTradingId, active = f const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString() return ( - {isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`} + {isMobile ? formatUSDC(amount) : `$${formatUSDC(amount)}`} ) } @@ -274,7 +274,7 @@ const SellOrdersTab: React.FC = ({ copyTradingId, active = f fontWeight: 500, fontSize: isMobile ? 12 : 14 }}> - {isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`} + {isMobile ? formatUSDC(value) : `$${formatUSDC(value)}`} ) }, @@ -362,10 +362,10 @@ const SellOrdersTab: React.FC = ({ copyTradingId, active = f
{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count} - {t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC + {t('copyTradingOrders.totalAmount') || '总金额'}: ${formatUSDC(group.stats.totalAmount)} {group.stats.totalPnl && ( - {t('copyTradingOrders.totalPnl') || '总盈亏'}: {formatUSDC(group.stats.totalPnl)} USDC + {t('copyTradingOrders.totalPnl') || '总盈亏'}: ${formatUSDC(group.stats.totalPnl)} )}
@@ -434,9 +434,9 @@ const SellOrdersTab: React.FC = ({ copyTradingId, active = f
{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}
-
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
+
{t('copyTradingOrders.amount') || '金额'}: ${formatUSDC(amount)}
- {t('copyTradingOrders.realizedPnl') || '已实现盈亏'}: {formatUSDC(order.realizedPnl)} USDC + {t('copyTradingOrders.realizedPnl') || '已实现盈亏'}: ${formatUSDC(order.realizedPnl)}
{formattedDate}
@@ -530,7 +530,7 @@ const SellOrdersTab: React.FC = ({ copyTradingId, active = f {t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}
- {t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC + {t('copyTradingOrders.amount') || '金额'}: ${formatUSDC(amount)}
@@ -541,7 +541,7 @@ const SellOrdersTab: React.FC = ({ copyTradingId, active = f fontWeight: 'bold', color: getPnlColor(order.realizedPnl) }}> - {formatUSDC(order.realizedPnl)} USDC + ${formatUSDC(order.realizedPnl)} diff --git a/frontend/src/pages/CopyTradingOrders/StatisticsModal.tsx b/frontend/src/pages/CopyTradingOrders/StatisticsModal.tsx index cf838f3..ed4ac2a 100644 --- a/frontend/src/pages/CopyTradingOrders/StatisticsModal.tsx +++ b/frontend/src/pages/CopyTradingOrders/StatisticsModal.tsx @@ -104,7 +104,7 @@ const StatisticsModal: React.FC = ({
- {formatUSDC(statistics.totalBuyAmount)} USDC + ${formatUSDC(statistics.totalBuyAmount)}
@@ -113,7 +113,7 @@ const StatisticsModal: React.FC = ({
- {formatUSDC(statistics.totalSellAmount)} USDC + ${formatUSDC(statistics.totalSellAmount)}
@@ -122,7 +122,7 @@ const StatisticsModal: React.FC = ({
{getPnlIcon(statistics.totalPnl)} - {formatUSDC(statistics.totalPnl)} USDC + ${formatUSDC(statistics.totalPnl)}
@@ -131,7 +131,7 @@ const StatisticsModal: React.FC = ({
{getPnlIcon(statistics.totalRealizedPnl)} - {formatUSDC(statistics.totalRealizedPnl)} USDC + ${formatUSDC(statistics.totalRealizedPnl)}
@@ -140,7 +140,7 @@ const StatisticsModal: React.FC = ({
{getPnlIcon(statistics.totalUnrealizedPnl)} - {formatUSDC(statistics.totalUnrealizedPnl)} USDC + ${formatUSDC(statistics.totalUnrealizedPnl)}
@@ -165,43 +165,38 @@ const StatisticsModal: React.FC = ({ } + prefix={<> $} /> } + prefix={<> $} /> {getPnlIcon(statistics.totalPnl)} $} /> {getPnlIcon(statistics.totalRealizedPnl)} $} /> {getPnlIcon(statistics.totalUnrealizedPnl)} $} /> diff --git a/frontend/src/pages/CopyTradingSellOrders.tsx b/frontend/src/pages/CopyTradingSellOrders.tsx index fbe8f76..35e8a4d 100644 --- a/frontend/src/pages/CopyTradingSellOrders.tsx +++ b/frontend/src/pages/CopyTradingSellOrders.tsx @@ -145,7 +145,7 @@ const CopyTradingSellOrdersPage: React.FC = () => { const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString() return ( - {isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`} + {isMobile ? formatUSDC(amount) : `$${formatUSDC(amount)}`} ) } @@ -161,7 +161,7 @@ const CopyTradingSellOrdersPage: React.FC = () => { fontWeight: 500, fontSize: isMobile ? 12 : 14 }}> - {isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`} + {isMobile ? formatUSDC(value) : `$${formatUSDC(value)}`} ) }, @@ -277,7 +277,7 @@ const CopyTradingSellOrdersPage: React.FC = () => { 数量: {formatUSDC(order.quantity)} | 价格: {formatUSDC(order.price)}
- 金额: {formatUSDC(amount)} USDC + 金额: ${formatUSDC(amount)}
@@ -289,7 +289,7 @@ const CopyTradingSellOrdersPage: React.FC = () => { fontWeight: 'bold', color: getPnlColor(order.realizedPnl) }}> - {formatUSDC(order.realizedPnl)} USDC + ${formatUSDC(order.realizedPnl)} diff --git a/frontend/src/pages/CopyTradingStatistics.tsx b/frontend/src/pages/CopyTradingStatistics.tsx index 7f639e8..01222af 100644 --- a/frontend/src/pages/CopyTradingStatistics.tsx +++ b/frontend/src/pages/CopyTradingStatistics.tsx @@ -145,7 +145,7 @@ const CopyTradingStatisticsPage: React.FC = () => { @@ -179,7 +179,7 @@ const CopyTradingStatisticsPage: React.FC = () => { @@ -220,8 +220,7 @@ const CopyTradingStatisticsPage: React.FC = () => { title="总已实现盈亏" value={formatUSDC(statistics.totalRealizedPnl)} valueStyle={{ color: getPnlColor(statistics.totalRealizedPnl) }} - prefix={getPnlIcon(statistics.totalRealizedPnl)} - suffix="USDC" + prefix={<>{getPnlIcon(statistics.totalRealizedPnl)} $} /> @@ -229,8 +228,7 @@ const CopyTradingStatisticsPage: React.FC = () => { title="总未实现盈亏" value={formatUSDC(statistics.totalUnrealizedPnl)} valueStyle={{ color: getPnlColor(statistics.totalUnrealizedPnl) }} - prefix={getPnlIcon(statistics.totalUnrealizedPnl)} - suffix="USDC" + prefix={<>{getPnlIcon(statistics.totalUnrealizedPnl)} $} /> @@ -238,8 +236,7 @@ const CopyTradingStatisticsPage: React.FC = () => { title="总盈亏" value={formatUSDC(statistics.totalPnl)} valueStyle={{ color: getPnlColor(statistics.totalPnl) }} - prefix={getPnlIcon(statistics.totalPnl)} - suffix="USDC" + prefix={<>{getPnlIcon(statistics.totalPnl)} $} /> diff --git a/frontend/src/pages/CryptoTailMonitor.tsx b/frontend/src/pages/CryptoTailMonitor.tsx index 4ddacfd..c40fafd 100644 --- a/frontend/src/pages/CryptoTailMonitor.tsx +++ b/frontend/src/pages/CryptoTailMonitor.tsx @@ -493,7 +493,7 @@ const CryptoTailMonitor: React.FC = () => { } else { timeStr = '--' } - return `${timeStr}   ${Number(val).toFixed(2)} USDC` + return `${timeStr}   $${Number(val).toFixed(2)}` } }, legend: { diff --git a/frontend/src/pages/CryptoTailPnlCurveModal.tsx b/frontend/src/pages/CryptoTailPnlCurveModal.tsx index 7732906..ad71f8f 100644 --- a/frontend/src/pages/CryptoTailPnlCurveModal.tsx +++ b/frontend/src/pages/CryptoTailPnlCurveModal.tsx @@ -45,12 +45,12 @@ const CryptoTailPnlCurveModal: React.FC = (props) if (!v) return '' const d = data.curveData.find((p) => p.timestamp === v[0]) if (!d) return '' - return dayjs(v[0]).format('YYYY-MM-DD HH:mm') + '
' + t('cryptoTailStrategy.pnlCurve.totalPnl') + ': ' + formatUSDC(d.cumulativePnl) + ' USDC' + return dayjs(v[0]).format('YYYY-MM-DD HH:mm') + '
' + t('cryptoTailStrategy.pnlCurve.totalPnl') + ': $' + formatUSDC(d.cumulativePnl) } }, grid: { left: '3%', right: '4%', bottom: '3%', top: '10%', containLabel: true }, xAxis: { type: 'time' }, - yAxis: { type: 'value', axisLabel: { formatter: (val: number) => String(val) + ' USDC' } }, + yAxis: { type: 'value', axisLabel: { formatter: (val: number) => '$' + String(val) } }, series: [{ name: t('cryptoTailStrategy.pnlCurve.totalPnl'), type: 'line', @@ -108,7 +108,7 @@ const CryptoTailPnlCurveModal: React.FC = (props) @@ -125,7 +125,7 @@ const CryptoTailPnlCurveModal: React.FC = (props) diff --git a/frontend/src/pages/CryptoTailStrategyList.tsx b/frontend/src/pages/CryptoTailStrategyList.tsx index 1e37699..eb7de70 100644 --- a/frontend/src/pages/CryptoTailStrategyList.tsx +++ b/frontend/src/pages/CryptoTailStrategyList.tsx @@ -773,7 +773,7 @@ const CryptoTailStrategyList: React.FC = () => {
{t('cryptoTailStrategy.list.totalRealizedPnl')}
{item.totalRealizedPnl != null ? ( -
{formatUSDC(item.totalRealizedPnl)} USDC
+
${formatUSDC(item.totalRealizedPnl)}
) : (
-
)} @@ -966,7 +966,7 @@ const CryptoTailStrategyList: React.FC = () => { ) : ( - + ) } @@ -1111,7 +1111,7 @@ const CryptoTailStrategyList: React.FC = () => { dataIndex: 'amountUsdc', key: 'amountUsdc', width: 110, - render: (v: string) => `${formatUSDC(v)} USDC` + render: (v: string) => `$${formatUSDC(v)}` }, { title: t('cryptoTailStrategy.triggerRecords.realizedPnl'), @@ -1186,7 +1186,7 @@ const CryptoTailStrategyList: React.FC = () => { dataIndex: 'amountUsdc', key: 'amountUsdc', width: 110, - render: (v: string) => `${formatUSDC(v)} USDC` + render: (v: string) => `$${formatUSDC(v)}` }, { title: t('cryptoTailStrategy.triggerRecords.failReason'), diff --git a/frontend/src/pages/LeaderList.tsx b/frontend/src/pages/LeaderList.tsx index 65a77ee..3b7260f 100644 --- a/frontend/src/pages/LeaderList.tsx +++ b/frontend/src/pages/LeaderList.tsx @@ -254,7 +254,7 @@ const LeaderList: React.FC = () => { return ( - {balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`} + {balance.available === '-' ? '-' : `$${formatUSDC(balance.available)}`} {t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)} @@ -488,7 +488,7 @@ const LeaderList: React.FC = () => { {t('leaderDetail.availableBalance')}
- {balance?.available && balance.available !== '-' ? `${formatUSDC(balance.available)} USDC` : '- USDC'} + {balance?.available && balance.available !== '-' ? `$${formatUSDC(balance.available)}` : '-'}
@@ -700,7 +700,7 @@ const LeaderList: React.FC = () => { value={parseFloat(detailBalance.availableBalance)} precision={4} valueStyle={{ color: '#1890ff' }} - suffix="USDC" + prefix="$" formatter={(value) => formatUSDC(value?.toString() || '0')} /> @@ -712,7 +712,7 @@ const LeaderList: React.FC = () => { value={parseFloat(detailBalance.positionBalance)} precision={4} valueStyle={{ color: '#722ed1' }} - suffix="USDC" + prefix="$" formatter={(value) => formatUSDC(value?.toString() || '0')} /> @@ -724,7 +724,7 @@ const LeaderList: React.FC = () => { value={parseFloat(detailBalance.totalBalance)} precision={4} valueStyle={{ color: '#52c41a', fontWeight: 'bold' }} - suffix="USDC" + prefix="$" formatter={(value) => formatUSDC(value?.toString() || '0')} /> diff --git a/frontend/src/pages/OrderList.tsx b/frontend/src/pages/OrderList.tsx index 1630f0e..e2648e0 100644 --- a/frontend/src/pages/OrderList.tsx +++ b/frontend/src/pages/OrderList.tsx @@ -118,7 +118,7 @@ const OrderList: React.FC = () => { key: 'pnl', render: (pnl: string | undefined) => pnl ? ( - {formatUSDC(pnl)} USDC + ${formatUSDC(pnl)} ) : '-' }, diff --git a/frontend/src/pages/PositionList.tsx b/frontend/src/pages/PositionList.tsx index 4b1660e..60fcb81 100644 --- a/frontend/src/pages/PositionList.tsx +++ b/frontend/src/pages/PositionList.tsx @@ -695,7 +695,7 @@ const PositionList: React.FC = () => { fontWeight: '500', color: isProfit ? '#52c41a' : '#f5222d' }}> - {pnlNum >= 0 ? '+' : ''}{formatUSDC(position.pnl)} USDC + {pnlNum >= 0 ? '+' : ''}${formatUSDC(position.pnl)}
)} @@ -718,7 +718,7 @@ const PositionList: React.FC = () => {
开仓价值 - {formatUSDC(position.initialValue)} USDC + ${formatUSDC(position.initialValue)}
{positionFilter === 'current' && position.currentPrice && ( @@ -732,7 +732,7 @@ const PositionList: React.FC = () => {
当前价值 - {formatUSDC(position.currentValue)} USDC + ${formatUSDC(position.currentValue)}
@@ -775,7 +775,7 @@ const PositionList: React.FC = () => { fontWeight: 'bold', color: isProfit ? '#52c41a' : '#f5222d' }}> - {pnlNum >= 0 ? '+' : ''}{formatUSDC(position.pnl)} USDC + {pnlNum >= 0 ? '+' : ''}${formatUSDC(position.pnl)}
@@ -802,7 +802,7 @@ const PositionList: React.FC = () => { color: parseFloat(position.realizedPnl) >= 0 ? '#52c41a' : '#f5222d', fontWeight: '500' }}> - {parseFloat(position.realizedPnl) >= 0 ? '+' : ''}{formatUSDC(position.realizedPnl)} USDC + {parseFloat(position.realizedPnl) >= 0 ? '+' : ''}${formatUSDC(position.realizedPnl)}
)} @@ -951,7 +951,7 @@ const PositionList: React.FC = () => { dataIndex: 'initialValue', key: 'initialValue', render: (value: string) => ( - {formatUSDC(value)} USDC + ${formatUSDC(value)} ), align: 'right' as const, width: 110 @@ -971,7 +971,7 @@ const PositionList: React.FC = () => { return (
- {formatUSDC(record.currentValue)} USDC + ${formatUSDC(record.currentValue)}
{ borderColor: '#52c41a' }} > - 赎回 ({redeemableSummary.totalCount}个, {formatUSDC(redeemableSummary.totalValue)} USDC) + 赎回 ({redeemableSummary.totalCount}个, ${formatUSDC(redeemableSummary.totalValue)}) )}
@@ -1238,13 +1238,13 @@ const PositionList: React.FC = () => { 开仓价值合计:{' '} - {formatUSDC(positionTotals.totalInitialValue.toString())} USDC + ${formatUSDC(positionTotals.totalInitialValue.toString())} 当前价值合计:{' '} - {formatUSDC(positionTotals.totalCurrentValue.toString())} USDC + ${formatUSDC(positionTotals.totalCurrentValue.toString())} @@ -1256,7 +1256,7 @@ const PositionList: React.FC = () => { }} > {positionTotals.totalPnl >= 0 ? '+' : ''} - {formatUSDC(positionTotals.totalPnl.toString())} USDC + ${formatUSDC(positionTotals.totalPnl.toString())} @@ -1268,7 +1268,7 @@ const PositionList: React.FC = () => { }} > {positionTotals.totalRealizedPnl >= 0 ? '+' : ''} - {formatUSDC(positionTotals.totalRealizedPnl.toString())} USDC + ${formatUSDC(positionTotals.totalRealizedPnl.toString())}
@@ -1532,7 +1532,7 @@ const PositionList: React.FC = () => { color: currentPnl.pnl >= 0 ? '#52c41a' : '#f5222d', marginBottom: '4px' }}> - {currentPnl.pnl >= 0 ? '+' : ''}{formatUSDC(currentPnl.pnl)} USDC + {currentPnl.pnl >= 0 ? '+' : ''}${formatUSDC(currentPnl.pnl)}
{ - {formatUSDC(redeemableSummary.totalValue)} USDC + ${formatUSDC(redeemableSummary.totalValue)} @@ -1625,7 +1625,7 @@ const PositionList: React.FC = () => { width: 120 }, { - title: '价值 (USDC)', + title: '价值 ($)', dataIndex: 'value', key: 'value', align: 'right' as const, diff --git a/frontend/src/pages/Statistics.tsx b/frontend/src/pages/Statistics.tsx index 8736f7c..bfbe2ed 100644 --- a/frontend/src/pages/Statistics.tsx +++ b/frontend/src/pages/Statistics.tsx @@ -101,9 +101,8 @@ const Statistics: React.FC = () => { = 0 ? : } + prefix={<>{stats?.totalPnl && parseFloat(stats.totalPnl) >= 0 ? : } $} valueStyle={{ color: stats?.totalPnl && parseFloat(stats.totalPnl || '0') >= 0 ? '#3f8600' : '#cf1322' }} - suffix="USDC" loading={loading} /> @@ -124,9 +123,8 @@ const Statistics: React.FC = () => { = 0 ? : } + prefix={<>{stats?.avgPnl && parseFloat(stats.avgPnl || '0') >= 0 ? : } $} valueStyle={{ color: stats?.avgPnl && parseFloat(stats.avgPnl || '0') >= 0 ? '#3f8600' : '#cf1322' }} - suffix="USDC" loading={loading} /> @@ -136,9 +134,8 @@ const Statistics: React.FC = () => { } + prefix={<> $} valueStyle={{ color: '#3f8600' }} - suffix="USDC" loading={loading} /> @@ -148,9 +145,8 @@ const Statistics: React.FC = () => { } + prefix={<> $} valueStyle={{ color: '#cf1322' }} - suffix="USDC" loading={loading} /> diff --git a/frontend/src/pages/TemplateAdd.tsx b/frontend/src/pages/TemplateAdd.tsx index af7f84d..e2aa4b8 100644 --- a/frontend/src/pages/TemplateAdd.tsx +++ b/frontend/src/pages/TemplateAdd.tsx @@ -154,7 +154,7 @@ const TemplateAdd: React.FC = () => { {copyMode === 'FIXED' && ( { {copyMode === 'RATIO' && ( <> { = 1 USDC。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'} + tooltip={t('templateAdd.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= $1。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'} rules={[ { validator: (_, value) => { @@ -282,7 +282,7 @@ const TemplateAdd: React.FC = () => { diff --git a/frontend/src/pages/TemplateEdit.tsx b/frontend/src/pages/TemplateEdit.tsx index cf18826..9bf72f1 100644 --- a/frontend/src/pages/TemplateEdit.tsx +++ b/frontend/src/pages/TemplateEdit.tsx @@ -189,9 +189,9 @@ const TemplateEdit: React.FC = () => { {copyMode === 'FIXED' && ( = 1 USDC。例如:设置为 10,则无论 Leader 买入多少,跟单金额始终为 10 USDC。'} + tooltip={t('templateEdit.fixedAmountTooltip') || '固定金额模式下,每次跟单的固定金额,不随 Leader 订单大小变化。必须 >= $1。例如:设置为 10,则无论 Leader 买入多少,跟单金额始终为 $10。'} rules={[ { required: true, message: t('templateEdit.fixedAmountRequired') || '请输入固定跟单金额' }, { @@ -229,9 +229,9 @@ const TemplateEdit: React.FC = () => { {copyMode === 'RATIO' && ( <> { = 1 USDC。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'} + tooltip={t('templateEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= $1。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'} rules={[ { validator: (_, value) => { @@ -318,7 +318,7 @@ const TemplateEdit: React.FC = () => { diff --git a/frontend/src/pages/TemplateList.tsx b/frontend/src/pages/TemplateList.tsx index 6a3dfce..618f265 100644 --- a/frontend/src/pages/TemplateList.tsx +++ b/frontend/src/pages/TemplateList.tsx @@ -176,7 +176,7 @@ const TemplateList: React.FC = () => { if (record.copyMode === 'RATIO') { return `${t('templateList.ratio') || '比例'} ${record.copyRatio}x` } else if (record.copyMode === 'FIXED' && record.fixedAmount) { - return `${t('templateList.fixedAmount') || '固定'} ${formatUSDC(record.fixedAmount)} USDC` + return `$${formatUSDC(record.fixedAmount)}` } return '-' } @@ -350,7 +350,7 @@ const TemplateList: React.FC = () => {
{template.copyMode === 'RATIO' ? `${t('templateList.ratioMode') || '比例模式'} ${(parseFloat(template.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%` - : `${t('templateList.fixedAmountMode') || '固定金额'} ${formatUSDC(template.fixedAmount || '0')} USDC` + : `$${formatUSDC(template.fixedAmount || '0')}` }
@@ -396,11 +396,11 @@ const TemplateList: React.FC = () => { }}> {t('templateList.amountLimit') || '金额限制'}: {template.maxOrderSize && ( - {t('templateList.max') || '最大'} {formatUSDC(template.maxOrderSize)} USDC + {t('templateList.max') || '最大'} ${formatUSDC(template.maxOrderSize)} )} {template.maxOrderSize && template.minOrderSize && | } {template.minOrderSize && ( - {t('templateList.min') || '最小'} {formatUSDC(template.minOrderSize)} USDC + {t('templateList.min') || '最小'} ${formatUSDC(template.minOrderSize)} )} {!template.maxOrderSize && !template.minOrderSize && {t('templateList.notSet') || '未设置'}} @@ -551,7 +551,7 @@ const TemplateList: React.FC = () => { {copyMode === 'FIXED' && ( { {copyMode === 'RATIO' && ( <> { { @@ -698,7 +698,7 @@ const TemplateList: React.FC = () => { 过滤条件(可选) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2dbbe9c..e9aae9e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -284,9 +284,21 @@ export const apiService = { /** * 赎回仓位 */ - redeemPositions: (data: any) => + redeemPositions: (data: any) => apiClient.post>('/accounts/positions/redeem', data), - + + /** + * 将 USDC.e wrap 为 pUSD(V2 迁移) + */ + wrapToPusd: (accountId: number) => + apiClient.post>('/accounts/wrap-to-pusd', { accountId }), + + /** + * 查询 USDC.e 余额(V2 迁移用) + */ + getUsdceBalance: (accountId: number) => + apiClient.post>('/accounts/usdce-balance', { accountId }), + }, /**