Files
WrBug 08e1219f77 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>
2026-02-16 01:46:55 +08:00

451 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});