From 914983acd7fadf16c90f8f5cfafb289c8176690e Mon Sep 17 00:00:00 2001 From: WrBug Date: Mon, 1 Dec 2025 13:02:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E9=92=B1=E5=8C=85=E7=9A=84=E4=BB=93=E4=BD=8D?= =?UTF-8?q?=E8=B5=8E=E5=9B=9E=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 Gnosis Safe EIP-712 签名逻辑 - 通过代理钱包的 execTransaction 调用 ConditionalTokens.redeemPositions - 支持多账户批量赎回 - 前端添加赎回按钮和赎回详情弹窗 - 根据链上交易分析实现完整的 Safe 交易签名流程 --- .../controller/AccountController.kt | 71 +++ .../com/wrbug/polymarketbot/dto/AccountDto.kt | 79 +++ .../polymarketbot/service/AccountService.kt | 192 +++++++ .../service/BlockchainService.kt | 469 ++++++++++++++++ .../wrbug/polymarketbot/util/Eip712Encoder.kt | 94 ++++ frontend/src/pages/PositionList.tsx | 513 ++++++++++++++---- frontend/src/services/api.ts | 12 + frontend/src/types/index.ts | 70 +++ 8 files changed, 1394 insertions(+), 106 deletions(-) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt index 0a52ba2..f26bbeb 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt @@ -278,5 +278,76 @@ class AccountController( } } + /** + * 获取可赎回仓位统计 + */ + @PostMapping("/positions/redeemable-summary") + fun getRedeemablePositionsSummary(@RequestBody request: AccountDetailRequest): ResponseEntity> { + return try { + val result = runBlocking { accountService.getRedeemablePositionsSummary(request.accountId) } + result.fold( + onSuccess = { summary -> + logger.info("获取可赎回仓位统计成功: 账户=${request.accountId}, 数量=${summary.totalCount}, 价值=${summary.totalValue}") + ResponseEntity.ok(ApiResponse.success(summary)) + }, + onFailure = { e -> + logger.error("获取可赎回仓位统计失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("获取可赎回仓位统计失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("获取可赎回仓位统计异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("获取可赎回仓位统计失败: ${e.message}")) + } + } + + /** + * 赎回仓位 + */ + @PostMapping("/positions/redeem") + fun redeemPositions(@RequestBody request: PositionRedeemRequest): ResponseEntity> { + return try { + // 参数验证 + if (request.positions.isEmpty()) { + return ResponseEntity.ok(ApiResponse.paramError("赎回仓位列表不能为空")) + } + + // 验证每个仓位项 + for (item in request.positions) { + if (item.accountId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("账户ID无效")) + } + if (item.marketId.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("市场ID不能为空")) + } + if (item.outcomeIndex < 0) { + return ResponseEntity.ok(ApiResponse.paramError("结果索引无效")) + } + } + + val result = runBlocking { accountService.redeemPositions(request) } + result.fold( + onSuccess = { response -> + logger.info("成功赎回仓位: 账户数=${response.transactions.size}, 交易数=${response.transactions.size}, 总价值=${response.totalRedeemedValue}") + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("赎回仓位失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + is IllegalStateException -> ResponseEntity.ok(ApiResponse.businessError(e.message ?: "业务逻辑错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("赎回仓位失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("赎回仓位异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("赎回仓位失败: ${e.message}")) + } + } + } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt index ad94379..230979e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt @@ -188,3 +188,82 @@ data class MarketPriceResponse( val midpoint: String? // 中间价 ) +/** + * 仓位赎回请求 + */ +data class PositionRedeemRequest( + val positions: List // 要赎回的仓位列表(支持多账户) +) + +/** + * 账户赎回仓位项(包含账户ID) + */ +data class AccountRedeemPositionItem( + val accountId: Long, // 账户ID(必需) + val marketId: String, // 市场ID(conditionId) + val outcomeIndex: Int, // 结果索引(0, 1, 2...) + val side: String? = null // 结果名称(可选,用于显示) +) + +/** + * 赎回仓位项 + */ +data class RedeemPositionItem( + val marketId: String, // 市场ID(conditionId) + val outcomeIndex: Int, // 结果索引(0, 1, 2...) + val side: String? = null // 结果名称(可选,用于显示) +) + +/** + * 仓位赎回响应 + */ +data class PositionRedeemResponse( + val transactions: List, // 每个账户的赎回交易 + val totalRedeemedValue: String, // 赎回总价值(USDC) + val createdAt: Long // 创建时间戳 +) + +/** + * 账户赎回交易信息 + */ +data class AccountRedeemTransaction( + val accountId: Long, + val accountName: String?, + val transactionHash: String, // 交易哈希 + val positions: List // 赎回的仓位信息 +) + +/** + * 赎回的仓位信息 + */ +data class RedeemedPositionInfo( + val marketId: String, + val side: String, + val outcomeIndex: Int, + val quantity: String, // 赎回数量 + val value: String // 赎回价值(USDC,1:1) +) + +/** + * 可赎回仓位统计响应 + */ +data class RedeemablePositionsSummary( + val totalCount: Int, // 可赎回仓位总数 + val totalValue: String, // 可赎回总价值(USDC) + val positions: List // 可赎回仓位列表 +) + +/** + * 可赎回仓位信息 + */ +data class RedeemablePositionInfo( + val accountId: Long, + val accountName: String?, + val marketId: String, + val marketTitle: String?, + val side: String, + val outcomeIndex: Int, + val quantity: String, + val value: String // 价值(USDC,1:1) +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt index 64ccc07..8ccb325 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt @@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal +import java.math.BigInteger /** * 账户管理服务 @@ -914,6 +915,197 @@ class AccountService( } } + /** + * 获取可赎回仓位统计 + */ + suspend fun getRedeemablePositionsSummary(accountId: Long? = null): Result { + return try { + val positionsResult = getAllPositions() + positionsResult.fold( + onSuccess = { positionListResponse -> + // 筛选可赎回的仓位 + val redeemablePositions = positionListResponse.currentPositions.filter { it.redeemable } + + // 如果指定了账户ID,进一步筛选 + val filteredPositions = if (accountId != null) { + redeemablePositions.filter { it.accountId == accountId } + } else { + redeemablePositions + } + + // 计算总价值(赎回是1:1,所以价值等于数量) + val totalValue = filteredPositions.fold(BigDecimal.ZERO) { sum, pos -> + sum.add(pos.quantity.toSafeBigDecimal()) + } + + // 转换为可赎回仓位信息列表 + val redeemableInfoList = filteredPositions.map { pos -> + com.wrbug.polymarketbot.dto.RedeemablePositionInfo( + accountId = pos.accountId, + accountName = pos.accountName, + marketId = pos.marketId, + marketTitle = pos.marketTitle, + side = pos.side, + outcomeIndex = pos.outcomeIndex ?: 0, + quantity = pos.quantity, + value = pos.quantity // 赎回价值等于数量(1:1) + ) + } + + Result.success( + com.wrbug.polymarketbot.dto.RedeemablePositionsSummary( + totalCount = redeemableInfoList.size, + totalValue = totalValue.toPlainString(), + positions = redeemableInfoList + ) + ) + }, + onFailure = { e -> + Result.failure(Exception("查询仓位失败: ${e.message}")) + } + ) + } catch (e: Exception) { + logger.error("获取可赎回仓位统计失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 赎回仓位 + * 支持多账户、多仓位赎回(自动按账户和市场分组) + */ + suspend fun redeemPositions(request: com.wrbug.polymarketbot.dto.PositionRedeemRequest): Result { + return try { + if (request.positions.isEmpty()) { + return Result.failure(IllegalArgumentException("赎回仓位列表不能为空")) + } + + // 1. 验证仓位是否存在且可赎回 + val positionsResult = getAllPositions() + val allPositions = positionsResult.getOrElse { + return Result.failure(Exception("查询仓位失败: ${it.message}")) + } + + // 2. 按账户分组 + val positionsByAccount = request.positions.groupBy { it.accountId } + + // 3. 验证所有账户是否存在 + val accounts = mutableMapOf() + for (accountId in positionsByAccount.keys) { + val account = accountRepository.findById(accountId).orElse(null) + ?: return Result.failure(IllegalArgumentException("账户不存在: $accountId")) + accounts[accountId] = account + } + + // 4. 验证并收集要赎回的仓位信息(按账户分组) + val accountRedeemData = mutableMapOf>>() + val accountRedeemedInfo = mutableMapOf>() + + for ((accountId, requestItems) in positionsByAccount) { + val accountPositions = mutableListOf>() + val accountInfo = mutableListOf() + + for (requestItem in requestItems) { + val position = allPositions.currentPositions.find { + it.accountId == accountId && + it.marketId == requestItem.marketId && + it.outcomeIndex == requestItem.outcomeIndex + } + + if (position == null) { + return Result.failure(IllegalArgumentException("仓位不存在: accountId=$accountId, marketId=${requestItem.marketId}, outcomeIndex=${requestItem.outcomeIndex}")) + } + + if (!position.redeemable) { + return Result.failure(IllegalStateException("仓位不可赎回: accountId=$accountId, marketId=${requestItem.marketId}, outcomeIndex=${requestItem.outcomeIndex}")) + } + + // 计算 indexSet = 2^outcomeIndex + val indexSet = BigInteger.TWO.pow(requestItem.outcomeIndex) + accountPositions.add(Pair(position, indexSet)) + + accountInfo.add( + com.wrbug.polymarketbot.dto.RedeemedPositionInfo( + marketId = position.marketId, + side = position.side, + outcomeIndex = requestItem.outcomeIndex, + quantity = position.quantity, + value = position.quantity // 赎回价值等于数量(1:1) + ) + ) + } + + accountRedeemData[accountId] = accountPositions + accountRedeemedInfo[accountId] = accountInfo + } + + // 5. 对每个账户执行赎回 + val accountTransactions = mutableListOf() + var totalRedeemedValue = BigDecimal.ZERO + + for ((accountId, positions) in accountRedeemData) { + val account = accounts[accountId]!! + val redeemedInfo = accountRedeemedInfo[accountId]!! + + // 按市场分组(同一市场的仓位可以批量赎回) + val positionsByMarket = positions.groupBy { it.first.marketId } + + // 对每个市场执行赎回 + var lastTxHash: String? = null + for ((marketId, marketPositions) in positionsByMarket) { + val indexSets = marketPositions.map { it.second } + + // 调用区块链服务赎回仓位 + val redeemResult = blockchainService.redeemPositions( + privateKey = account.privateKey, + proxyAddress = account.proxyAddress, + conditionId = marketId, + indexSets = indexSets + ) + + redeemResult.fold( + onSuccess = { txHash -> + lastTxHash = txHash + logger.info("账户 $accountId 市场 $marketId 赎回成功: txHash=$txHash, indexSets=$indexSets") + }, + onFailure = { e -> + logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e) + return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}")) + } + ) + } + + // 计算该账户的赎回总价值 + val accountTotalValue = redeemedInfo.fold(BigDecimal.ZERO) { sum, info -> + sum.add(info.value.toSafeBigDecimal()) + } + totalRedeemedValue = totalRedeemedValue.add(accountTotalValue) + + // 添加到交易列表 + accountTransactions.add( + com.wrbug.polymarketbot.dto.AccountRedeemTransaction( + accountId = accountId, + accountName = account.accountName, + transactionHash = lastTxHash ?: "", + positions = redeemedInfo + ) + ) + } + + // 6. 返回结果 + Result.success( + com.wrbug.polymarketbot.dto.PositionRedeemResponse( + transactions = accountTransactions, + totalRedeemedValue = totalRedeemedValue.toPlainString(), + createdAt = System.currentTimeMillis() + ) + ) + } catch (e: Exception) { + logger.error("赎回仓位异常: ${e.message}", e) + Result.failure(e) + } + } + /** * 检查账户是否有活跃订单 * 使用账户的 API Key 查询该账户的活跃订单 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt index 55dbc47..235d74b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt @@ -400,5 +400,474 @@ class BlockchainService( Result.failure(e) } } + + /** + * 赎回仓位 + * 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数 + * + * 重要说明(基于实际链上交易分析): + * - 仓位在 proxyAddress 上,需要通过代理钱包的 execTransaction 执行赎回 + * - 交易流程:EOA → Proxy.execTransaction → ConditionalTokens.redeemPositions + * - execTransaction 是 Gnosis Safe 标准函数,需要构建 Safe 交易并签名 + * + * 参考交易: https://polygonscan.com/tx/0xb3b4cbab668c4c764aa2e7ff6f17f56f372921fc4852ef616ce65238d73eca4e + * + * @param privateKey 私钥(原始钱包的私钥,用于签名交易) + * @param proxyAddress 代理地址(Gnosis Safe 代理钱包地址) + * @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串) + * @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex,例如 [1] 表示 outcome 0,[2] 表示 outcome 1) + * @return 交易哈希 + */ + suspend fun redeemPositions( + privateKey: String, + proxyAddress: String, + conditionId: String, + indexSets: List + ): Result { + return try { + // 如果未配置 RPC URL,返回错误 + if (ethereumRpcUrl.isBlank()) { + logger.warn("未配置 Ethereum RPC URL,无法赎回仓位") + return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法赎回仓位")) + } + + val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + + // 验证参数 + if (indexSets.isEmpty()) { + return Result.failure(IllegalArgumentException("indexSets 不能为空")) + } + + if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) { + return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串")) + } + + if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) { + return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址")) + } + + // 从私钥推导实际签名地址(交易真正的 from 地址) + val cleanPrivateKey = privateKey.removePrefix("0x") + val privateKeyBigInt = BigInteger(cleanPrivateKey, 16) + val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16)) + val fromAddress = credentials.address + + logger.debug("赎回仓位: from=$fromAddress, proxy=$proxyAddress, conditionId=$conditionId, indexSets=$indexSets") + + // 1. 构建 ConditionalTokens.redeemPositions 的调用数据 + val redeemFunctionSelector = EthereumUtils.getFunctionSelector("redeemPositions(address,bytes32,bytes32,uint256[])") + + // 编码 redeemPositions 参数 + val encodedCollateral = EthereumUtils.encodeAddress(usdcContractAddress) + val encodedParentCollection = EthereumUtils.encodeBytes32(EMPTY_SET) // parentCollectionId 通常为全0 + val encodedConditionId = EthereumUtils.encodeBytes32(conditionId) + + // 编码数组:offset (32字节) + length (32字节) + 每个元素 (32字节) + val arrayOffset = BigInteger.valueOf(128) + val arrayLength = BigInteger.valueOf(indexSets.size.toLong()) + val encodedArrayOffset = EthereumUtils.encodeUint256(arrayOffset) + val encodedArrayLength = EthereumUtils.encodeUint256(arrayLength) + val encodedArrayElements = indexSets.joinToString("") { EthereumUtils.encodeUint256(it) } + + // ConditionalTokens.redeemPositions 的调用数据 + val redeemCallData = redeemFunctionSelector + + encodedCollateral + + encodedParentCollection + + encodedConditionId + + encodedArrayOffset + + encodedArrayLength + + encodedArrayElements + + // 2. 构建 Proxy.execTransaction 的调用数据 + // 函数签名: execTransaction(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,bytes signatures) + // 根据实际交易分析,参数如下: + // - to: ConditionalTokens 合约地址 + // - value: 0 + // - data: redeemPositions 的调用数据 + // - operation: 0 (CALL) + // - safeTxGas: 0 (使用所有可用 gas) + // - baseGas: 0 + // - gasPrice: 0 (使用当前 gas price) + // - gasToken: 0x0000...0000 (使用原生代币) + // - refundReceiver: 0x0000...0000 + // - signatures: EIP-712 签名的 Safe 交易 + + // 获取 Proxy 的 nonce(用于构建 Safe 交易哈希) + val proxyNonceResult = getProxyNonce(proxyAddress) + val proxyNonce = proxyNonceResult.getOrElse { + logger.warn("获取 Proxy nonce 失败,使用 0: ${it.message}") + BigInteger.ZERO + } + + // 构建 Safe 交易哈希(用于 EIP-712 签名) + // 使用 Gnosis Safe 的 EIP-712 签名标准 + val safeTxGas = BigInteger.ZERO + val baseGas = BigInteger.ZERO + val safeGasPrice = BigInteger.ZERO // 使用不同的变量名避免冲突 + val gasToken = "0x0000000000000000000000000000000000000000" + val refundReceiver = "0x0000000000000000000000000000000000000000" + + // 1. 编码 Safe 域分隔符 + val safeDomainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeSafeDomain( + verifyingContract = proxyAddress + ) + + // 2. 编码 SafeTx 消息哈希 + val safeTxHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeSafeTx( + to = conditionalTokensAddress, + value = BigInteger.ZERO, + data = redeemCallData, + operation = 0, // CALL + safeTxGas = safeTxGas, + baseGas = baseGas, + gasPrice = safeGasPrice, + gasToken = gasToken, + refundReceiver = refundReceiver, + nonce = proxyNonce + ) + + // 3. 计算完整的结构化数据哈希 + val safeTxStructuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData( + domainSeparator = safeDomainSeparator, + messageHash = safeTxHash + ) + + // 4. 使用私钥签名 Safe 交易 + val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt) + val safeSignature = org.web3j.crypto.Sign.signMessage(safeTxStructuredHash, ecKeyPair, false) + + // 5. 编码签名数据(Gnosis Safe 签名格式:r + s + v,每个 32 字节,共 96 字节) + // Safe 签名格式:r (32 bytes) + s (32 bytes) + v (1 byte,但需要编码为 32 字节) + val vBytes = safeSignature.v as ByteArray + val vInt = if (vBytes.isNotEmpty()) { + vBytes[0].toInt() and 0xff + } else { + 0 + } + + // 编码为十六进制字符串(每个字段 32 字节 = 64 个十六进制字符) + val rHex = org.web3j.utils.Numeric.toHexString(safeSignature.r).removePrefix("0x").padStart(64, '0') + val sHex = org.web3j.utils.Numeric.toHexString(safeSignature.s).removePrefix("0x").padStart(64, '0') + val vHex = String.format("%064x", vInt) + + // Safe 签名格式:r + s + v(每个 32 字节,共 96 字节 = 192 个十六进制字符) + val safeSignatureHex = rHex + sHex + vHex + + // 3. 构建 execTransaction 的调用数据 + // 函数签名: execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes) + val execFunctionSelector = EthereumUtils.getFunctionSelector("execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)") + + // 编码 execTransaction 参数 + val encodedTo = EthereumUtils.encodeAddress(conditionalTokensAddress) + val encodedValue = EthereumUtils.encodeUint256(BigInteger.ZERO) + + // data 参数(bytes 类型):offset (32字节) + length (32字节) + data (按长度,不足32字节的倍数需要padding) + // 固定参数部分:to(32) + value(32) + data_offset(32) + operation(32) + safeTxGas(32) + baseGas(32) + gasPrice(32) + gasToken(32) + refundReceiver(32) + signatures_offset(32) = 320字节 + val dataOffset = BigInteger.valueOf(320L) + val redeemCallDataHex = redeemCallData.removePrefix("0x") + val dataLengthBytes = BigInteger.valueOf((redeemCallDataHex.length / 2).toLong()) // 字节数 + val encodedDataOffset = EthereumUtils.encodeUint256(dataOffset) + val encodedDataLength = EthereumUtils.encodeUint256(dataLengthBytes) + // data 需要按 32 字节对齐(不足的补0) + val dataPaddedLength = ((dataLengthBytes.toInt() + 31) / 32) * 32 * 2 // 十六进制字符数 + val encodedData = redeemCallDataHex.padEnd(dataPaddedLength, '0') + + val encodedOperation = EthereumUtils.encodeUint256(BigInteger.ZERO) // 0 = CALL + val encodedSafeTxGas = EthereumUtils.encodeUint256(BigInteger.ZERO) + val encodedBaseGas = EthereumUtils.encodeUint256(BigInteger.ZERO) + val encodedGasPrice = EthereumUtils.encodeUint256(BigInteger.ZERO) + val encodedGasToken = EthereumUtils.encodeAddress("0x0000000000000000000000000000000000000000") + val encodedRefundReceiver = EthereumUtils.encodeAddress("0x0000000000000000000000000000000000000000") + + // signatures 参数(bytes 类型):offset + length + signatures + // signatures 的 offset = dataOffset + dataLength 的 32字节对齐后的位置 + val dataPaddedBytes = dataPaddedLength / 2 + val signaturesOffset = BigInteger.valueOf((320 + dataPaddedBytes).toLong()) + // Safe 签名长度:r (32) + s (32) + v (32) = 96 字节 + val signaturesLength = BigInteger.valueOf(96L) + val encodedSignaturesOffset = EthereumUtils.encodeUint256(signaturesOffset) + val encodedSignaturesLength = EthereumUtils.encodeUint256(signaturesLength) + // signatures 需要按 32 字节对齐(96 字节已经是 32 的倍数,不需要 padding) + val encodedSignatures = safeSignatureHex + + // 组合 execTransaction 调用数据 + val execCallData = "0x" + execFunctionSelector.removePrefix("0x") + + encodedTo + + encodedValue + + encodedDataOffset + + encodedDataLength + + encodedData + + encodedOperation + + encodedSafeTxGas + + encodedBaseGas + + encodedGasPrice + + encodedGasToken + + encodedRefundReceiver + + encodedSignaturesOffset + + encodedSignaturesLength + + encodedSignatures + + // 4. 获取 EOA 的 nonce(用于发送交易) + val nonceResult = getTransactionCount(fromAddress) + val nonce = nonceResult.getOrElse { + return Result.failure(Exception("获取 nonce 失败: ${it.message}")) + } + + // 5. 获取 gas price + val gasPriceResult = getGasPrice() + val gasPrice = gasPriceResult.getOrElse { + return Result.failure(Exception("获取 gas price 失败: ${it.message}")) + } + + // 6. Gas limit(通过 Proxy 执行需要更多 gas,给 240 万,参考实际交易) + val gasLimit = BigInteger.valueOf(2400000) + + // 7. 构建并签名交易 + // 调用 Proxy 的 execTransaction + val transaction = buildTransaction( + privateKey = privateKey, + from = fromAddress, + to = proxyAddress, // 调用代理钱包 + data = execCallData, + nonce = nonce, + gasLimit = gasLimit, + gasPrice = gasPrice + ) + + // 8. 发送交易 + val txHashResult = sendTransaction(rpcApi, transaction) + txHashResult.fold( + onSuccess = { txHash -> + logger.info("赎回仓位交易已发送: txHash=$txHash, from=$fromAddress, proxy=$proxyAddress, conditionId=$conditionId, indexSets=$indexSets") + Result.success(txHash) + }, + onFailure = { e -> + logger.error("发送赎回交易失败: ${e.message}", e) + Result.failure(e) + } + ) + } catch (e: Exception) { + logger.error("赎回仓位失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 获取代理钱包的 nonce(用于构建 Safe 交易) + */ + private suspend fun getProxyNonce(proxyAddress: String): Result { + val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + + // Gnosis Safe 的 nonce 通过调用合约的 nonce() 函数获取 + val nonceFunctionSelector = EthereumUtils.getFunctionSelector("nonce()") + + val rpcRequest = JsonRpcRequest( + method = "eth_call", + params = listOf( + mapOf( + "to" to proxyAddress, + "data" to nonceFunctionSelector + ), + "latest" + ) + ) + + val response = rpcApi.call(rpcRequest) + if (!response.isSuccessful || response.body() == null) { + return Result.failure(Exception("获取 Proxy nonce 失败: ${response.code()} ${response.message()}")) + } + + val rpcResponse = response.body()!! + if (rpcResponse.error != null) { + return Result.failure(Exception("获取 Proxy nonce 失败: ${rpcResponse.error.message}")) + } + + val hexNonce = rpcResponse.result ?: return Result.failure(Exception("Proxy nonce 结果为空")) + val nonce = EthereumUtils.decodeUint256(hexNonce) + return Result.success(nonce) + } + + /** + * 获取交易 nonce + */ + private suspend fun getTransactionCount(address: String): Result { + val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + + val rpcRequest = JsonRpcRequest( + method = "eth_getTransactionCount", + // 使用 "pending",将未打包的待处理交易也计入 nonce, + // 避免在有挂起交易时出现 "nonce too low" 错误 + params = listOf(address, "pending") + ) + + val response = rpcApi.call(rpcRequest) + if (!response.isSuccessful || response.body() == null) { + return Result.failure(Exception("获取 nonce 失败: ${response.code()} ${response.message()}")) + } + + val rpcResponse = response.body()!! + if (rpcResponse.error != null) { + return Result.failure(Exception("获取 nonce 失败: ${rpcResponse.error.message}")) + } + + val hexNonce = rpcResponse.result ?: return Result.failure(Exception("nonce 结果为空")) + val nonce = EthereumUtils.decodeUint256(hexNonce) + return Result.success(nonce) + } + + /** + * 获取 gas price + */ + private suspend fun getGasPrice(): Result { + val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + + val rpcRequest = JsonRpcRequest( + method = "eth_gasPrice", + params = emptyList() + ) + + val response = rpcApi.call(rpcRequest) + if (!response.isSuccessful || response.body() == null) { + return Result.failure(Exception("获取 gas price 失败: ${response.code()} ${response.message()}")) + } + + val rpcResponse = response.body()!! + if (rpcResponse.error != null) { + return Result.failure(Exception("获取 gas price 失败: ${rpcResponse.error.message}")) + } + + val hexGasPrice = rpcResponse.result ?: return Result.failure(Exception("gas price 结果为空")) + val gasPrice = EthereumUtils.decodeUint256(hexGasPrice) + return Result.success(gasPrice) + } + + /** + * 构建并签名交易 + */ + private fun buildTransaction( + privateKey: String, + from: String, + to: String, + data: String, + nonce: BigInteger, + gasLimit: BigInteger, + gasPrice: BigInteger + ): Map { + // 从私钥创建凭证 + val cleanPrivateKey = privateKey.removePrefix("0x") + val privateKeyBigInt = BigInteger(cleanPrivateKey, 16) + val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16)) + + // 构建原始交易 + val rawTransaction = org.web3j.crypto.RawTransaction.createTransaction( + nonce, + gasPrice, + gasLimit, + to, + data + ) + + // 签名交易(Polygon 主网 chainId = 137) + val chainId: Long = 137L + val signedTransaction = org.web3j.crypto.TransactionEncoder.signMessage(rawTransaction, chainId, credentials) + val hexValue = org.web3j.utils.Numeric.toHexString(signedTransaction) + + return mapOf( + "from" to from, + "to" to to, + "data" to data, + "nonce" to "0x${nonce.toString(16)}", + "gas" to "0x${gasLimit.toString(16)}", + "gasPrice" to "0x${gasPrice.toString(16)}", + "value" to "0x0", + "chainId" to "0x89", // Polygon 主网 chainId = 137 = 0x89 + "rawTransaction" to hexValue + ) + } + + /** + * 发送交易 + */ + private suspend fun sendTransaction( + rpcApi: EthereumRpcApi, + transaction: Map + ): Result { + val rawTransaction = transaction["rawTransaction"] as? String + ?: return Result.failure(IllegalArgumentException("rawTransaction 不能为空")) + + val rpcRequest = JsonRpcRequest( + method = "eth_sendRawTransaction", + params = listOf(rawTransaction) + ) + + val response = rpcApi.call(rpcRequest) + if (!response.isSuccessful || response.body() == null) { + return Result.failure(Exception("发送交易失败: ${response.code()} ${response.message()}")) + } + + val rpcResponse = response.body()!! + if (rpcResponse.error != null) { + return Result.failure(Exception("发送交易失败: ${rpcResponse.error.message}")) + } + + val txHash = rpcResponse.result ?: return Result.failure(Exception("交易哈希为空")) + return Result.success(txHash) + } + + /** + * 查询交易详情(用于调试和分析) + * @param txHash 交易哈希 + * @return 交易详情(JSON 字符串) + */ + suspend fun getTransactionDetails(txHash: String): Result { + return try { + if (ethereumRpcUrl.isBlank()) { + return Result.failure(IllegalStateException("未配置 Ethereum RPC URL")) + } + + val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + + // 查询交易 + val txRequest = JsonRpcRequest( + method = "eth_getTransactionByHash", + params = listOf(txHash) + ) + + val txResponse = rpcApi.call(txRequest) + if (!txResponse.isSuccessful || txResponse.body() == null) { + return Result.failure(Exception("查询交易失败: ${txResponse.code()} ${txResponse.message()}")) + } + + val txRpcResponse = txResponse.body()!! + if (txRpcResponse.error != null) { + return Result.failure(Exception("查询交易失败: ${txRpcResponse.error.message}")) + } + + val txResult = txRpcResponse.result ?: return Result.failure(Exception("交易结果为空")) + + // 查询交易回执(包含内部调用和事件日志) + val receiptRequest = JsonRpcRequest( + method = "eth_getTransactionReceipt", + params = listOf(txHash) + ) + + val receiptResponse = rpcApi.call(receiptRequest) + if (!receiptResponse.isSuccessful || receiptResponse.body() == null) { + return Result.success("交易信息: $txResult\n\n注意: 无法获取交易回执") + } + + val receiptRpcResponse = receiptResponse.body()!! + val receiptResult = if (receiptRpcResponse.error != null) { + "交易回执查询失败: ${receiptRpcResponse.error.message}" + } else { + receiptRpcResponse.result ?: "交易回执为空(可能还在打包中)" + } + + Result.success("交易信息:\n$txResult\n\n交易回执:\n$receiptResult") + } catch (e: Exception) { + logger.error("查询交易详情失败: ${e.message}", e) + Result.failure(e) + } + } } 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 87fa30c..6f7af25 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt @@ -278,5 +278,99 @@ object Eip712Encoder { return keccak256(encoded) } + + /** + * 编码 Gnosis Safe 域分隔符 + * Domain: { verifyingContract: address } + * 参考: Gnosis Safe 合约的 EIP-712 域定义 + */ + fun encodeSafeDomain( + verifyingContract: String + ): ByteArray { + val domainTypeHash = encodeType( + "EIP712Domain", + listOf( + "verifyingContract" to "address" + ) + ) + + val contractBytes = encodeAddress(verifyingContract) + + val encoded = ByteArray(32 + 32) + System.arraycopy(domainTypeHash, 0, encoded, 0, 32) + System.arraycopy(contractBytes, 0, encoded, 32, 32) + + return keccak256(encoded) + } + + /** + * 编码 Gnosis Safe SafeTx 消息哈希 + * SafeTx: { to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce } + * 参考: Gnosis Safe 合约的 SafeTx 结构 + */ + fun encodeSafeTx( + to: String, + value: BigInteger, + data: String, + operation: Int, // 0 = CALL, 1 = DELEGATECALL + safeTxGas: BigInteger, + baseGas: BigInteger, + gasPrice: BigInteger, + gasToken: String, + refundReceiver: String, + nonce: BigInteger + ): ByteArray { + val safeTxTypeHash = encodeType( + "SafeTx", + listOf( + "to" to "address", + "value" to "uint256", + "data" to "bytes", + "operation" to "uint8", + "safeTxGas" to "uint256", + "baseGas" to "uint256", + "gasPrice" to "uint256", + "gasToken" to "address", + "refundReceiver" to "address", + "nonce" to "uint256" + ) + ) + + // 编码字段 + val toBytes = encodeAddress(to) + val valueBytes = encodeUint256(value) + // data 是 bytes 类型,需要先计算 keccak256 哈希 + val dataBytes = if (data.isBlank() || data == "0x") { + ByteArray(32) // 空 bytes 的哈希 + } else { + val cleanData = data.removePrefix("0x") + val dataByteArray = Numeric.hexStringToByteArray("0x$cleanData") + keccak256(dataByteArray) + } + val operationBytes = encodeUint256(BigInteger.valueOf(operation.toLong())) + val safeTxGasBytes = encodeUint256(safeTxGas) + val baseGasBytes = encodeUint256(baseGas) + val gasPriceBytes = encodeUint256(gasPrice) + val gasTokenBytes = encodeAddress(gasToken) + val refundReceiverBytes = encodeAddress(refundReceiver) + val nonceBytes = encodeUint256(nonce) + + // 组合所有字段 + val encoded = ByteArray(32 * 11) // 11 个字段,每个 32 字节 + var offset = 0 + System.arraycopy(safeTxTypeHash, 0, encoded, offset, 32); offset += 32 + System.arraycopy(toBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(valueBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(dataBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(operationBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(safeTxGasBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(baseGasBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(gasPriceBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(gasTokenBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(refundReceiverBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(nonceBytes, 0, encoded, offset, 32) + + return keccak256(encoded) + } } diff --git a/frontend/src/pages/PositionList.tsx b/frontend/src/pages/PositionList.tsx index 3b4753e..c78e125 100644 --- a/frontend/src/pages/PositionList.tsx +++ b/frontend/src/pages/PositionList.tsx @@ -1,8 +1,8 @@ import { useEffect, useState, useMemo } from 'react' -import { Card, Table, Tag, message, Space, Input, Radio, Select, Button, Row, Col, Empty, Modal, Form } from 'antd' +import { Card, Table, Tag, message, Space, Input, Radio, Select, Button, Row, Col, Empty, Modal, Form, Descriptions } from 'antd' import { SearchOutlined, AppstoreOutlined, UnorderedListOutlined, UpOutlined, DownOutlined } from '@ant-design/icons' import { apiService } from '../services/api' -import type { AccountPosition, Account, PositionPushMessage, PositionSellRequest, MarketPriceResponse } from '../types' +import type { AccountPosition, Account, PositionPushMessage, PositionSellRequest, MarketPriceResponse, RedeemablePositionsSummary, PositionRedeemRequest } from '../types' import { getPositionKey } from '../types' import { useMediaQuery } from 'react-responsive' import { useWebSocketSubscription } from '../hooks/useWebSocket' @@ -32,6 +32,10 @@ const PositionList: React.FC = () => { const [form] = Form.useForm() const [submitting, setSubmitting] = useState(false) const [wsConnected, setWsConnected] = useState(false) + const [redeemModalVisible, setRedeemModalVisible] = useState(false) + const [redeemableSummary, setRedeemableSummary] = useState(null) + const [loadingRedeemableSummary, setLoadingRedeemableSummary] = useState(false) + const [redeeming, setRedeeming] = useState(false) useEffect(() => { fetchAccounts() @@ -52,6 +56,70 @@ const PositionList: React.FC = () => { } }, []) + // 当仓位数据变化时,更新可赎回统计 + useEffect(() => { + if (currentPositions.length > 0) { + fetchRedeemableSummary() + } + }, [currentPositions, selectedAccountId]) + + // 获取可赎回仓位统计 + const fetchRedeemableSummary = async () => { + setLoadingRedeemableSummary(true) + try { + const response = await apiService.accounts.getRedeemableSummary({ accountId: selectedAccountId }) + if (response.data.code === 0 && response.data.data) { + setRedeemableSummary(response.data.data) + } + } catch (error: any) { + console.error('获取可赎回统计失败:', error) + } finally { + setLoadingRedeemableSummary(false) + } + } + + // 处理赎回按钮点击 + const handleRedeemClick = async () => { + await fetchRedeemableSummary() + setRedeemModalVisible(true) + } + + // 提交赎回 + const handleRedeemSubmit = async () => { + if (!redeemableSummary || redeemableSummary.positions.length === 0) { + message.warning('没有可赎回的仓位') + return + } + + setRedeeming(true) + try { + const request: PositionRedeemRequest = { + positions: redeemableSummary.positions.map(pos => ({ + accountId: pos.accountId, + marketId: pos.marketId, + outcomeIndex: pos.outcomeIndex, + side: pos.side + })) + } + + const response = await apiService.accounts.redeemPositions(request) + if (response.data.code === 0 && response.data.data) { + const transactions = response.data.data.transactions || [] + const txHashes = transactions.map((tx: any) => tx.transactionHash.substring(0, 10) + '...').join(', ') + message.success(`赎回成功!共 ${transactions.length} 个账户,交易哈希: ${txHashes}`) + setRedeemModalVisible(false) + // 刷新可赎回统计 + await fetchRedeemableSummary() + } else { + message.error(response.data.msg || '赎回失败') + } + } catch (error: any) { + message.error('赎回失败: ' + (error.message || '未知错误')) + } finally { + setRedeeming(false) + } + } + // 订阅仓位推送 const { connected: positionConnected } = useWebSocketSubscription( 'position', @@ -228,6 +296,50 @@ const PositionList: React.FC = () => { return `${num >= 0 ? '+' : ''}${num.toFixed(2)}%` } + // 统计当前筛选后的仓位合计:开仓价值、当前价值、盈亏、已实现盈亏 + const positionTotals = useMemo(() => { + if (filteredPositions.length === 0) { + return { + totalInitialValue: 0, + totalCurrentValue: 0, + totalPnl: 0, + totalRealizedPnl: 0 + } + } + + let totalInitialValue = 0 + let totalCurrentValue = 0 + let totalPnl = 0 + let totalRealizedPnl = 0 + + filteredPositions.forEach((pos) => { + const initialValue = parseFloat(pos.initialValue || '0') + const currentValue = parseFloat(pos.currentValue || '0') + const pnl = parseFloat(pos.pnl || '0') + const realizedPnl = parseFloat(pos.realizedPnl || '0') + + if (!isNaN(initialValue)) { + totalInitialValue += initialValue + } + if (!isNaN(currentValue)) { + totalCurrentValue += currentValue + } + if (!isNaN(pnl)) { + totalPnl += pnl + } + if (!isNaN(realizedPnl)) { + totalRealizedPnl += realizedPnl + } + }) + + return { + totalInitialValue, + totalCurrentValue, + totalPnl, + totalRealizedPnl + } + }, [filteredPositions]) + // 切换卡片展开/折叠状态 const toggleCard = (cardKey: string) => { setExpandedCards(prev => { @@ -519,6 +631,12 @@ const PositionList: React.FC = () => { {formatNumber(position.avgPrice, 4)} +
+ 开仓价值 + + {formatNumber(position.initialValue, 2)} USDC + +
{positionFilter === 'current' && position.currentPrice && ( <>
@@ -610,23 +728,15 @@ const PositionList: React.FC = () => { {/* 操作按钮(移动端折叠时隐藏) */} {positionFilter === 'current' && !shouldCollapse && (
- - {position.redeemable && ( + {!position.redeemable && ( )}
@@ -756,6 +866,18 @@ const PositionList: React.FC = () => { align: 'right' as const, width: 120 }, + { + title: '开仓价值', + dataIndex: 'initialValue', + key: 'initialValue', + render: (value: string) => ( + + {formatNumber(value, 2)} USDC + + ), + align: 'right' as const, + width: 120 + }, ] // 只有当前仓位才显示当前价格和当前价值列 @@ -865,21 +987,14 @@ const PositionList: React.FC = () => { key: 'action', render: (_: any, record: AccountPosition) => ( - - {record.redeemable && ( + {!record.redeemable && ( )} @@ -982,90 +1097,160 @@ const PositionList: React.FC = () => { })) ]} /> -
- setPositionFilter(e.target.value)} - size={isMobile ? 'small' : 'middle'} - style={{ display: 'flex', gap: '4px' }} - > - +
+ setPositionFilter(e.target.value)} + size={isMobile ? 'small' : 'middle'} + style={{ display: 'flex', gap: '4px' }} + > + + + 当前仓位 + + {currentCount} + + + + + + 历史仓位 + + {historicalCount} + + + + +
+ {redeemableSummary && redeemableSummary.totalCount > 0 && ( + + )}
+ {/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(基于当前筛选后的仓位) */} + {filteredPositions.length > 0 && ( +
+ + 开仓价值合计:{' '} + + {formatNumber(positionTotals.totalInitialValue.toString(), 2)} USDC + + + + 当前价值合计:{' '} + + {positionFilter === 'current' + ? `${formatNumber(positionTotals.totalCurrentValue.toString(), 2)} USDC` + : '-'} + + + + 盈亏合计:{' '} + = 0 ? '#3f8600' : '#cf1322' + }} + > + {positionTotals.totalPnl >= 0 ? '+' : ''} + {formatNumber(positionTotals.totalPnl.toString(), 2)} USDC + + + + 已实现盈亏合计:{' '} + = 0 ? '#3f8600' : '#cf1322' + }} + > + {positionTotals.totalRealizedPnl >= 0 ? '+' : ''} + {formatNumber(positionTotals.totalRealizedPnl.toString(), 2)} USDC + + +
+ )} {(isMobile || viewMode === 'card') ? ( @@ -1289,6 +1474,122 @@ const PositionList: React.FC = () => { )} + + {/* 赎回模态框 */} + { + if (!redeeming) { + setRedeemModalVisible(false) + } + }} + onOk={handleRedeemSubmit} + okText="确认赎回" + cancelText="取消" + width={isMobile ? '90%' : 800} + destroyOnClose + confirmLoading={redeeming} + maskClosable={!redeeming} + > + {redeemableSummary && redeemableSummary.positions.length > 0 ? ( +
+ + + {redeemableSummary.totalCount} 个 + + + + {formatNumber(redeemableSummary.totalValue, 2)} USDC + + + + + {new Set(redeemableSummary.positions.map(p => p.accountId)).size} 个账户 + + + + +
+
赎回仓位列表:
+ `${record.marketId}-${record.outcomeIndex}-${index}`} + pagination={false} + size="small" + scroll={{ y: 300 }} + columns={[ + { + title: '账户', + dataIndex: 'accountName', + key: 'account', + render: (text, record) => ( + + {text || `账户 ${record.accountId}`} + + ), + width: 150 + }, + { + title: '市场', + dataIndex: 'marketTitle', + key: 'marketTitle', + render: (text, record) => text || record.marketId.substring(0, 10) + '...', + width: 200 + }, + { + title: '方向', + dataIndex: 'side', + key: 'side', + render: (side) => {side}, + width: 80 + }, + { + title: '数量', + dataIndex: 'quantity', + key: 'quantity', + align: 'right' as const, + render: (value) => formatNumber(value, 4), + width: 120 + }, + { + title: '价值 (USDC)', + dataIndex: 'value', + key: 'value', + align: 'right' as const, + render: (value) => ( + + {formatNumber(value, 2)} + + ), + width: 120 + } + ]} + /> + + +
+
+
💡 提示:
+
• 赎回将按 1:1 比例将获胜仓位换回 USDC
+
• 同一市场的多个仓位将批量赎回,节省 Gas 费用
+
• 赎回操作需要发送链上交易,请确保账户有足够的 POL 支付 Gas
+
• 赎回成功后,仓位将从当前仓位列表中移除
+
+
+ + ) : ( +
+ +
+ )} + ) } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d02615e..81acd31 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -105,6 +105,18 @@ export const apiService = { sellPosition: (data: any) => apiClient.post>('/copy-trading/accounts/positions/sell', data), + /** + * 获取可赎回仓位统计 + */ + getRedeemableSummary: (data: { accountId?: number }) => + apiClient.post>('/copy-trading/accounts/positions/redeemable-summary', data), + + /** + * 赎回仓位 + */ + redeemPositions: (data: any) => + apiClient.post>('/copy-trading/accounts/positions/redeem', data), + }, /** diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 77be423..635493b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -304,3 +304,73 @@ export interface OrderPushMessage { timestamp?: number // 推送时间戳 } +/** + * 账户赎回仓位项(包含账户ID) + */ +export interface AccountRedeemPositionItem { + accountId: number + marketId: string + outcomeIndex: number + side?: string +} + +/** + * 仓位赎回请求(支持多账户) + */ +export interface PositionRedeemRequest { + positions: AccountRedeemPositionItem[] +} + +/** + * 赎回的仓位信息 + */ +export interface RedeemedPositionInfo { + marketId: string + side: string + outcomeIndex: number + quantity: string + value: string +} + +/** + * 账户赎回交易信息 + */ +export interface AccountRedeemTransaction { + accountId: number + accountName?: string + transactionHash: string + positions: RedeemedPositionInfo[] +} + +/** + * 仓位赎回响应 + */ +export interface PositionRedeemResponse { + transactions: AccountRedeemTransaction[] + totalRedeemedValue: string + createdAt: number +} + +/** + * 可赎回仓位信息 + */ +export interface RedeemablePositionInfo { + accountId: number + accountName?: string + marketId: string + marketTitle?: string + side: string + outcomeIndex: number + quantity: string + value: string +} + +/** + * 可赎回仓位统计响应 + */ +export interface RedeemablePositionsSummary { + totalCount: number + totalValue: string + positions: RedeemablePositionInfo[] +} +