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:
WrBug
2026-02-16 01:46:55 +08:00
parent 62842f5aa8
commit 08e1219f77
6 changed files with 696 additions and 11 deletions
@@ -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 ->
@@ -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 交易 hash0x 开头)
* @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)"))
}
/**
* 查询代理地址的 WCOLWrapped Collateral)余额(raw6 位小数)
*/
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 走 execTransactionMagic 走 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 交易)
*/
@@ -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)
+10 -1
View File
@@ -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 PROXYencodeProxyTransactionData),最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount)USDC.e 转入该代理地址。
## 小结
- **Neg Risk 特殊逻辑**:赎回用 WrappedCollateral、下单用 Neg Risk Exchange;均由「是否为 Neg Risk 市场」分支处理。
+450
View File
@@ -0,0 +1,450 @@
#!/usr/bin/env node
/**
* 将 Wrapped Collateral (WCOL) 解包为 USDC.e
*
* Polygon 上 Polymarket 使用 USDC.eBridged 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 RelayerGasless,主钱包无需 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 RelayerGasless),主钱包无需 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);
});