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

- 实现 Gnosis Safe EIP-712 签名逻辑
- 通过代理钱包的 execTransaction 调用 ConditionalTokens.redeemPositions
- 支持多账户批量赎回
- 前端添加赎回按钮和赎回详情弹窗
- 根据链上交易分析实现完整的 Safe 交易签名流程
This commit is contained in:
WrBug
2025-12-01 13:02:58 +08:00
parent 4e89f53c24
commit 914983acd7
8 changed files with 1394 additions and 106 deletions
@@ -278,5 +278,76 @@ class AccountController(
}
}
/**
* 获取可赎回仓位统计
*/
@PostMapping("/positions/redeemable-summary")
fun getRedeemablePositionsSummary(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<RedeemablePositionsSummary>> {
return try {
val result = runBlocking { accountService.getRedeemablePositionsSummary(request.accountId) }
result.fold(
onSuccess = { summary ->
logger.info("获取可赎回仓位统计成功: 账户=${request.accountId}, 数量=${summary.totalCount}, 价值=${summary.totalValue}")
ResponseEntity.ok(ApiResponse.success(summary))
},
onFailure = { e ->
logger.error("获取可赎回仓位统计失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("获取可赎回仓位统计失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("获取可赎回仓位统计异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("获取可赎回仓位统计失败: ${e.message}"))
}
}
/**
* 赎回仓位
*/
@PostMapping("/positions/redeem")
fun redeemPositions(@RequestBody request: PositionRedeemRequest): ResponseEntity<ApiResponse<PositionRedeemResponse>> {
return try {
// 参数验证
if (request.positions.isEmpty()) {
return ResponseEntity.ok(ApiResponse.paramError("赎回仓位列表不能为空"))
}
// 验证每个仓位项
for (item in request.positions) {
if (item.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.paramError("账户ID无效"))
}
if (item.marketId.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("市场ID不能为空"))
}
if (item.outcomeIndex < 0) {
return ResponseEntity.ok(ApiResponse.paramError("结果索引无效"))
}
}
val result = runBlocking { accountService.redeemPositions(request) }
result.fold(
onSuccess = { response ->
logger.info("成功赎回仓位: 账户数=${response.transactions.size}, 交易数=${response.transactions.size}, 总价值=${response.totalRedeemedValue}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("赎回仓位失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
is IllegalStateException -> ResponseEntity.ok(ApiResponse.businessError(e.message ?: "业务逻辑错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("赎回仓位失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("赎回仓位异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("赎回仓位失败: ${e.message}"))
}
}
}
@@ -188,3 +188,82 @@ data class MarketPriceResponse(
val midpoint: String? // 中间价
)
/**
* 仓位赎回请求
*/
data class PositionRedeemRequest(
val positions: List<AccountRedeemPositionItem> // 要赎回的仓位列表(支持多账户)
)
/**
* 账户赎回仓位项(包含账户ID
*/
data class AccountRedeemPositionItem(
val accountId: Long, // 账户ID(必需)
val marketId: String, // 市场IDconditionId
val outcomeIndex: Int, // 结果索引(0, 1, 2...
val side: String? = null // 结果名称(可选,用于显示)
)
/**
* 赎回仓位项
*/
data class RedeemPositionItem(
val marketId: String, // 市场IDconditionId
val outcomeIndex: Int, // 结果索引(0, 1, 2...
val side: String? = null // 结果名称(可选,用于显示)
)
/**
* 仓位赎回响应
*/
data class PositionRedeemResponse(
val transactions: List<AccountRedeemTransaction>, // 每个账户的赎回交易
val totalRedeemedValue: String, // 赎回总价值(USDC
val createdAt: Long // 创建时间戳
)
/**
* 账户赎回交易信息
*/
data class AccountRedeemTransaction(
val accountId: Long,
val accountName: String?,
val transactionHash: String, // 交易哈希
val positions: List<RedeemedPositionInfo> // 赎回的仓位信息
)
/**
* 赎回的仓位信息
*/
data class RedeemedPositionInfo(
val marketId: String,
val side: String,
val outcomeIndex: Int,
val quantity: String, // 赎回数量
val value: String // 赎回价值(USDC1:1
)
/**
* 可赎回仓位统计响应
*/
data class RedeemablePositionsSummary(
val totalCount: Int, // 可赎回仓位总数
val totalValue: String, // 可赎回总价值(USDC
val positions: List<RedeemablePositionInfo> // 可赎回仓位列表
)
/**
* 可赎回仓位信息
*/
data class RedeemablePositionInfo(
val accountId: Long,
val accountName: String?,
val marketId: String,
val marketTitle: String?,
val side: String,
val outcomeIndex: Int,
val quantity: String,
val value: String // 价值(USDC1:1
)
@@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.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 市场条件IDbytes32,必须是 0x 开头的 66 位十六进制字符串)
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex,例如 [1] 表示 outcome 0[2] 表示 outcome 1
* @return 交易哈希
*/
suspend fun redeemPositions(
privateKey: String,
proxyAddress: String,
conditionId: String,
indexSets: List<BigInteger>
): Result<String> {
return try {
// 如果未配置 RPC URL,返回错误
if (ethereumRpcUrl.isBlank()) {
logger.warn("未配置 Ethereum RPC URL,无法赎回仓位")
return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法赎回仓位"))
}
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
// 验证参数
if (indexSets.isEmpty()) {
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
}
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
}
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 从私钥推导实际签名地址(交易真正的 from 地址)
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
val fromAddress = credentials.address
logger.debug("赎回仓位: from=$fromAddress, proxy=$proxyAddress, conditionId=$conditionId, indexSets=$indexSets")
// 1. 构建 ConditionalTokens.redeemPositions 的调用数据
val redeemFunctionSelector = EthereumUtils.getFunctionSelector("redeemPositions(address,bytes32,bytes32,uint256[])")
// 编码 redeemPositions 参数
val encodedCollateral = EthereumUtils.encodeAddress(usdcContractAddress)
val encodedParentCollection = EthereumUtils.encodeBytes32(EMPTY_SET) // parentCollectionId 通常为全0
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
// 编码数组:offset (32字节) + length (32字节) + 每个元素 (32字节)
val arrayOffset = BigInteger.valueOf(128)
val arrayLength = BigInteger.valueOf(indexSets.size.toLong())
val encodedArrayOffset = EthereumUtils.encodeUint256(arrayOffset)
val encodedArrayLength = EthereumUtils.encodeUint256(arrayLength)
val encodedArrayElements = indexSets.joinToString("") { EthereumUtils.encodeUint256(it) }
// ConditionalTokens.redeemPositions 的调用数据
val redeemCallData = redeemFunctionSelector +
encodedCollateral +
encodedParentCollection +
encodedConditionId +
encodedArrayOffset +
encodedArrayLength +
encodedArrayElements
// 2. 构建 Proxy.execTransaction 的调用数据
// 函数签名: execTransaction(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,bytes signatures)
// 根据实际交易分析,参数如下:
// - to: ConditionalTokens 合约地址
// - value: 0
// - data: redeemPositions 的调用数据
// - operation: 0 (CALL)
// - safeTxGas: 0 (使用所有可用 gas)
// - baseGas: 0
// - gasPrice: 0 (使用当前 gas price)
// - gasToken: 0x0000...0000 (使用原生代币)
// - refundReceiver: 0x0000...0000
// - signatures: EIP-712 签名的 Safe 交易
// 获取 Proxy 的 nonce(用于构建 Safe 交易哈希)
val proxyNonceResult = getProxyNonce(proxyAddress)
val proxyNonce = proxyNonceResult.getOrElse {
logger.warn("获取 Proxy nonce 失败,使用 0: ${it.message}")
BigInteger.ZERO
}
// 构建 Safe 交易哈希(用于 EIP-712 签名)
// 使用 Gnosis Safe 的 EIP-712 签名标准
val safeTxGas = BigInteger.ZERO
val baseGas = BigInteger.ZERO
val safeGasPrice = BigInteger.ZERO // 使用不同的变量名避免冲突
val gasToken = "0x0000000000000000000000000000000000000000"
val refundReceiver = "0x0000000000000000000000000000000000000000"
// 1. 编码 Safe 域分隔符
val safeDomainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeSafeDomain(
verifyingContract = proxyAddress
)
// 2. 编码 SafeTx 消息哈希
val safeTxHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeSafeTx(
to = conditionalTokensAddress,
value = BigInteger.ZERO,
data = redeemCallData,
operation = 0, // CALL
safeTxGas = safeTxGas,
baseGas = baseGas,
gasPrice = safeGasPrice,
gasToken = gasToken,
refundReceiver = refundReceiver,
nonce = proxyNonce
)
// 3. 计算完整的结构化数据哈希
val safeTxStructuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
domainSeparator = safeDomainSeparator,
messageHash = safeTxHash
)
// 4. 使用私钥签名 Safe 交易
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val safeSignature = org.web3j.crypto.Sign.signMessage(safeTxStructuredHash, ecKeyPair, false)
// 5. 编码签名数据(Gnosis Safe 签名格式:r + s + v,每个 32 字节,共 96 字节)
// Safe 签名格式:r (32 bytes) + s (32 bytes) + v (1 byte,但需要编码为 32 字节)
val vBytes = safeSignature.v as ByteArray
val vInt = if (vBytes.isNotEmpty()) {
vBytes[0].toInt() and 0xff
} else {
0
}
// 编码为十六进制字符串(每个字段 32 字节 = 64 个十六进制字符)
val rHex = org.web3j.utils.Numeric.toHexString(safeSignature.r).removePrefix("0x").padStart(64, '0')
val sHex = org.web3j.utils.Numeric.toHexString(safeSignature.s).removePrefix("0x").padStart(64, '0')
val vHex = String.format("%064x", vInt)
// Safe 签名格式:r + s + v(每个 32 字节,共 96 字节 = 192 个十六进制字符)
val safeSignatureHex = rHex + sHex + vHex
// 3. 构建 execTransaction 的调用数据
// 函数签名: execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)
val execFunctionSelector = EthereumUtils.getFunctionSelector("execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)")
// 编码 execTransaction 参数
val encodedTo = EthereumUtils.encodeAddress(conditionalTokensAddress)
val encodedValue = EthereumUtils.encodeUint256(BigInteger.ZERO)
// data 参数(bytes 类型):offset (32字节) + length (32字节) + data (按长度,不足32字节的倍数需要padding)
// 固定参数部分:to(32) + value(32) + data_offset(32) + operation(32) + safeTxGas(32) + baseGas(32) + gasPrice(32) + gasToken(32) + refundReceiver(32) + signatures_offset(32) = 320字节
val dataOffset = BigInteger.valueOf(320L)
val redeemCallDataHex = redeemCallData.removePrefix("0x")
val dataLengthBytes = BigInteger.valueOf((redeemCallDataHex.length / 2).toLong()) // 字节数
val encodedDataOffset = EthereumUtils.encodeUint256(dataOffset)
val encodedDataLength = EthereumUtils.encodeUint256(dataLengthBytes)
// data 需要按 32 字节对齐(不足的补0)
val dataPaddedLength = ((dataLengthBytes.toInt() + 31) / 32) * 32 * 2 // 十六进制字符数
val encodedData = redeemCallDataHex.padEnd(dataPaddedLength, '0')
val encodedOperation = EthereumUtils.encodeUint256(BigInteger.ZERO) // 0 = CALL
val encodedSafeTxGas = EthereumUtils.encodeUint256(BigInteger.ZERO)
val encodedBaseGas = EthereumUtils.encodeUint256(BigInteger.ZERO)
val encodedGasPrice = EthereumUtils.encodeUint256(BigInteger.ZERO)
val encodedGasToken = EthereumUtils.encodeAddress("0x0000000000000000000000000000000000000000")
val encodedRefundReceiver = EthereumUtils.encodeAddress("0x0000000000000000000000000000000000000000")
// signatures 参数(bytes 类型):offset + length + signatures
// signatures 的 offset = dataOffset + dataLength 的 32字节对齐后的位置
val dataPaddedBytes = dataPaddedLength / 2
val signaturesOffset = BigInteger.valueOf((320 + dataPaddedBytes).toLong())
// Safe 签名长度:r (32) + s (32) + v (32) = 96 字节
val signaturesLength = BigInteger.valueOf(96L)
val encodedSignaturesOffset = EthereumUtils.encodeUint256(signaturesOffset)
val encodedSignaturesLength = EthereumUtils.encodeUint256(signaturesLength)
// signatures 需要按 32 字节对齐(96 字节已经是 32 的倍数,不需要 padding
val encodedSignatures = safeSignatureHex
// 组合 execTransaction 调用数据
val execCallData = "0x" + execFunctionSelector.removePrefix("0x") +
encodedTo +
encodedValue +
encodedDataOffset +
encodedDataLength +
encodedData +
encodedOperation +
encodedSafeTxGas +
encodedBaseGas +
encodedGasPrice +
encodedGasToken +
encodedRefundReceiver +
encodedSignaturesOffset +
encodedSignaturesLength +
encodedSignatures
// 4. 获取 EOA 的 nonce(用于发送交易)
val nonceResult = getTransactionCount(fromAddress)
val nonce = nonceResult.getOrElse {
return Result.failure(Exception("获取 nonce 失败: ${it.message}"))
}
// 5. 获取 gas price
val gasPriceResult = getGasPrice()
val gasPrice = gasPriceResult.getOrElse {
return Result.failure(Exception("获取 gas price 失败: ${it.message}"))
}
// 6. Gas limit(通过 Proxy 执行需要更多 gas,给 240 万,参考实际交易)
val gasLimit = BigInteger.valueOf(2400000)
// 7. 构建并签名交易
// 调用 Proxy 的 execTransaction
val transaction = buildTransaction(
privateKey = privateKey,
from = fromAddress,
to = proxyAddress, // 调用代理钱包
data = execCallData,
nonce = nonce,
gasLimit = gasLimit,
gasPrice = gasPrice
)
// 8. 发送交易
val txHashResult = sendTransaction(rpcApi, transaction)
txHashResult.fold(
onSuccess = { txHash ->
logger.info("赎回仓位交易已发送: txHash=$txHash, from=$fromAddress, proxy=$proxyAddress, conditionId=$conditionId, indexSets=$indexSets")
Result.success(txHash)
},
onFailure = { e ->
logger.error("发送赎回交易失败: ${e.message}", e)
Result.failure(e)
}
)
} catch (e: Exception) {
logger.error("赎回仓位失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取代理钱包的 nonce(用于构建 Safe 交易)
*/
private suspend fun getProxyNonce(proxyAddress: String): Result<BigInteger> {
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
// Gnosis Safe 的 nonce 通过调用合约的 nonce() 函数获取
val nonceFunctionSelector = EthereumUtils.getFunctionSelector("nonce()")
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to proxyAddress,
"data" to nonceFunctionSelector
),
"latest"
)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("获取 Proxy nonce 失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("获取 Proxy nonce 失败: ${rpcResponse.error.message}"))
}
val hexNonce = rpcResponse.result ?: return Result.failure(Exception("Proxy nonce 结果为空"))
val nonce = EthereumUtils.decodeUint256(hexNonce)
return Result.success(nonce)
}
/**
* 获取交易 nonce
*/
private suspend fun getTransactionCount(address: String): Result<BigInteger> {
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
val rpcRequest = JsonRpcRequest(
method = "eth_getTransactionCount",
// 使用 "pending",将未打包的待处理交易也计入 nonce,
// 避免在有挂起交易时出现 "nonce too low" 错误
params = listOf(address, "pending")
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("获取 nonce 失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("获取 nonce 失败: ${rpcResponse.error.message}"))
}
val hexNonce = rpcResponse.result ?: return Result.failure(Exception("nonce 结果为空"))
val nonce = EthereumUtils.decodeUint256(hexNonce)
return Result.success(nonce)
}
/**
* 获取 gas price
*/
private suspend fun getGasPrice(): Result<BigInteger> {
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
val rpcRequest = JsonRpcRequest(
method = "eth_gasPrice",
params = emptyList()
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("获取 gas price 失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("获取 gas price 失败: ${rpcResponse.error.message}"))
}
val hexGasPrice = rpcResponse.result ?: return Result.failure(Exception("gas price 结果为空"))
val gasPrice = EthereumUtils.decodeUint256(hexGasPrice)
return Result.success(gasPrice)
}
/**
* 构建并签名交易
*/
private fun buildTransaction(
privateKey: String,
from: String,
to: String,
data: String,
nonce: BigInteger,
gasLimit: BigInteger,
gasPrice: BigInteger
): Map<String, Any> {
// 从私钥创建凭证
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
// 构建原始交易
val rawTransaction = org.web3j.crypto.RawTransaction.createTransaction(
nonce,
gasPrice,
gasLimit,
to,
data
)
// 签名交易(Polygon 主网 chainId = 137
val chainId: Long = 137L
val signedTransaction = org.web3j.crypto.TransactionEncoder.signMessage(rawTransaction, chainId, credentials)
val hexValue = org.web3j.utils.Numeric.toHexString(signedTransaction)
return mapOf(
"from" to from,
"to" to to,
"data" to data,
"nonce" to "0x${nonce.toString(16)}",
"gas" to "0x${gasLimit.toString(16)}",
"gasPrice" to "0x${gasPrice.toString(16)}",
"value" to "0x0",
"chainId" to "0x89", // Polygon 主网 chainId = 137 = 0x89
"rawTransaction" to hexValue
)
}
/**
* 发送交易
*/
private suspend fun sendTransaction(
rpcApi: EthereumRpcApi,
transaction: Map<String, Any>
): Result<String> {
val rawTransaction = transaction["rawTransaction"] as? String
?: return Result.failure(IllegalArgumentException("rawTransaction 不能为空"))
val rpcRequest = JsonRpcRequest(
method = "eth_sendRawTransaction",
params = listOf(rawTransaction)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("发送交易失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("发送交易失败: ${rpcResponse.error.message}"))
}
val txHash = rpcResponse.result ?: return Result.failure(Exception("交易哈希为空"))
return Result.success(txHash)
}
/**
* 查询交易详情(用于调试和分析)
* @param txHash 交易哈希
* @return 交易详情(JSON 字符串)
*/
suspend fun getTransactionDetails(txHash: String): Result<String> {
return try {
if (ethereumRpcUrl.isBlank()) {
return Result.failure(IllegalStateException("未配置 Ethereum RPC URL"))
}
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
// 查询交易
val txRequest = JsonRpcRequest(
method = "eth_getTransactionByHash",
params = listOf(txHash)
)
val txResponse = rpcApi.call(txRequest)
if (!txResponse.isSuccessful || txResponse.body() == null) {
return Result.failure(Exception("查询交易失败: ${txResponse.code()} ${txResponse.message()}"))
}
val txRpcResponse = txResponse.body()!!
if (txRpcResponse.error != null) {
return Result.failure(Exception("查询交易失败: ${txRpcResponse.error.message}"))
}
val txResult = txRpcResponse.result ?: return Result.failure(Exception("交易结果为空"))
// 查询交易回执(包含内部调用和事件日志)
val receiptRequest = JsonRpcRequest(
method = "eth_getTransactionReceipt",
params = listOf(txHash)
)
val receiptResponse = rpcApi.call(receiptRequest)
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
return Result.success("交易信息: $txResult\n\n注意: 无法获取交易回执")
}
val receiptRpcResponse = receiptResponse.body()!!
val receiptResult = if (receiptRpcResponse.error != null) {
"交易回执查询失败: ${receiptRpcResponse.error.message}"
} else {
receiptRpcResponse.result ?: "交易回执为空(可能还在打包中)"
}
Result.success("交易信息:\n$txResult\n\n交易回执:\n$receiptResult")
} catch (e: Exception) {
logger.error("查询交易详情失败: ${e.message}", e)
Result.failure(e)
}
}
}
@@ -278,5 +278,99 @@ object Eip712Encoder {
return keccak256(encoded)
}
/**
* 编码 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)
}
}