feat(neg-risk): WCOL 解包轮询与 Safe/Magic 一致说明
- 新增 WcolUnwrapJobService:每 20s 轮询解包 WCOL,未配置 Builder Key 时直接跳过 - AccountService:赎回流程不再等待确认与解包,由轮询统一处理;新增 runWcolUnwrapForAllAccounts - BlockchainService:unwrapWcolForProxy 注释说明 Safe/Magic 逻辑一致;waitForTransactionConfirmed 避免 inline lambda 中 continue - RelayClientService:createUnwrapWcolTx 注释说明共用逻辑;移除 Builder Relayer 调试日志 - docs/neg-risk-redeem.md:更新解包流程与 Safe/Magic 一致说明 - scripts/unwrap-wcol.js:新增脚本支持 Safe/EOA 解包与 Builder Relayer Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -727,7 +727,38 @@ class AccountService(
|
||||
throw RuntimeException("解密私钥失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包为 USDC.e。
|
||||
* 由 WcolUnwrapJobService 每 20 秒调用,赎回后无需在赎回流程内等待确认与解包。
|
||||
*/
|
||||
suspend fun runWcolUnwrapForAllAccounts() {
|
||||
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
|
||||
if (accounts.isEmpty()) return
|
||||
for (account in accounts) {
|
||||
try {
|
||||
val privateKey = decryptPrivateKey(account)
|
||||
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||
blockchainService.unwrapWcolForProxy(
|
||||
privateKey = privateKey,
|
||||
proxyAddress = account.proxyAddress,
|
||||
walletType = walletType
|
||||
).fold(
|
||||
onSuccess = { txHash ->
|
||||
if (txHash != null) {
|
||||
logger.info("轮询解包 WCOL: accountId=${account.id}, proxy=${account.proxyAddress.take(10)}..., txHash=$txHash")
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.warn("轮询解包 WCOL 失败 accountId=${account.id}: ${e.message}")
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("轮询解包 WCOL 跳过 accountId=${account.id}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密账户 API Secret
|
||||
*/
|
||||
@@ -1523,9 +1554,11 @@ class AccountService(
|
||||
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
|
||||
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// WCOL 解包由 WcolUnwrapJobService 每 20 秒轮询统一处理,赎回流程不再等待确认与解包
|
||||
|
||||
// 计算该账户的赎回总价值
|
||||
val accountTotalValue = redeemedInfo.fold(BigDecimal.ZERO) { sum, info ->
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.wrbug.polymarketbot.service.accounts
|
||||
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* WCOL 解包轮询任务
|
||||
* 每 20 秒轮询一次,遍历所有账户的代理地址:若 WCOL 余额 > 0 则解包为 USDC.e。
|
||||
* 同一时间仅允许单次执行;若上次执行未结束则本次忽略(与现有轮询逻辑一致)。
|
||||
* 若未配置 Builder API Key,直接跳过本轮(解包依赖 Relayer Gasless,未配置则无法执行)。
|
||||
*/
|
||||
@Service
|
||||
class WcolUnwrapJobService(
|
||||
private val accountService: AccountService,
|
||||
private val relayClientService: RelayClientService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(WcolUnwrapJobService::class.java)
|
||||
private val scope = kotlinx.coroutines.CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private var unwrapJob: Job? = null
|
||||
|
||||
/**
|
||||
* 每 20 秒触发一次;若未配置 Builder Key 或当前任务仍在执行则跳过本次
|
||||
*/
|
||||
@Scheduled(fixedRate = 20_000)
|
||||
fun runWcolUnwrapPolling() {
|
||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||
logger.debug("Builder API Key 未配置,跳过 WCOL 解包轮询")
|
||||
return
|
||||
}
|
||||
if (unwrapJob?.isActive == true) {
|
||||
logger.debug("上一轮 WCOL 解包任务仍在执行,跳过本次")
|
||||
return
|
||||
}
|
||||
unwrapJob = scope.launch {
|
||||
try {
|
||||
accountService.runWcolUnwrapForAllAccounts()
|
||||
} catch (e: Exception) {
|
||||
logger.error("WCOL 解包轮询异常: ${e.message}", e)
|
||||
} finally {
|
||||
unwrapJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
import kotlinx.coroutines.delay
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
@@ -54,6 +55,9 @@ class BlockchainService(
|
||||
|
||||
// ConditionalTokens 合约地址(Polygon 主网)
|
||||
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||
|
||||
// Neg Risk WrappedCollateral 合约地址(Polygon,解包后得 USDC.e)
|
||||
private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
|
||||
|
||||
// 空集合ID(用于计算collectionId)
|
||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
@@ -675,6 +679,126 @@ class BlockchainService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询等待交易上链并确认成功
|
||||
* @param txHash 交易 hash(0x 开头)
|
||||
* @param maxWaitMs 最大等待毫秒数
|
||||
* @param pollIntervalMs 轮询间隔毫秒数
|
||||
* @return 成功返回 Unit,超时或 revert 返回 Result.failure
|
||||
*/
|
||||
suspend fun waitForTransactionConfirmed(
|
||||
txHash: String,
|
||||
maxWaitMs: Long = 120_000,
|
||||
pollIntervalMs: Long = 3_000
|
||||
): Result<Unit> {
|
||||
val rpcApi = polygonRpcApi
|
||||
val start = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - start < maxWaitMs) {
|
||||
val req = JsonRpcRequest(method = "eth_getTransactionReceipt", params = listOf(txHash))
|
||||
val response = rpcApi.call(req)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
delay(pollIntervalMs)
|
||||
continue
|
||||
}
|
||||
val body = response.body()!!
|
||||
if (body.error != null) {
|
||||
delay(pollIntervalMs)
|
||||
continue
|
||||
}
|
||||
val result = body.result
|
||||
if (result == null || result.isJsonNull) {
|
||||
delay(pollIntervalMs)
|
||||
continue
|
||||
}
|
||||
val status = result.asJsonObject?.get("status")?.asString
|
||||
if (status == null) {
|
||||
delay(pollIntervalMs)
|
||||
continue
|
||||
}
|
||||
return when (status) {
|
||||
"0x1" -> Result.success(Unit)
|
||||
"0x0" -> Result.failure(Exception("交易已上链但执行失败 (revert)"))
|
||||
else -> Result.failure(Exception("交易状态异常: $status"))
|
||||
}
|
||||
}
|
||||
return Result.failure(Exception("等待交易确认超时 (${maxWaitMs}ms)"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询代理地址的 WCOL(Wrapped Collateral)余额(raw,6 位小数)
|
||||
*/
|
||||
suspend fun getWcolBalance(proxyAddress: String): Result<BigInteger> {
|
||||
val rpcApi = polygonRpcApi
|
||||
val functionSelector = "0x70a08231" // balanceOf(address)
|
||||
val paddedAddress = proxyAddress.removePrefix("0x").lowercase().padStart(64, '0')
|
||||
val data = functionSelector + paddedAddress
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to wcolContractAddress,
|
||||
"data" to data
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
return Result.failure(Exception("查询 WCOL 余额失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
val rpcResponse = response.body()!!
|
||||
if (rpcResponse.error != null) {
|
||||
return Result.failure(Exception("查询 WCOL 余额失败: ${rpcResponse.error.message}"))
|
||||
}
|
||||
val hexBalance = rpcResponse.result?.asString ?: return Result.failure(Exception("WCOL 余额结果为空"))
|
||||
val balance = EthereumUtils.decodeUint256(hexBalance)
|
||||
return Result.success(balance)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将代理钱包内的 WCOL 解包为 USDC.e(解包后转入代理地址)
|
||||
* 赎回 Neg Risk 仓位后到账为 WCOL,调用此方法可转为 USDC.e 以便显示/使用。
|
||||
*
|
||||
* Safe 与 Magic 使用同一套逻辑:同一 [createUnwrapWcolTx] + [RelayClientService.execute];
|
||||
* Safe 走 execTransaction,Magic 走 PROXY 编码,最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount),USDC.e 转入 proxyAddress。
|
||||
*
|
||||
* @param privateKey 主钱包私钥
|
||||
* @param proxyAddress 代理地址(Safe 或 Magic 代理)
|
||||
* @param walletType 钱包类型(SAFE / MAGIC),用于选择 Relayer 执行路径
|
||||
* @return 成功返回交易 hash,余额为 0 返回 null,失败返回 Result.failure
|
||||
*/
|
||||
suspend fun unwrapWcolForProxy(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
walletType: WalletType
|
||||
): Result<String?> {
|
||||
return try {
|
||||
val balanceResult = getWcolBalance(proxyAddress)
|
||||
val balance = balanceResult.getOrElse {
|
||||
logger.warn("查询 WCOL 余额失败,跳过解包: ${it.message}")
|
||||
return Result.success(null)
|
||||
}
|
||||
if (balance == BigInteger.ZERO) {
|
||||
return Result.success(null)
|
||||
}
|
||||
val unwrapTx = relayClientService.createUnwrapWcolTx(proxyAddress, balance)
|
||||
val executeResult = relayClientService.execute(privateKey, proxyAddress, unwrapTx, walletType)
|
||||
executeResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
logger.info("WCOL 解包成功: proxy=${proxyAddress.take(10)}..., txHash=$txHash")
|
||||
Result.success(txHash)
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("WCOL 解包失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("WCOL 解包异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理钱包的 nonce(用于构建 Safe 交易)
|
||||
*/
|
||||
|
||||
+25
-7
@@ -219,6 +219,31 @@ class RelayClientService(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 WCOL 解包交易(将 Wrapped Collateral 解包为 USDC.e)
|
||||
* 合约: Neg Risk WrappedCollateral 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2
|
||||
* 方法: unwrap(address _to, uint256 _amount),解包后 USDC.e 转到 _to
|
||||
*
|
||||
* Safe 与 Magic 共用此交易对象:Safe 走 [executeViaBuilderRelayer] / [executeManually](execTransaction),
|
||||
* Magic 走 [executeViaBuilderRelayerProxy](encodeProxyTransactionData),语义一致。
|
||||
*
|
||||
* @param toAddress 接收 USDC.e 的地址(通常为 proxy 自身,使余额留在代理钱包)
|
||||
* @param amountWei WCOL 数量(6 位小数对应的 raw 值,与 balanceOf 返回一致)
|
||||
* @return Safe 交易对象
|
||||
*/
|
||||
fun createUnwrapWcolTx(toAddress: String, amountWei: BigInteger): SafeTransaction {
|
||||
val functionSelector = EthereumUtils.getFunctionSelector("unwrap(address,uint256)")
|
||||
val encodedTo = EthereumUtils.encodeAddress(toAddress)
|
||||
val encodedAmount = EthereumUtils.encodeUint256(amountWei)
|
||||
val callData = "0x" + functionSelector.removePrefix("0x") + encodedTo + encodedAmount
|
||||
return SafeTransaction(
|
||||
to = negRiskWrappedCollateralAddress,
|
||||
operation = 0, // CALL
|
||||
data = callData,
|
||||
value = "0"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 MultiSend 交易(合并多个 SafeTransaction 为一笔交易)
|
||||
* 参考 TypeScript: builder-relayer-client/src/encode/safe.ts createSafeMultisendTransaction
|
||||
@@ -660,11 +685,6 @@ class RelayClientService(
|
||||
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||
val packedSignature = splitAndPackSig(safeSignature)
|
||||
|
||||
// 调试日志(地址已遮蔽)
|
||||
logger.debug("=== Builder Relayer 签名调试 ===")
|
||||
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
|
||||
logger.debug("Signature Length: ${packedSignature.length}")
|
||||
|
||||
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts)
|
||||
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
|
||||
val request = BuilderRelayerApi.TransactionRequest(
|
||||
@@ -690,8 +710,6 @@ class RelayClientService(
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
|
||||
|
||||
// 调用 Builder Relayer API(认证头通过拦截器添加)
|
||||
val response = relayerApi.submitTransaction(request)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user