Files
2026-05-31 13:49:36 +08:00

199 lines
7.6 KiB
TypeScript

/**
* Polymarket Proxy Wallet withdraw test script
*
* Uses Gnosis Safe execTransaction: EOA signs SafeTx EIP-712 hash -> call Proxy to transfer out USDC
* Does not depend on the official Polymarket API, interacts directly with the on-chain Safe + USDC contracts
*
* Usage:
* 1. Fill in CONFIG below (private key / Proxy / target address / withdraw amount)
* 2. tsx scripts/test-withdraw.ts
*
* Safety: DRY_RUN=true by default, only prints, does not send transactions. Change to false after confirming the params are correct.
*/
import { ethers } from "ethers";
import { readFileSync, existsSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
// Simple .env loader (no third-party package)
const __dirname = dirname(fileURLToPath(import.meta.url));
const ENV_FILE = resolve(__dirname, "..", ".env");
if (existsSync(ENV_FILE)) {
for (const line of readFileSync(ENV_FILE, "utf-8").split("\n")) {
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
if (!m) continue;
let val = m[2];
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (process.env[m[1]] === undefined) process.env[m[1]] = val;
}
}
// ── Config ──────────────────────────────────────────────────
// Private key and Proxy are read from .env (POLYMARKET_PRIVATE_KEY / POLYMARKET_PROXY_ADDRESS)
// This test script only tests one wallet; for production batch withdraws write a separate script that reads multi-wallet config
const CONFIG = {
// Withdraw target wallet (required)
TO_ADDRESS: "0x__TARGET_ADDRESS__",
// Withdraw amount (USDC, 6 decimals). Use 0.01 for testing
AMOUNT_USDC: "0.01",
// true = only print, do not send on-chain; false = actually send the transaction
DRY_RUN: true,
} as const;
const EOA_PRIVATE_KEY = process.env.POLYMARKET_PRIVATE_KEY || "";
const PROXY_ADDRESS = process.env.POLYMARKET_PROXY_ADDRESS || "";
if (!EOA_PRIVATE_KEY || !PROXY_ADDRESS) {
console.error("✗ .env is missing POLYMARKET_PRIVATE_KEY or POLYMARKET_PROXY_ADDRESS");
process.exit(1);
}
// ── Constants ────────────────────────────────────────────────────
const POLYGON_RPC = "https://polygon-bor-rpc.publicnode.com";
const POLYGON_CHAIN_ID = 137;
// Polymarket uses USDC.e (PoS bridge USDC), not native USDC
const USDC_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174";
const USDC_DECIMALS = 6;
// Gnosis Safe ABI (only the parts used)
const SAFE_ABI = [
"function nonce() view returns (uint256)",
"function getThreshold() view returns (uint256)",
"function getOwners() view returns (address[])",
"function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures) payable returns (bool)",
"function VERSION() view returns (string)",
];
const ERC20_ABI = [
"function balanceOf(address) view returns (uint256)",
"function transfer(address,uint256)",
"function decimals() view returns (uint8)",
];
// SafeTx EIP-712 type
const EIP712_SAFE_TX_TYPE = {
SafeTx: [
{ type: "address", name: "to" },
{ type: "uint256", name: "value" },
{ type: "bytes", name: "data" },
{ type: "uint8", name: "operation" },
{ type: "uint256", name: "safeTxGas" },
{ type: "uint256", name: "baseGas" },
{ type: "uint256", name: "gasPrice" },
{ type: "address", name: "gasToken" },
{ type: "address", name: "refundReceiver" },
{ type: "uint256", name: "nonce" },
],
};
async function main() {
console.log("─── Polymarket Proxy withdraw test ───");
console.log(`DRY_RUN: ${CONFIG.DRY_RUN}`);
const provider = new ethers.JsonRpcProvider(POLYGON_RPC, POLYGON_CHAIN_ID);
const eoa = new ethers.Wallet(EOA_PRIVATE_KEY, provider);
console.log(`EOA: ${eoa.address}`);
console.log(`Proxy: ${PROXY_ADDRESS}`);
console.log(`To: ${CONFIG.TO_ADDRESS}`);
// 1. Check Safe status
const safe = new ethers.Contract(PROXY_ADDRESS, SAFE_ABI, provider);
const [owners, threshold, safeNonce] = await Promise.all([
safe.getOwners() as Promise<string[]>,
safe.getThreshold() as Promise<bigint>,
safe.nonce() as Promise<bigint>,
]);
console.log(`Owners: ${owners.join(", ")}`);
console.log(`Threshold: ${threshold} Nonce: ${safeNonce}`);
if (!owners.map(o => o.toLowerCase()).includes(eoa.address.toLowerCase())) {
throw new Error("EOA is not an owner of the Proxy, misconfigured");
}
if (threshold !== 1n) {
throw new Error(`Proxy threshold=${threshold}, this script only supports 1/1 Safe`);
}
// 2. Check USDC balance
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
const balance = await usdc.balanceOf(PROXY_ADDRESS) as bigint;
const balanceHuman = ethers.formatUnits(balance, USDC_DECIMALS);
console.log(`Proxy USDC balance: ${balanceHuman}`);
const amountWei = ethers.parseUnits(CONFIG.AMOUNT_USDC, USDC_DECIMALS);
if (amountWei > balance) {
throw new Error(`Insufficient balance: trying to withdraw ${CONFIG.AMOUNT_USDC}, only have ${balanceHuman}`);
}
console.log(`Withdraw amount: ${CONFIG.AMOUNT_USDC} USDC (${amountWei} wei)`);
// 3. Check EOA MATIC (pays gas)
const maticBalance = await provider.getBalance(eoa.address);
console.log(`EOA MATIC: ${ethers.formatEther(maticBalance)}`);
if (maticBalance < ethers.parseEther("0.01")) {
console.warn("⚠ EOA MATIC < 0.01, may not have enough gas");
}
// 4. Build the callData for USDC.transfer(to, amount)
const usdcIface = new ethers.Interface(ERC20_ABI);
const transferData = usdcIface.encodeFunctionData("transfer", [CONFIG.TO_ADDRESS, amountWei]);
console.log(`transferData: ${transferData}`);
// 5. Assemble SafeTx
const safeTx = {
to: USDC_ADDRESS,
value: 0n,
data: transferData,
operation: 0, // CALL
safeTxGas: 0n,
baseGas: 0n,
gasPrice: 0n,
gasToken: ethers.ZeroAddress,
refundReceiver: ethers.ZeroAddress,
nonce: safeNonce,
};
// 6. EIP-712 signature
const domain = {
chainId: POLYGON_CHAIN_ID,
verifyingContract: PROXY_ADDRESS,
};
const signature = await eoa.signTypedData(domain, EIP712_SAFE_TX_TYPE, safeTx);
console.log(`SafeTx signature: ${signature}`);
if (CONFIG.DRY_RUN) {
console.log("\n✓ DRY_RUN: all params OK, no transaction sent");
console.log(" Set CONFIG.DRY_RUN = false and run again to actually withdraw");
return;
}
// 7. Send on-chain: call Proxy.execTransaction(...)
const safeWithSigner = safe.connect(eoa) as ethers.Contract;
console.log("\nSending execTransaction…");
const tx = await safeWithSigner.execTransaction(
safeTx.to,
safeTx.value,
safeTx.data,
safeTx.operation,
safeTx.safeTxGas,
safeTx.baseGas,
safeTx.gasPrice,
safeTx.gasToken,
safeTx.refundReceiver,
signature
);
console.log(`tx hash: ${tx.hash}`);
console.log("Waiting for confirmation…");
const rec = await tx.wait();
if (rec?.status === 1) {
console.log(`✓ Success block=${rec.blockNumber} gas=${rec.gasUsed}`);
const newBal = await usdc.balanceOf(PROXY_ADDRESS) as bigint;
console.log(`Proxy new balance: ${ethers.formatUnits(newBal, USDC_DECIMALS)} USDC`);
} else {
console.log("✗ tx mined but status=0 (execTransaction reverted internally)");
}
}
main().catch(err => {
console.error("✗ Failed:", err instanceof Error ? err.message : err);
process.exit(1);
});