feat: 实现通过代理钱包的仓位赎回功能
- 实现 Gnosis Safe EIP-712 签名逻辑 - 通过代理钱包的 execTransaction 调用 ConditionalTokens.redeemPositions - 支持多账户批量赎回 - 前端添加赎回按钮和赎回详情弹窗 - 根据链上交易分析实现完整的 Safe 交易签名流程
This commit is contained in:
@@ -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, // 市场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<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 // 赎回价值(USDC,1: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 // 价值(USDC,1: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 市场条件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<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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user