feat: 实现通过代理钱包的仓位赎回功能

- 实现 Gnosis Safe EIP-712 签名逻辑
- 通过代理钱包的 execTransaction 调用 ConditionalTokens.redeemPositions
- 支持多账户批量赎回
- 前端添加赎回按钮和赎回详情弹窗
- 根据链上交易分析实现完整的 Safe 交易签名流程
This commit is contained in:
WrBug
2025-12-01 13:02:58 +08:00
parent 4e89f53c24
commit 914983acd7
8 changed files with 1394 additions and 106 deletions
@@ -278,5 +278,76 @@ class AccountController(
} }
} }
/**
* 获取可赎回仓位统计
*/
@PostMapping("/positions/redeemable-summary")
fun getRedeemablePositionsSummary(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<RedeemablePositionsSummary>> {
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<ApiResponse<PositionRedeemResponse>> {
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}"))
}
}
} }
@@ -188,3 +188,82 @@ data class MarketPriceResponse(
val midpoint: String? // 中间价 val midpoint: String? // 中间价
) )
/**
* 仓位赎回请求
*/
data class PositionRedeemRequest(
val positions: List<AccountRedeemPositionItem> // 要赎回的仓位列表(支持多账户)
)
/**
* 账户赎回仓位项(包含账户ID
*/
data class AccountRedeemPositionItem(
val accountId: Long, // 账户ID(必需)
val marketId: String, // 市场IDconditionId
val outcomeIndex: Int, // 结果索引(0, 1, 2...
val side: String? = null // 结果名称(可选,用于显示)
)
/**
* 赎回仓位项
*/
data class RedeemPositionItem(
val marketId: String, // 市场IDconditionId
val outcomeIndex: Int, // 结果索引(0, 1, 2...
val side: String? = null // 结果名称(可选,用于显示)
)
/**
* 仓位赎回响应
*/
data class PositionRedeemResponse(
val transactions: List<AccountRedeemTransaction>, // 每个账户的赎回交易
val totalRedeemedValue: String, // 赎回总价值(USDC
val createdAt: Long // 创建时间戳
)
/**
* 账户赎回交易信息
*/
data class AccountRedeemTransaction(
val accountId: Long,
val accountName: String?,
val transactionHash: String, // 交易哈希
val positions: List<RedeemedPositionInfo> // 赎回的仓位信息
)
/**
* 赎回的仓位信息
*/
data class RedeemedPositionInfo(
val marketId: String,
val side: String,
val outcomeIndex: Int,
val quantity: String, // 赎回数量
val value: String // 赎回价值(USDC1:1
)
/**
* 可赎回仓位统计响应
*/
data class RedeemablePositionsSummary(
val totalCount: Int, // 可赎回仓位总数
val totalValue: String, // 可赎回总价值(USDC
val positions: List<RedeemablePositionInfo> // 可赎回仓位列表
)
/**
* 可赎回仓位信息
*/
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 // 价值(USDC1:1
)
@@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal import java.math.BigDecimal
import java.math.BigInteger
/** /**
* 账户管理服务 * 账户管理服务
@@ -914,6 +915,197 @@ class AccountService(
} }
} }
/**
* 获取可赎回仓位统计
*/
suspend fun getRedeemablePositionsSummary(accountId: Long? = null): Result<com.wrbug.polymarketbot.dto.RedeemablePositionsSummary> {
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<com.wrbug.polymarketbot.dto.PositionRedeemResponse> {
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<Long, Account>()
for (accountId in positionsByAccount.keys) {
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在: $accountId"))
accounts[accountId] = account
}
// 4. 验证并收集要赎回的仓位信息(按账户分组)
val accountRedeemData = mutableMapOf<Long, MutableList<Pair<AccountPositionDto, BigInteger>>>()
val accountRedeemedInfo = mutableMapOf<Long, MutableList<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>>()
for ((accountId, requestItems) in positionsByAccount) {
val accountPositions = mutableListOf<Pair<AccountPositionDto, BigInteger>>()
val accountInfo = mutableListOf<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>()
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<com.wrbug.polymarketbot.dto.AccountRedeemTransaction>()
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 查询该账户的活跃订单 * 使用账户的 API Key 查询该账户的活跃订单
@@ -400,5 +400,474 @@ class BlockchainService(
Result.failure(e) 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 市场条件IDbytes32,必须是 0x 开头的 66 位十六进制字符串)
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex,例如 [1] 表示 outcome 0[2] 表示 outcome 1
* @return 交易哈希
*/
suspend fun redeemPositions(
privateKey: String,
proxyAddress: String,
conditionId: String,
indexSets: List<BigInteger>
): Result<String> {
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<BigInteger> {
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<BigInteger> {
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<BigInteger> {
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<String, Any> {
// 从私钥创建凭证
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<String, Any>
): Result<String> {
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<String> {
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)
}
}
} }
@@ -278,5 +278,99 @@ object Eip712Encoder {
return keccak256(encoded) 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)
}
} }
+407 -106
View File
@@ -1,8 +1,8 @@
import { useEffect, useState, useMemo } from 'react' 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 { SearchOutlined, AppstoreOutlined, UnorderedListOutlined, UpOutlined, DownOutlined } from '@ant-design/icons'
import { apiService } from '../services/api' 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 { getPositionKey } from '../types'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import { useWebSocketSubscription } from '../hooks/useWebSocket' import { useWebSocketSubscription } from '../hooks/useWebSocket'
@@ -32,6 +32,10 @@ const PositionList: React.FC = () => {
const [form] = Form.useForm() const [form] = Form.useForm()
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [wsConnected, setWsConnected] = useState(false) const [wsConnected, setWsConnected] = useState(false)
const [redeemModalVisible, setRedeemModalVisible] = useState(false)
const [redeemableSummary, setRedeemableSummary] = useState<RedeemablePositionsSummary | null>(null)
const [loadingRedeemableSummary, setLoadingRedeemableSummary] = useState(false)
const [redeeming, setRedeeming] = useState(false)
useEffect(() => { useEffect(() => {
fetchAccounts() 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<PositionPushMessage>( const { connected: positionConnected } = useWebSocketSubscription<PositionPushMessage>(
'position', 'position',
@@ -228,6 +296,50 @@ const PositionList: React.FC = () => {
return `${num >= 0 ? '+' : ''}${num.toFixed(2)}%` 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) => { const toggleCard = (cardKey: string) => {
setExpandedCards(prev => { setExpandedCards(prev => {
@@ -519,6 +631,12 @@ const PositionList: React.FC = () => {
{formatNumber(position.avgPrice, 4)} {formatNumber(position.avgPrice, 4)}
</span> </span>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ fontSize: '13px', color: '#666' }}></span>
<span style={{ fontSize: '13px', fontWeight: '500' }}>
{formatNumber(position.initialValue, 2)} USDC
</span>
</div>
{positionFilter === 'current' && position.currentPrice && ( {positionFilter === 'current' && position.currentPrice && (
<> <>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
@@ -610,23 +728,15 @@ const PositionList: React.FC = () => {
{/* 操作按钮(移动端折叠时隐藏) */} {/* 操作按钮(移动端折叠时隐藏) */}
{positionFilter === 'current' && !shouldCollapse && ( {positionFilter === 'current' && !shouldCollapse && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '8px' }}> <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '8px' }}>
<Button {!position.redeemable && (
type="primary"
danger
size="small"
block={isMobile}
onClick={() => handleSellClick(position)}
>
</Button>
{position.redeemable && (
<Button <Button
type="default" type="primary"
danger
size="small" size="small"
block={isMobile} block={isMobile}
onClick={() => message.info('赎回功能开发中')} onClick={() => handleSellClick(position)}
> >
</Button> </Button>
)} )}
</div> </div>
@@ -756,6 +866,18 @@ const PositionList: React.FC = () => {
align: 'right' as const, align: 'right' as const,
width: 120 width: 120
}, },
{
title: '开仓价值',
dataIndex: 'initialValue',
key: 'initialValue',
render: (value: string) => (
<span>
{formatNumber(value, 2)} USDC
</span>
),
align: 'right' as const,
width: 120
},
] ]
// 只有当前仓位才显示当前价格和当前价值列 // 只有当前仓位才显示当前价格和当前价值列
@@ -865,21 +987,14 @@ const PositionList: React.FC = () => {
key: 'action', key: 'action',
render: (_: any, record: AccountPosition) => ( render: (_: any, record: AccountPosition) => (
<Space size="small"> <Space size="small">
<Button {!record.redeemable && (
type="primary"
danger
size="small"
onClick={() => handleSellClick(record)}
>
</Button>
{record.redeemable && (
<Button <Button
type="default" type="primary"
danger
size="small" size="small"
onClick={() => message.info('赎回功能开发中')} onClick={() => handleSellClick(record)}
> >
</Button> </Button>
)} )}
</Space> </Space>
@@ -982,90 +1097,160 @@ const PositionList: React.FC = () => {
})) }))
]} ]}
/> />
<div style={{ <div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
background: '#f5f5f5', <div style={{
padding: '4px', background: '#f5f5f5',
borderRadius: '8px', padding: '4px',
display: 'inline-flex', borderRadius: '8px',
gap: '4px' display: 'inline-flex',
}}> gap: '4px'
<Radio.Group }}>
value={positionFilter} <Radio.Group
onChange={(e) => setPositionFilter(e.target.value)} value={positionFilter}
size={isMobile ? 'small' : 'middle'} onChange={(e) => setPositionFilter(e.target.value)}
style={{ display: 'flex', gap: '4px' }} size={isMobile ? 'small' : 'middle'}
> style={{ display: 'flex', gap: '4px' }}
<Radio.Button >
value="current" <Radio.Button
value="current"
style={{
border: 'none',
borderRadius: '6px',
padding: '8px 16px',
height: 'auto',
lineHeight: '1.5',
transition: 'all 0.3s ease',
background: positionFilter === 'current' ? '#1890ff' : 'transparent',
color: positionFilter === 'current' ? '#fff' : '#666',
fontWeight: positionFilter === 'current' ? '500' : 'normal',
boxShadow: positionFilter === 'current' ? '0 2px 4px rgba(24, 144, 255, 0.2)' : 'none'
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span></span>
<Tag
color={positionFilter === 'current' ? 'default' : 'blue'}
style={{
margin: 0,
borderRadius: '10px',
fontSize: '12px',
lineHeight: '20px',
padding: '0 8px',
background: positionFilter === 'current' ? 'rgba(255, 255, 255, 0.3)' : undefined,
color: positionFilter === 'current' ? '#fff' : undefined,
border: positionFilter === 'current' ? 'none' : undefined
}}
>
{currentCount}
</Tag>
</span>
</Radio.Button>
<Radio.Button
value="historical"
style={{
border: 'none',
borderRadius: '6px',
padding: '8px 16px',
height: 'auto',
lineHeight: '1.5',
transition: 'all 0.3s ease',
background: positionFilter === 'historical' ? '#1890ff' : 'transparent',
color: positionFilter === 'historical' ? '#fff' : '#666',
fontWeight: positionFilter === 'historical' ? '500' : 'normal',
boxShadow: positionFilter === 'historical' ? '0 2px 4px rgba(24, 144, 255, 0.2)' : 'none'
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span></span>
<Tag
color={positionFilter === 'historical' ? 'default' : 'default'}
style={{
margin: 0,
borderRadius: '10px',
fontSize: '12px',
lineHeight: '20px',
padding: '0 8px',
background: positionFilter === 'historical' ? 'rgba(255, 255, 255, 0.3)' : undefined,
color: positionFilter === 'historical' ? '#fff' : undefined,
border: positionFilter === 'historical' ? 'none' : undefined
}}
>
{historicalCount}
</Tag>
</span>
</Radio.Button>
</Radio.Group>
</div>
{redeemableSummary && redeemableSummary.totalCount > 0 && (
<Button
type="primary"
onClick={handleRedeemClick}
loading={loadingRedeemableSummary}
style={{ style={{
border: 'none', background: '#52c41a',
borderRadius: '6px', borderColor: '#52c41a'
padding: '8px 16px',
height: 'auto',
lineHeight: '1.5',
transition: 'all 0.3s ease',
background: positionFilter === 'current' ? '#1890ff' : 'transparent',
color: positionFilter === 'current' ? '#fff' : '#666',
fontWeight: positionFilter === 'current' ? '500' : 'normal',
boxShadow: positionFilter === 'current' ? '0 2px 4px rgba(24, 144, 255, 0.2)' : 'none'
}} }}
> >
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> ({redeemableSummary.totalCount}, {formatNumber(redeemableSummary.totalValue, 2)} USDC)
<span></span> </Button>
<Tag )}
color={positionFilter === 'current' ? 'default' : 'blue'}
style={{
margin: 0,
borderRadius: '10px',
fontSize: '12px',
lineHeight: '20px',
padding: '0 8px',
background: positionFilter === 'current' ? 'rgba(255, 255, 255, 0.3)' : undefined,
color: positionFilter === 'current' ? '#fff' : undefined,
border: positionFilter === 'current' ? 'none' : undefined
}}
>
{currentCount}
</Tag>
</span>
</Radio.Button>
<Radio.Button
value="historical"
style={{
border: 'none',
borderRadius: '6px',
padding: '8px 16px',
height: 'auto',
lineHeight: '1.5',
transition: 'all 0.3s ease',
background: positionFilter === 'historical' ? '#1890ff' : 'transparent',
color: positionFilter === 'historical' ? '#fff' : '#666',
fontWeight: positionFilter === 'historical' ? '500' : 'normal',
boxShadow: positionFilter === 'historical' ? '0 2px 4px rgba(24, 144, 255, 0.2)' : 'none'
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span></span>
<Tag
color={positionFilter === 'historical' ? 'default' : 'default'}
style={{
margin: 0,
borderRadius: '10px',
fontSize: '12px',
lineHeight: '20px',
padding: '0 8px',
background: positionFilter === 'historical' ? 'rgba(255, 255, 255, 0.3)' : undefined,
color: positionFilter === 'historical' ? '#fff' : undefined,
border: positionFilter === 'historical' ? 'none' : undefined
}}
>
{historicalCount}
</Tag>
</span>
</Radio.Button>
</Radio.Group>
</div> </div>
</div> </div>
{/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(基于当前筛选后的仓位) */}
{filteredPositions.length > 0 && (
<div
style={{
marginTop: '12px',
padding: '10px 16px',
borderRadius: '8px',
background: '#f5f5f5',
display: 'flex',
flexWrap: 'wrap',
gap: '16px',
fontSize: '13px',
color: '#555'
}}
>
<span>
{' '}
<span style={{ fontWeight: 600 }}>
{formatNumber(positionTotals.totalInitialValue.toString(), 2)} USDC
</span>
</span>
<span>
{' '}
<span style={{ fontWeight: 600 }}>
{positionFilter === 'current'
? `${formatNumber(positionTotals.totalCurrentValue.toString(), 2)} USDC`
: '-'}
</span>
</span>
<span>
{' '}
<span
style={{
fontWeight: 600,
color: positionTotals.totalPnl >= 0 ? '#3f8600' : '#cf1322'
}}
>
{positionTotals.totalPnl >= 0 ? '+' : ''}
{formatNumber(positionTotals.totalPnl.toString(), 2)} USDC
</span>
</span>
<span>
{' '}
<span
style={{
fontWeight: 600,
color: positionTotals.totalRealizedPnl >= 0 ? '#3f8600' : '#cf1322'
}}
>
{positionTotals.totalRealizedPnl >= 0 ? '+' : ''}
{formatNumber(positionTotals.totalRealizedPnl.toString(), 2)} USDC
</span>
</span>
</div>
)}
</div> </div>
{(isMobile || viewMode === 'card') ? ( {(isMobile || viewMode === 'card') ? (
@@ -1289,6 +1474,122 @@ const PositionList: React.FC = () => {
</Form> </Form>
)} )}
</Modal> </Modal>
{/* 赎回模态框 */}
<Modal
title="赎回仓位详情"
open={redeemModalVisible}
onCancel={() => {
if (!redeeming) {
setRedeemModalVisible(false)
}
}}
onOk={handleRedeemSubmit}
okText="确认赎回"
cancelText="取消"
width={isMobile ? '90%' : 800}
destroyOnClose
confirmLoading={redeeming}
maskClosable={!redeeming}
>
{redeemableSummary && redeemableSummary.positions.length > 0 ? (
<div>
<Descriptions bordered column={1} size="small" style={{ marginBottom: '16px' }}>
<Descriptions.Item label="可赎回仓位数量">
<Tag color="green">{redeemableSummary.totalCount} </Tag>
</Descriptions.Item>
<Descriptions.Item label="可赎回总价值">
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#52c41a' }}>
{formatNumber(redeemableSummary.totalValue, 2)} USDC
</span>
</Descriptions.Item>
<Descriptions.Item label="涉及账户">
<Tag color="blue">
{new Set(redeemableSummary.positions.map(p => p.accountId)).size}
</Tag>
</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: '16px' }}>
<div style={{ marginBottom: '8px', fontWeight: '500' }}></div>
<Table
dataSource={redeemableSummary.positions}
rowKey={(record, index) => `${record.marketId}-${record.outcomeIndex}-${index}`}
pagination={false}
size="small"
scroll={{ y: 300 }}
columns={[
{
title: '账户',
dataIndex: 'accountName',
key: 'account',
render: (text, record) => (
<span>
{text || `账户 ${record.accountId}`}
</span>
),
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) => <Tag color={getSideColor(side)}>{side}</Tag>,
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) => (
<span style={{ fontWeight: '500', color: '#52c41a' }}>
{formatNumber(value, 2)}
</span>
),
width: 120
}
]}
/>
</div>
<div style={{
marginTop: '16px',
padding: '12px',
background: '#f0f9ff',
borderRadius: '8px',
border: '1px solid #bae7ff'
}}>
<div style={{ color: '#666', fontSize: '12px', lineHeight: '1.8' }}>
<div>💡 <strong></strong></div>
<div> 1:1 USDC</div>
<div> Gas </div>
<div> POL Gas</div>
<div> </div>
</div>
</div>
</div>
) : (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Empty description="没有可赎回的仓位" />
</div>
)}
</Modal>
</div> </div>
) )
} }
+12
View File
@@ -105,6 +105,18 @@ export const apiService = {
sellPosition: (data: any) => sellPosition: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/sell', data), apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/sell', data),
/**
*
*/
getRedeemableSummary: (data: { accountId?: number }) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/redeemable-summary', data),
/**
*
*/
redeemPositions: (data: any) =>
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/redeem', data),
}, },
/** /**
+70
View File
@@ -304,3 +304,73 @@ export interface OrderPushMessage {
timestamp?: number // 推送时间戳 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[]
}