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? // 中间价
|
||||
)
|
||||
|
||||
/**
|
||||
* 仓位赎回请求
|
||||
*/
|
||||
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.transaction.annotation.Transactional
|
||||
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 查询该账户的活跃订单
|
||||
|
||||
@@ -400,5 +400,474 @@ class BlockchainService(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 赎回仓位
|
||||
* 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数
|
||||
*
|
||||
* 重要说明(基于实际链上交易分析):
|
||||
* - 仓位在 proxyAddress 上,需要通过代理钱包的 execTransaction 执行赎回
|
||||
* - 交易流程:EOA → Proxy.execTransaction → ConditionalTokens.redeemPositions
|
||||
* - execTransaction 是 Gnosis Safe 标准函数,需要构建 Safe 交易并签名
|
||||
*
|
||||
* 参考交易: https://polygonscan.com/tx/0xb3b4cbab668c4c764aa2e7ff6f17f56f372921fc4852ef616ce65238d73eca4e
|
||||
*
|
||||
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
|
||||
* @param proxyAddress 代理地址(Gnosis Safe 代理钱包地址)
|
||||
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
||||
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex,例如 [1] 表示 outcome 0,[2] 表示 outcome 1)
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun redeemPositions(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
conditionId: String,
|
||||
indexSets: List<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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user