From 08e1219f77853390d8a55c814fc78a88aebc8aae Mon Sep 17 00:00:00 2001 From: WrBug Date: Mon, 16 Feb 2026 01:46:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(neg-risk):=20WCOL=20=E8=A7=A3=E5=8C=85?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2=E4=B8=8E=20Safe/Magic=20=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .../service/accounts/AccountService.kt | 39 +- .../service/accounts/WcolUnwrapJobService.kt | 51 ++ .../service/common/BlockchainService.kt | 124 +++++ .../service/system/RelayClientService.kt | 32 +- docs/neg-risk-redeem.md | 11 +- scripts/unwrap-wcol.js | 450 ++++++++++++++++++ 6 files changed, 696 insertions(+), 11 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/WcolUnwrapJobService.kt create mode 100644 scripts/unwrap-wcol.js 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 e48e0ab..2008054 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 @@ -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 -> diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/WcolUnwrapJobService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/WcolUnwrapJobService.kt new file mode 100644 index 0000000..82e4f97 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/WcolUnwrapJobService.kt @@ -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 + } + } + } +} 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 90eb513..f7b0101 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 @@ -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 { + 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 { + 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 { + 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 交易) */ 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 5608c27..e06df54 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 @@ -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) diff --git a/docs/neg-risk-redeem.md b/docs/neg-risk-redeem.md index 07a6058..48bfed1 100644 --- a/docs/neg-risk-redeem.md +++ b/docs/neg-risk-redeem.md @@ -4,7 +4,7 @@ ### 1. 赎回(Redeem) -- **普通市场**:仓位由 **USDC** 抵押,调用 CTF 的 `redeemPositions(collateralToken, parentCollectionId, conditionId, indexSets)` 时,`collateralToken` 为 USDC 地址(Polygon: `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`)。 +- **普通市场**:仓位由 **USDC.e**(Bridged USDC)抵押,调用 CTF 的 `redeemPositions(collateralToken, parentCollectionId, conditionId, indexSets)` 时,`collateralToken` 为 USDC.e 地址(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`。 @@ -31,6 +31,15 @@ --- +## 赎回后自动解包 WCOL → USDC.e + +Neg Risk 赎回到账为 **WCOL**,若不解包则余额显示为 WCOL 而非 USDC.e。本项目中由**轮询任务**统一解包,赎回流程内不再等待确认与解包: + +- **WcolUnwrapJobService**:每 20 秒轮询一次(`@Scheduled(fixedRate = 20_000)`),遍历所有账户的代理地址,若 WCOL 余额 > 0 则调用 **BlockchainService.unwrapWcolForProxy** 解包为 USDC.e。同一时间仅允许单次执行;若上次执行未结束则本次忽略。 +- **AccountService.redeemPositions**:只负责赎回,不再在流程内等待交易确认或执行解包;解包由上述 Job 在后续轮询中处理(含重启后未解包、解包失败重试等)。 +- **BlockchainService**:提供 `getWcolBalance(proxyAddress)`、`unwrapWcolForProxy(...)`;**RelayClientService** 提供 `createUnwrapWcolTx(toAddress, amountWei)`。 +- **Safe 与 Magic 解包逻辑一致**:均使用同一 `createUnwrapWcolTx(proxyAddress, balance)` 与 `execute(privateKey, proxyAddress, unwrapTx, walletType)`;Safe 走 execTransaction(或 Builder Relayer SAFE),Magic 走 Builder Relayer PROXY(encodeProxyTransactionData),最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount),USDC.e 转入该代理地址。 + ## 小结 - **Neg Risk 特殊逻辑**:赎回用 WrappedCollateral、下单用 Neg Risk Exchange;均由「是否为 Neg Risk 市场」分支处理。 diff --git a/scripts/unwrap-wcol.js b/scripts/unwrap-wcol.js new file mode 100644 index 0000000..504fd83 --- /dev/null +++ b/scripts/unwrap-wcol.js @@ -0,0 +1,450 @@ +#!/usr/bin/env node + +/** + * 将 Wrapped Collateral (WCOL) 解包为 USDC.e + * + * Polygon 上 Polymarket 使用 USDC.e(Bridged USDC),解包后到账的为 USDC.e,在钱包/区块浏览器中显示为 USDC.e。 + * + * 合约: Polymarket Neg Risk WrappedCollateral + * Polygon: 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2 + * 方法: unwrap(address _to, uint256 _amount) + * + * 使用方式: + * 1) 从 Safe 解包(Gasless,需 Builder 凭证,主钱包无需 POL): + * PRIVATE_KEY=0x... SAFE_ADDRESS=0xB4c3... BUILDERS_API_KEY=... BUILDERS_SECRET=... BUILDERS_PASSPHRASE=... node scripts/unwrap-wcol.js + * 2) 从 Safe 解包(自付 gas,主钱包需少量 POL): + * PRIVATE_KEY=0x... SAFE_ADDRESS=0xB4c3... node scripts/unwrap-wcol.js + * 3) 从 EOA 解包(WCOL 在主钱包上): + * PRIVATE_KEY=0x... node scripts/unwrap-wcol.js + * + * 环境变量: + * PRIVATE_KEY 主钱包私钥(必需) + * SAFE_ADDRESS 若设置则从 Safe 执行 unwrap + * UNWRAP_TO_MAIN_WALLET=1 从 Safe 解包时,USDC.e 转到主钱包;不设则转到 Safe + * BUILDERS_API_KEY / BUILDERS_SECRET / BUILDERS_PASSPHRASE 三者齐备时走 Builder Relayer(Gasless,主钱包无需 POL) + * RPC_URL Polygon RPC(可选) + * RELAYER_URL Builder Relayer 地址(可选,默认 https://relayer-v2.polymarket.com) + */ + +import { ethers } from "ethers"; +import crypto from "crypto"; + +const CHAIN_ID = 137; +const RPC_URL = process.env.RPC_URL || "https://polygon-rpc.com"; +const RELAYER_URL = (process.env.RELAYER_URL || "https://relayer-v2.polymarket.com").replace(/\/$/, ""); +const WCOL_ADDRESS = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"; + +const WCOL_ABI = [ + "function balanceOf(address account) view returns (uint256)", + "function decimals() view returns (uint8)", + "function unwrap(address _to, uint256 _amount)", +]; + +// Safe 合约:nonce() +const SAFE_ABI = ["function nonce() view returns (uint256)"]; + +function normalizePrivateKey(key) { + if (!key || !key.trim()) return null; + const k = key.trim(); + return k.startsWith("0x") ? k : "0x" + k; +} + +function getPrivateKey() { + const key = normalizePrivateKey(process.env.PRIVATE_KEY); + if (!key) { + console.error("请设置环境变量 PRIVATE_KEY(主钱包私钥)"); + process.exit(1); + } + return key; +} + +// EIP-712: domain type hash for Safe (chainId + verifyingContract) +function getSafeDomainTypeHash() { + const typeStr = "EIP712Domain(uint256 chainId,address verifyingContract)"; + return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(typeStr)); +} + +function encodeSafeDomain(chainId, verifyingContract) { + const typeHash = getSafeDomainTypeHash(); + const chainIdHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(chainId).toHexString(), 32); + const contractHex = ethers.utils.hexZeroPad(verifyingContract.toLowerCase(), 32); + return ethers.utils.keccak256(ethers.utils.concat([typeHash, chainIdHex, contractHex])); +} + +// SafeTx type hash +function getSafeTxTypeHash() { + const typeStr = + "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)"; + return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(typeStr)); +} + +function encodeSafeTxMessage(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce) { + const typeHash = getSafeTxTypeHash(); + const toHex = ethers.utils.hexZeroPad(to.toLowerCase(), 32); + const valueHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(value).toHexString(), 32); + const dataHash = data && data !== "0x" ? ethers.utils.keccak256(data) : ethers.constants.HashZero; + const opHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(operation).toHexString(), 32); + const safeTxGasHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(safeTxGas).toHexString(), 32); + const baseGasHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(baseGas).toHexString(), 32); + const gasPriceHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(gasPrice).toHexString(), 32); + const gasTokenHex = ethers.utils.hexZeroPad(gasToken.toLowerCase(), 32); + const refundHex = ethers.utils.hexZeroPad(refundReceiver.toLowerCase(), 32); + const nonceHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(nonce).toHexString(), 32); + return ethers.utils.keccak256( + ethers.utils.concat([ + typeHash, + toHex, + valueHex, + dataHash, + opHex, + safeTxGasHex, + baseGasHex, + gasPriceHex, + gasTokenHex, + refundHex, + nonceHex, + ]) + ); +} + +function hashStructuredData(domainSeparator, messageHash) { + const prefix = "0x1901"; + return ethers.utils.keccak256(ethers.utils.concat([prefix, domainSeparator, messageHash])); +} + +// Gnosis Safe 签名:先对 structHash 做 personal_sign 风格(\x19Ethereum Signed Message:\n32 + hash),再对结果做 keccak256 后签名 +async function signSafeTx(wallet, structHash) { + const hashBytes = ethers.utils.arrayify(structHash); + const messagePrefix = "\x19Ethereum Signed Message:\n32"; + const prefixed = ethers.utils.concat([ + ethers.utils.toUtf8Bytes(messagePrefix), + hashBytes, + ]); + const hashToSign = ethers.utils.keccak256(prefixed); + const sig = await wallet._signingKey().signDigest(ethers.utils.arrayify(hashToSign)); + let v = sig.v; + if (v < 27) v += 27; + const r = ethers.utils.hexZeroPad(ethers.BigNumber.from(sig.r).toHexString(), 32).slice(2); + const s = ethers.utils.hexZeroPad(ethers.BigNumber.from(sig.s).toHexString(), 32).slice(2); + const vByte = v.toString(16).padStart(2, "0"); + return "0x" + r + s + vByte; +} + +// Builder Relayer 要求 Safe 签名使用调整后的 v(与后端 splitAndPackSig 一致),否则链上恢复签名者会失败 +function packSafeSignatureForRelayer(signatureHex) { + const raw = signatureHex.startsWith("0x") ? signatureHex.slice(2) : signatureHex; + if (raw.length !== 130) throw new Error("签名长度应为 65 字节 (130 个十六进制字符)"); + const r = raw.slice(0, 64); + const s = raw.slice(64, 128); + const vHex = raw.slice(128, 130); + let v = parseInt(vHex, 16); + const adjustedV = v === 0 || v === 1 ? v + 31 : v === 27 || v === 28 ? v + 4 : v; + return "0x" + r + s + adjustedV.toString(16).padStart(2, "0"); +} + +// 构建 execTransaction(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, signatures) +function buildExecTransactionCalldata(to, data, operation, signatureHex) { + const iface = new ethers.utils.Interface([ + "function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures)", + ]); + const value = 0; + const safeTxGas = 0; + const baseGas = 0; + const gasPrice = 0; + const gasToken = ethers.constants.AddressZero; + const refundReceiver = ethers.constants.AddressZero; + const sigBytes = ethers.utils.arrayify(signatureHex); + return iface.encodeFunctionData("execTransaction", [ + to, + value, + data, + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + sigBytes, + ]); +} + +function isBuildersConfigured() { + const k = process.env.BUILDERS_API_KEY?.trim(); + const s = process.env.BUILDERS_SECRET?.trim(); + const p = process.env.BUILDERS_PASSPHRASE?.trim(); + return !!(k && s && p); +} + +function buildBuilderSignature(signString, secret) { + let decodedSecret; + try { + decodedSecret = Buffer.from(secret, "base64"); + } catch { + try { + decodedSecret = Buffer.from(secret.replace(/-/g, "+").replace(/_/g, "/"), "base64"); + } catch { + decodedSecret = Buffer.from(secret, "utf8"); + } + } + const hmac = crypto.createHmac("sha256", decodedSecret); + hmac.update(signString, "utf8"); + const base64 = hmac.digest("base64"); + return base64.replace(/\+/g, "-").replace(/\//g, "_"); +} + +function getBuilderHeaders(method, path, body, apiKey, secret, passphrase) { + const timestamp = Date.now().toString(); + const bodyStr = body ?? ""; + const signString = timestamp + method + path + bodyStr; + const signature = buildBuilderSignature(signString, secret); + return { + POLY_BUILDER_SIGNATURE: signature, + POLY_BUILDER_TIMESTAMP: timestamp, + POLY_BUILDER_API_KEY: apiKey, + POLY_BUILDER_PASSPHRASE: passphrase, + }; +} + +async function relayerGetNonce(fromAddress) { + const pathForSign = "/nonce"; + const url = RELAYER_URL + "/nonce?address=" + encodeURIComponent(fromAddress) + "&type=SAFE"; + const headers = getBuilderHeaders( + "GET", + pathForSign, + "", + process.env.BUILDERS_API_KEY.trim(), + process.env.BUILDERS_SECRET.trim(), + process.env.BUILDERS_PASSPHRASE.trim() + ); + const res = await fetch(url, { method: "GET", headers }); + if (!res.ok) { + const text = await res.text(); + throw new Error("Relayer getNonce 失败: " + res.status + " " + text); + } + const data = await res.json(); + return data.nonce; +} + +async function relayerSubmit(requestBody) { + const pathForSign = "/submit"; + const body = JSON.stringify(requestBody); + const headers = { + "Content-Type": "application/json", + ...getBuilderHeaders( + "POST", + pathForSign, + body, + process.env.BUILDERS_API_KEY.trim(), + process.env.BUILDERS_SECRET.trim(), + process.env.BUILDERS_PASSPHRASE.trim() + ), + }; + const res = await fetch(RELAYER_URL + pathForSign, { method: "POST", headers, body }); + const rawText = await res.text(); + if (!res.ok) throw new Error("Relayer submit 失败: " + res.status + " " + rawText); + const data = JSON.parse(rawText); + console.log("Relayer submit 原始响应:", JSON.stringify(data, null, 2)); + return data; +} + +async function relayerGetTransaction(transactionId) { + const pathForSign = "/transaction"; + const url = RELAYER_URL + "/transaction?id=" + encodeURIComponent(transactionId); + const headers = getBuilderHeaders( + "GET", + pathForSign, + "", + process.env.BUILDERS_API_KEY.trim(), + process.env.BUILDERS_SECRET.trim(), + process.env.BUILDERS_PASSPHRASE.trim() + ); + const res = await fetch(url, { method: "GET", headers }); + const rawText = await res.text(); + if (!res.ok) throw new Error("Relayer getTransaction 失败: " + res.status + " " + rawText); + const data = JSON.parse(rawText); + return { parsed: Array.isArray(data) ? data[0] : data, raw: data }; +} + +function isSuccessState(state) { + if (!state) return false; + const s = String(state).toUpperCase(); + return s === "STATE_CONFIRMED" || s === "STATE_MINED" || s === "STATE_EXECUTED"; +} + +function isFailedState(state) { + if (!state) return false; + const s = String(state).toUpperCase(); + return s === "STATE_FAILED" || s === "STATE_INVALID"; +} + +// 链上交易 hash 应为 0x + 64 个十六进制字符 +function isChainTxHash(s) { + if (!s || typeof s !== "string") return false; + const t = s.trim(); + return /^0x[0-9a-fA-F]{64}$/.test(t); +} + +function getChainTxHashFromTx(tx) { + if (!tx) return null; + const candidates = [ + tx.transactionHash, + tx.hash, + tx.txHash, + tx.blockchainHash, + tx.chainTxHash, + tx.executionHash, + ]; + for (const c of candidates) { + if (isChainTxHash(c)) return c; + } + return null; +} + +async function pollUntilMined(transactionId, maxAttempts = 30, intervalMs = 3000) { + for (let i = 0; i < maxAttempts; i++) { + const { parsed: tx, raw: rawData } = await relayerGetTransaction(transactionId); + const state = tx?.state; + if (isSuccessState(state)) { + console.log("Relayer getTransaction 原始响应 (最终状态):", JSON.stringify(rawData, null, 2)); + const chainHash = getChainTxHashFromTx(tx); + if (chainHash) return chainHash; + return null; + } + if (isFailedState(state)) { + console.log("Relayer getTransaction 原始响应 (失败状态):", JSON.stringify(rawData, null, 2)); + const detail = tx.transactionHash ? ` txHash=${tx.transactionHash}` : ""; + const errMsg = tx.errorMessage ?? tx.error ?? tx.revertReason ?? ""; + const full = errMsg ? ` ${errMsg}` : detail; + throw new Error("Relayer 交易失败: state=" + state + full); + } + if (i === 0) console.log("等待 Relayer 确认交易,每", intervalMs / 1000, "秒查询一次..."); + console.log(` [${i + 1}/${maxAttempts}] state=${state ?? "未知"}`); + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error("等待交易确认超时"); +} + +async function main() { + const privateKey = getPrivateKey(); + const safeAddress = process.env.SAFE_ADDRESS?.trim(); + + const provider = new ethers.providers.JsonRpcProvider(RPC_URL); + const wallet = new ethers.Wallet(privateKey, provider); + const wcol = new ethers.Contract(WCOL_ADDRESS, WCOL_ABI, provider); + + const ownerAddress = wallet.address; + const targetBalanceAddress = safeAddress || ownerAddress; + const balanceRaw = await wcol.balanceOf(targetBalanceAddress); + const decimals = await wcol.decimals(); + const balanceFormatted = ethers.utils.formatUnits(balanceRaw, decimals); + + if (balanceRaw.isZero()) { + console.log(`地址 ${targetBalanceAddress} 的 WCOL 余额为 0,无需解包。`); + return; + } + + console.log(`WCOL 余额: ${balanceFormatted} (${targetBalanceAddress})`); + + if (safeAddress) { + const iface = new ethers.utils.Interface(WCOL_ABI); + // 从 Safe 解包时,USDC 默认转到 Safe;设环境变量 UNWRAP_TO_MAIN_WALLET=1 则转到主钱包 + const unwrapTo = process.env.UNWRAP_TO_MAIN_WALLET === "1" ? ownerAddress : safeAddress; + const unwrapData = iface.encodeFunctionData("unwrap", [unwrapTo, balanceRaw]); + + const safeContract = new ethers.Contract(safeAddress, SAFE_ABI, provider); + + const useRelayer = isBuildersConfigured(); + let nonce; + if (useRelayer) { + const relayerNonce = await relayerGetNonce(safeAddress); + nonce = await safeContract.nonce(); + console.log("Nonce: Relayer 返回=" + relayerNonce.toString() + ", Safe 链上=" + nonce.toString() + "(Gasless 一律用链上 nonce 签名)"); + } else { + nonce = await safeContract.nonce(); + } + + const domainSeparator = encodeSafeDomain(CHAIN_ID, safeAddress); + const zero = "0"; + const gasToken = ethers.constants.AddressZero; + const messageHash = encodeSafeTxMessage( + WCOL_ADDRESS, + zero, + unwrapData, + 0, + zero, + zero, + zero, + gasToken, + gasToken, + nonce + ); + const structHash = hashStructuredData(domainSeparator, messageHash); + const signatureHex = await signSafeTx(wallet, structHash); + + if (useRelayer) { + console.log("使用 Builder Relayer(Gasless),主钱包无需 POL"); + console.log("若链上仍报 GS026,多为 Relayer 上链前 Safe 又执行了其他交易导致 nonce 变化,可改用自付 gas(不设 BUILDERS_*)或确保此时无其他 Safe 交易。"); + const relayerSignature = packSafeSignatureForRelayer(signatureHex); + const requestBody = { + type: "SAFE", + from: ownerAddress, + to: WCOL_ADDRESS, + proxyWallet: safeAddress, + data: unwrapData, + nonce: nonce.toString(), + signature: relayerSignature, + signatureParams: { + gasPrice: "0", + operation: "0", + safeTxnGas: "0", + baseGas: "0", + gasToken: ethers.constants.AddressZero, + refundReceiver: ethers.constants.AddressZero, + }, + metadata: "unwrap WCOL to USDC.e", + }; + const result = await relayerSubmit(requestBody); + const txId = result.transactionID ?? result.transactionId ?? result.id ?? result.transaction_id; + if (!txId) { + console.log("Relayer 已提交,响应:", JSON.stringify(result, null, 2)); + return; + } + console.log("Relayer 已提交,transactionId:", txId); + const txHash = await pollUntilMined(txId); + if (txHash) { + console.log("交易已确认 (Polygon):", txHash); + } else { + console.log("Relayer 已标记为成功,但未返回链上 tx hash。可在 https://polygonscan.com 用 Safe 地址查看最新交易:", safeAddress); + console.log("若需排查,可设置 DEBUG=1 查看 Relayer 返回的完整数据"); + } + console.log("已解包", balanceFormatted, "WCOL → USDC.e,到账地址:", unwrapTo); + return; + } + + const execCalldata = buildExecTransactionCalldata(WCOL_ADDRESS, unwrapData, 0, signatureHex); + console.log("正在由 Safe 执行 unwrap,主钱包发送交易(需少量 POL 付 gas)..."); + const tx = await wallet.sendTransaction({ + to: safeAddress, + data: execCalldata, + value: 0, + gasLimit: 500000, + }); + console.log("Tx hash:", tx.hash); + const receipt = await tx.wait(); + console.log("已确认,block:", receipt.blockNumber); + console.log("已解包", balanceFormatted, "WCOL → USDC.e,到账地址:", unwrapTo); + return; + } + + console.log("正在发送 unwrap 交易,USDC.e 将转到:", ownerAddress); + const tx = await wcol.connect(wallet).unwrap(ownerAddress, balanceRaw); + console.log("Tx hash:", tx.hash); + const receipt = await tx.wait(); + console.log("已确认,block:", receipt.blockNumber); + console.log("已解包", balanceFormatted, "WCOL → USDC.e"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});