diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt index 7368eac..e48e0ab 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt @@ -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, 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}")) + } + ) + } } // 计算该账户的赎回总价值 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt index 21e4d74..90eb513 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt @@ -587,6 +587,7 @@ class BlockchainService( * @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址) * @param conditionId 市场条件ID(bytes32,必须是 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, + isNegRisk: Boolean = false, walletType: WalletType = WalletType.SAFE ): Result { 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, Boolean>>, + walletType: WalletType = WalletType.SAFE + ): Result { + 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 交易) */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt index ceffa71..5608c27 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt @@ -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-client(client.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 合约地址(Polygon,neg-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): SafeTransaction { + fun createRedeemTx(conditionId: String, indexSets: List, 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 { + 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}") diff --git a/docs/neg-risk-redeem.md b/docs/neg-risk-redeem.md new file mode 100644 index 0000000..07a6058 --- /dev/null +++ b/docs/neg-risk-redeem.md @@ -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**。