fix(redeem): Neg Risk 市场使用 WrappedCollateral 赎回,并补充文档

- RelayClientService: 增加 negRiskWrappedCollateralAddress,createRedeemTx 支持 isNegRisk 选用抵押品
- BlockchainService: redeemPositions/redeemPositionsBatch 支持 isNegRisk,批量请求改为 (conditionId, indexSets, isNegRisk)
- AccountService: 赎回前按市场查询 getNegRiskByConditionId,批量与单笔均传入 isNegRisk
- docs/neg-risk-redeem.md: 新增 Neg Risk 赎回逻辑与对应 JS/TS 代码说明

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WrBug
2026-02-16 00:11:40 +08:00
co-authored by Cursor
parent 0f50939625
commit 13662dd0d5
4 changed files with 231 additions and 21 deletions
@@ -1464,21 +1464,30 @@ class AccountService(
// 按市场分组(同一市场的仓位可以批量赎回)
val positionsByMarket = positions.groupBy { it.first.marketId }
// 对每个市场执行赎回
// 获取钱包类型
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
// 解密私钥(只需解密一次)
val decryptedPrivateKey = decryptPrivateKey(account)
// 执行赎回
var lastTxHash: String? = null
for ((marketId, marketPositions) in positionsByMarket) {
val indexSets = marketPositions.map { it.second }
// 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// Safe 钱包且有多个市场:使用 MultiSend 批量赎回
if (walletTypeEnum == WalletType.SAFE && positionsByMarket.size > 1) {
val redeemRequests = mutableListOf<Triple<String, List<BigInteger>, Boolean>>()
for ((marketId, marketPositions) in positionsByMarket) {
val indexSets = marketPositions.map { it.second }
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
redeemRequests.add(Triple(marketId, indexSets, isNegRisk))
}
// 调用区块链服务赎回仓位
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
val redeemResult = blockchainService.redeemPositions(
logger.info("账户 $accountId: 使用 MultiSend 批量赎回 ${redeemRequests.size} 个市场")
val redeemResult = blockchainService.redeemPositionsBatch(
privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress,
conditionId = marketId,
indexSets = indexSets,
redeemRequests = redeemRequests,
walletType = walletTypeEnum
)
@@ -1487,10 +1496,35 @@ class AccountService(
lastTxHash = txHash
},
onFailure = { e ->
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
logger.error("账户 $accountId MultiSend 批量赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId - ${e.message}"))
}
)
} else {
// Magic 钱包或单个市场:逐笔赎回
for ((marketId, marketPositions) in positionsByMarket) {
val indexSets = marketPositions.map { it.second }
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
val redeemResult = blockchainService.redeemPositions(
privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress,
conditionId = marketId,
indexSets = indexSets,
isNegRisk = isNegRisk,
walletType = walletTypeEnum
)
redeemResult.fold(
onSuccess = { txHash ->
lastTxHash = txHash
},
onFailure = { e ->
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
}
)
}
}
// 计算该账户的赎回总价值
@@ -587,6 +587,7 @@ class BlockchainService(
* @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址)
* @param conditionId 市场条件IDbytes32,必须是 0x 开头的 66 位十六进制字符串)
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex
* @param isNegRisk 是否为 Neg Risk 市场(true 时使用 WrappedCollateral 作为抵押品)
* @param walletType 钱包类型:MAGIC 或 SAFE,用于选择执行路径
* @return 交易哈希
*/
@@ -595,6 +596,7 @@ class BlockchainService(
proxyAddress: String,
conditionId: String,
indexSets: List<BigInteger>,
isNegRisk: Boolean = false,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
@@ -608,14 +610,71 @@ class BlockchainService(
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
} catch (e: Exception) {
logger.error("赎回仓位失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 批量赎回多个市场的仓位(使用 MultiSend 合并为一笔交易)
* 仅支持 Safe 钱包类型,Magic 钱包不支持 MultiSend
*
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
* @param proxyAddress 代理地址(Safe 代理钱包地址)
* @param redeemRequests 赎回请求列表,每个元素是 (conditionId, indexSets, isNegRisk)
* @param walletType 钱包类型:仅支持 SAFE
* @return 交易哈希
*/
suspend fun redeemPositionsBatch(
privateKey: String,
proxyAddress: String,
redeemRequests: List<Triple<String, List<BigInteger>, Boolean>>,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
if (redeemRequests.isEmpty()) {
return Result.failure(IllegalArgumentException("redeemRequests 不能为空"))
}
// Magic 钱包不支持 MultiSend
if (walletType == WalletType.MAGIC) {
return Result.failure(IllegalArgumentException("Magic 钱包不支持 MultiSend 批量赎回,请使用逐笔赎回"))
}
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 验证所有 conditionId 格式
for ((conditionId, _, _) in redeemRequests) {
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误: $conditionId"))
}
}
// 创建每个市场的赎回交易(Neg Risk 市场使用 WrappedCollateral
val redeemTxs = redeemRequests.map { (conditionId, indexSets, isNegRisk) ->
if (indexSets.isEmpty()) {
throw IllegalArgumentException("indexSets 不能为空: $conditionId")
}
relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
}
// 使用 MultiSend 合并所有交易
val multiSendTx = relayClientService.createMultiSendTx(redeemTxs)
logger.info("批量赎回: 合并 ${redeemRequests.size} 个市场为一笔交易")
relayClientService.execute(privateKey, proxyAddress, multiSendTx, walletType)
} catch (e: Exception) {
logger.error("批量赎回仓位失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取代理钱包的 nonce(用于构建 Safe 交易)
*/
@@ -20,8 +20,8 @@ import java.math.BigInteger
* 如果需要真正的 Gasless 功能,需要集成 Builder Relayer API
*
* 参考:
* - TypeScript: @polymarket/builder-relayer-client
* - TypeScript: utils/redeem.ts
* - TypeScript: https://github.com/Polymarket/builder-relayer-clientclient.execute、src/encode/safe.ts MultiSend
* - 赎回 calldata 由本服务构建,官方仓库无 redeem 工具;Neg Risk 逻辑见 docs/neg-risk-redeem.md
*/
@Service
class RelayClientService(
@@ -35,9 +35,12 @@ class RelayClientService(
// ConditionalTokens 合约地址
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
// USDC.e 合约地址
// USDC.e 合约地址(普通市场抵押品)
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// Neg Risk 市场使用的 WrappedCollateral 合约地址(Polygonneg-risk-ctf-adapter
private val negRiskWrappedCollateralAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
// 空集合ID
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
@@ -45,6 +48,9 @@ class RelayClientService(
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
private val defaultProxyGasLimit = "10000000"
// Safe MultiSend 合约地址(Polygon 主网)
private val safeMultisendAddress = "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761"
// Builder Relayer API 交易类型常量
private val RELAYER_TYPE_PROXY = "PROXY"
@@ -170,19 +176,22 @@ class RelayClientService(
/**
* 创建赎回交易(支持多个 indexSets,用于批量赎回)
* 参考 TypeScript: utils/redeem.ts 的 createRedeemTx
* Neg Risk 市场使用 WrappedCollateral 作为抵押品,需传 isNegRisk=true
*
* @param conditionId 市场条件ID
* @param indexSets 索引集合列表(每个元素是 2^outcomeIndex
* @param isNegRisk 是否为 Neg Risk 市场(true 时使用 WrappedCollateral 地址)
* @return Safe 交易对象
*/
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>): SafeTransaction {
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>, isNegRisk: Boolean = false): SafeTransaction {
// 编码 redeemPositions 函数调用
val functionSelector = EthereumUtils.getFunctionSelector(
"redeemPositions(address,bytes32,bytes32,uint256[])"
)
// 编码参数
val encodedCollateral = EthereumUtils.encodeAddress(usdcContractAddress)
// Neg Risk 市场仓位由 WrappedCollateral 抵押,普通市场由 USDC 抵押
val collateralAddress = if (isNegRisk) negRiskWrappedCollateralAddress else usdcContractAddress
val encodedCollateral = EthereumUtils.encodeAddress(collateralAddress)
val encodedParentCollection = EthereumUtils.encodeBytes32(EMPTY_SET)
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
@@ -210,6 +219,73 @@ class RelayClientService(
)
}
/**
* 创建 MultiSend 交易(合并多个 SafeTransaction 为一笔交易)
* 参考 TypeScript: builder-relayer-client/src/encode/safe.ts createSafeMultisendTransaction
*
* 使用 Gnosis Safe 的 MultiSend 合约将多个交易合并为一笔 DelegateCall 交易
*
* @param safeTxs 多个 Safe 交易
* @return 合并后的 MultiSend 交易(operation = 1 = DelegateCall
*/
fun createMultiSendTx(safeTxs: List<SafeTransaction>): SafeTransaction {
if (safeTxs.isEmpty()) {
throw IllegalArgumentException("safeTxs 不能为空")
}
// 单个交易直接返回,不需要 MultiSend
if (safeTxs.size == 1) {
logger.debug("单个交易,不使用 MultiSend")
return safeTxs.first()
}
logger.debug("创建 MultiSend 交易: ${safeTxs.size} 个交易待合并")
// MultiSend 函数选择器:multiSend(bytes)
val multiSendSelector = EthereumUtils.getFunctionSelector("multiSend(bytes)")
// 编码每个交易:encodePacked([uint8 operation, address to, uint256 value, uint256 dataLength, bytes data])
// 与 builder-relayer-client encode/safe.ts 完全一致
val encodedTransactions = safeTxs.map { tx ->
val operation = tx.operation.toByte()
// address: 20 字节,右对齐(取最后 40 个十六进制字符)
val toHex = tx.to.removePrefix("0x").lowercase().padStart(40, '0').takeLast(40)
val to = EthereumUtils.hexToBytes(toHex)
// value: 32 字节大端
val valueHex = BigInteger(tx.value).toString(16).padStart(64, '0')
val value = EthereumUtils.hexToBytes(valueHex)
val dataBytes = EthereumUtils.hexToBytes(tx.data.removePrefix("0x"))
// dataLength: 32 字节大端,表示 data 的字节数
val dataLengthHex = BigInteger.valueOf(dataBytes.size.toLong()).toString(16).padStart(64, '0')
val dataLength = EthereumUtils.hexToBytes(dataLengthHex)
// encodePacked: operation(1) + to(20) + value(32) + dataLength(32) + data(variable)
byteArrayOf(operation) + to + value + dataLength + dataBytes
}
// 拼接所有交易(无 padding,与 viem concatHex 一致)
val concatenatedTransactions = encodedTransactions.reduce { acc, bytes -> acc + bytes }
val totalDataLength = concatenatedTransactions.size
// multiSend(bytes) 的 ABI 编码:offset(32) + length(32) + data(按 32 字节对齐 padding)
val paddedLength = ((totalDataLength + 31) / 32) * 32
val paddedData = concatenatedTransactions + ByteArray(paddedLength - totalDataLength)
val encodedOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
val encodedLength = EthereumUtils.encodeUint256(BigInteger.valueOf(totalDataLength.toLong()))
val encodedData = paddedData.joinToString("") { "%02x".format(it) }
val callData = "0x" + multiSendSelector.removePrefix("0x") + encodedOffset + encodedLength + encodedData
return SafeTransaction(
to = safeMultisendAddress,
operation = 1, // DelegateCall
data = callData,
value = "0"
)
}
/**
* 执行代理交易(Safe 或 Magic PROXY
* 参考 TypeScript: RelayClient.execute()
@@ -607,7 +683,11 @@ class RelayClientService(
gasToken = gasToken,
refundReceiver = refundReceiver
),
metadata = "Redeem positions via Builder Relayer"
metadata = if (safeTx.operation == 1) {
"MultiSend redeem positions via Builder Relayer"
} else {
"Redeem positions via Builder Relayer"
}
)
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
+37
View File
@@ -0,0 +1,37 @@
# Neg Risk 赎回与对应 JS/TS 代码说明
## Neg Risk 特殊逻辑
### 1. 赎回(Redeem
- **普通市场**:仓位由 **USDC** 抵押,调用 CTF 的 `redeemPositions(collateralToken, parentCollectionId, conditionId, indexSets)` 时,`collateralToken` 为 USDC 地址(Polygon: `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`)。
- **Neg Risk 市场**:仓位由 **WrappedCollateral** 抵押(neg-risk-ctf-adapter 设计),同一笔赎回必须使用 WrappedCollateral 地址(Polygon: `0x3A3BD7bb9528E159577F7C2e685CC81A765002E2`),否则链上找不到对应仓位,会得到 payout 0。
本项目中:通过 Gamma API 的 `negRisk` / `negRiskOther` 判断市场类型,赎回时对 Neg Risk 市场传 `isNegRisk=true`,在 `createRedeemTx` 中选用 WrappedCollateral 作为 `collateralToken`
### 2. 下单/签约(Order Signing
- **普通市场**:使用标准 CTF Exchange 合约签约。
- **Neg Risk 市场**:必须使用 **Neg Risk CTF Exchange** 合约签约,否则 CLOB 返回 invalid signature。
`OrderSigningService.getExchangeContract(negRisk)``CopyOrderTrackingService` 中按 `getNegRiskByConditionId` 选择 exchange。
---
## 对应的 JS/TS 代码位置
| 功能 | 仓库/来源 | 路径或说明 |
|------|-----------|------------|
| Relayer 执行、Safe 交易提交 | [Polymarket/builder-relayer-client](https://github.com/Polymarket/builder-relayer-client) | `src/client.ts``execute`)、`src/encode/safe.ts`MultiSend `createSafeMultisendTransaction` |
| 链与合约配置 | builder-relayer-client | `src/config/index.ts`Polygon/Amoy 的 SafeMultisend 等) |
| 赎回 calldata 构建 | **官方仓库无** | 官方库只负责执行传入的 `Transaction[]`,不包含 `createRedeemTx` 或 redeem 工具函数 |
| 社区赎回示例(单一 collateral | [Gist: redeem-positions](https://gist.github.com/Waawzer/5cdff342767265c2637e21607d03f6eb) | 使用 `collateralToken` 调用 `redeemPositions`**未区分 Neg Risk**(全部用同一 collateral,如 USDC |
| Neg Risk 合约与 WrappedCollateral | [Polymarket/neg-risk-ctf-adapter](https://github.com/Polymarket/neg-risk-ctf-adapter) | README、`addresses.json`137 链上 negRiskWrappedCollateral 等地址) |
| 市场是否 Neg Risk | Gamma API | 市场/事件的 `negRisk``negRiskOther` 字段,本项目中通过 `MarketService.getNegRiskByConditionId` 查询 |
---
## 小结
- **Neg Risk 特殊逻辑**:赎回用 WrappedCollateral、下单用 Neg Risk Exchange;均由「是否为 Neg Risk 市场」分支处理。
- **对应 JS 代码**:执行与 MultiSend 在 **builder-relayer-client**;赎回参数与 calldata 在**应用层**构建,官方无现成 redeem 工具;Neg Risk 的抵押品与合约见 **neg-risk-ctf-adapter****Gamma API**