import axios from "axios"; import qs from "qs"; import { HttpsProxyAgent } from "https-proxy-agent"; import { AssetType, Chain, ClobClient, OrderType, Side, SignatureTypeV2 } from "@polymarket/clob-client-v2"; import { BuilderConfig } from "@polymarket/builder-signing-sdk"; import { deriveProxyWallet, RelayerTxType, RelayClient } from "@polymarket/builder-relayer-client"; import { createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, formatUnits, http, parseUnits, } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { polygon } from "viem/chains"; import WebSocketClient from "./webSocketClient.js"; import Logs from "./logs.js"; const NODE_HTTP_PROXY = process.env.NODE_HTTP_PROXY; const proxyAgent = NODE_HTTP_PROXY ? new HttpsProxyAgent(NODE_HTTP_PROXY) : undefined; if (NODE_HTTP_PROXY) { axios.defaults.proxy = false; axios.defaults.httpAgent = proxyAgent; axios.defaults.httpsAgent = proxyAgent; } const CHAIN_ID = 137; const GAMMA_HOST = "https://gamma-api.polymarket.com"; const CLOB_HOST = "https://clob.polymarket.com"; const PUSD_ADDRESS = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"; const PUSD_DECIMALS = 6; const PROXY_FACTORY_ADDRESS = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"; /** * axios 默认配置 */ const axiosDefaultOptions = { baseURL: "", url: "", method: "GET", headers: {}, params: {}, data: {}, timeout: 10000, }; /** * 通用请求 * @param {*} options * @param {*} baseURL * @returns */ const clientRequest = async (options, baseURL) => { const { url } = options; if (!url || !baseURL) { throw new Error("url and baseURL are required"); } const mergedOptions = { ...axiosDefaultOptions, ...options, baseURL, paramsSerializer: params => qs.stringify(params, { arrayFormat: 'repeat' }) }; return axios(mergedOptions).then(res => res.data); } /** * 请求市场数据 * @param {*} options * @returns */ const requestMarketData = async (options) => { return clientRequest(options, GAMMA_HOST); } /** * 请求订单簿数据 */ const requestClobData = async (options) => { return clientRequest(options, CLOB_HOST); } const getRequiredEnv = (key) => { const value = process.env[key]; if (!value) { throw new Error(`${key} is required`); } return value; } const normalizePrivateKey = (privateKey) => { return privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`; } const getOptionalEnv = (key) => { const value = process.env[key]; return value && value.trim() ? value.trim() : undefined; } const getPolymarketFunderAddress = (accountAddress) => { return getOptionalEnv("POLYMARKET_DEPOSIT_WALLET_ADDRESS") || getOptionalEnv("POLYMARKET_FUNDER_ADDRESS") || accountAddress; } const getPolymarketSignatureType = (funderAddress, accountAddress) => { const configuredType = getOptionalEnv("POLYMARKET_SIGNATURE_TYPE"); if (configuredType) { const signatureType = SignatureTypeV2[configuredType] ?? Number(configuredType); if (!Number.isInteger(signatureType) || !SignatureTypeV2[signatureType]) { throw new Error(`POLYMARKET_SIGNATURE_TYPE is invalid: ${configuredType}`); } return signatureType; } return funderAddress.toLowerCase() === accountAddress.toLowerCase() ? SignatureTypeV2.POLY_PROXY : SignatureTypeV2.POLY_1271; } /** * 创建 viem HTTP transport * NODE_HTTP_PROXY 存在时通过 axios 代理请求 Polygon RPC * @param {string} rpcUrl * @returns {import("viem").HttpTransport} */ const createViemHttpTransport = (rpcUrl) => { if (!NODE_HTTP_PROXY) { return rpcUrl ? http(rpcUrl) : http(); } const fetchFn = async (url, init = {}) => { const headers = Object.fromEntries(new Headers(init.headers ?? {}).entries()); const response = await axios({ url, method: init.method || "POST", headers, data: init.body, transformResponse: [data => data], responseType: "text", validateStatus: () => true, }); return new Response(response.data, { status: response.status, statusText: response.statusText, headers: response.headers, }); }; return http(rpcUrl, { fetchFn }); } export const createClobClient = () => { const account = privateKeyToAccount(normalizePrivateKey(getRequiredEnv("POLYMARKET_PRIVATE_KEY"))); const signer = createWalletClient({ account, chain: polygon, transport: createViemHttpTransport(getOptionalEnv("POLYGON_RPC_URL")), }); const funderAddress = getPolymarketFunderAddress(account.address); const userApiCreds = { key: getRequiredEnv("POLYMARKET_API_KEY"), secret: getRequiredEnv("POLYMARKET_API_SECRET"), passphrase: getRequiredEnv("POLYMARKET_API_PASSPHRASE"), }; return new ClobClient({ host: CLOB_HOST, chain: Chain.POLYGON, signer, creds: userApiCreds, signatureType: getPolymarketSignatureType(funderAddress, account.address), funderAddress, throwOnError: true, }); } const createPolymarketContext = () => { const rpcUrl = getOptionalEnv("POLYGON_RPC_URL"); const transport = createViemHttpTransport(rpcUrl); const account = privateKeyToAccount(normalizePrivateKey(getRequiredEnv("POLYMARKET_PRIVATE_KEY"))); const signer = createWalletClient({ account, chain: polygon, transport }); const publicClient = createPublicClient({ chain: polygon, transport }); const creds = { key: getRequiredEnv("POLYMARKET_API_KEY"), secret: getRequiredEnv("POLYMARKET_API_SECRET"), passphrase: getRequiredEnv("POLYMARKET_API_PASSPHRASE"), }; return { account, signer, publicClient, creds }; } const normalizeWalletMode = (wallet = "both") => { const value = wallet.toLowerCase(); if (!["deposit", "proxy", "both"].includes(value)) { throw new Error("wallet must be deposit, proxy, or both", { cause: 400 }); } return value; } const createBalanceClobClient = ({ signer, creds, funderAddress, signatureType }) => { return new ClobClient({ host: CLOB_HOST, chain: Chain.POLYGON, signer, creds, signatureType, funderAddress, throwOnError: true, }); } const getChainPusdBalance = async ({ publicClient, address }) => { const balance = await publicClient.readContract({ address: PUSD_ADDRESS, abi: erc20Abi, functionName: "balanceOf", args: [address], }); return formatUnits(balance, PUSD_DECIMALS); } const getClobBalanceAllowance = async ({ signer, creds, funderAddress, signatureType }) => { const client = createBalanceClobClient({ signer, creds, funderAddress, signatureType }); return client.getBalanceAllowance({ asset_type: AssetType.COLLATERAL }); } const createBuilderConfig = () => { return new BuilderConfig({ localBuilderCreds: { key: getRequiredEnv("POLYMARKET_BUILDER_API_KEY"), secret: getRequiredEnv("POLYMARKET_BUILDER_SECRET"), passphrase: getRequiredEnv("POLYMARKET_BUILDER_PASS_PHRASE"), }, }); } /** * 创建 Polymarket builder relayer 客户端 * @param {Object} options * @param {Object} options.signer viem wallet client * @param {number} options.relayTxType relayer 交易类型 * @returns {RelayClient} */ const createRelayer = ({ signer, relayTxType = RelayerTxType.PROXY } = {}) => { const relayerUrl = getOptionalEnv("POLYMARKET_RELAYER_URL") || "https://relayer-v2.polymarket.com"; const relayer = new RelayClient(relayerUrl, CHAIN_ID, signer, createBuilderConfig(), relayTxType); if (proxyAgent) { relayer.httpClient.instance.defaults.proxy = false; relayer.httpClient.instance.defaults.httpAgent = proxyAgent; relayer.httpClient.instance.defaults.httpsAgent = proxyAgent; } return relayer; } /** * 获取 Polymarket proxy wallet 地址 * @param {Object} options * @param {string} options.ownerAddress owner 钱包地址 * @returns {string} */ const getProxyWalletAddress = ({ ownerAddress }) => { return getOptionalEnv("POLYMARKET_PROXY_WALLET_ADDRESS") || deriveProxyWallet(ownerAddress, PROXY_FACTORY_ADDRESS); } /** * 获取 Polymarket deposit wallet 地址 * @param {Object} options * @param {Object} options.signer viem wallet client * @returns {Promise} */ const getDepositWalletAddress = async ({ signer }) => { const configuredAddress = getOptionalEnv("POLYMARKET_DEPOSIT_WALLET_ADDRESS"); if (configuredAddress) { return configuredAddress; } const relayer = createRelayer({ signer }); return relayer.deriveDepositWalletAddress(); } /** * 获取 pUSD 转账所需的钱包上下文 * @returns {Promise} owner、signer、publicClient、relayer 和 proxy/deposit 钱包地址 */ const getTransferWalletContext = async () => { const { account, signer, publicClient } = createPolymarketContext(); const proxyRelayer = createRelayer({ signer, relayTxType: RelayerTxType.PROXY }); const proxyWalletAddress = getProxyWalletAddress({ ownerAddress: account.address }); const depositWalletAddress = await getDepositWalletAddress({ signer }); return { account, signer, publicClient, proxyRelayer, proxyWalletAddress, depositWalletAddress, }; } /** * 获取钱包原始 pUSD 余额 * @param {Object} options * @param {Object} options.publicClient viem public client * @param {string} options.address 钱包地址 * @returns {Promise} */ const getRawPusdBalance = ({ publicClient, address }) => { return publicClient.readContract({ address: PUSD_ADDRESS, abi: erc20Abi, functionName: "balanceOf", args: [address], }); } /** * 校验并转换转账金额为 pUSD 最小单位 * @param {string|number} amount 转账数量 * @returns {bigint} */ const normalizeTransferAmount = (amount) => { if (!amount || !Number.isFinite(Number(amount)) || Number(amount) <= 0) { throw new Error("amount must be greater than 0", { cause: 400 }); } return parseUnits(String(amount), PUSD_DECIMALS); } /** * 校验并标准化钱包转账方向 * @param {Object} options * @param {string} options.from 来源钱包类型 * @param {string} options.to 目标钱包类型 * @returns {{from: "proxy"|"deposit", to: "proxy"|"deposit"}} */ const normalizeTransferDirection = ({ from, to } = {}) => { const normalizedFrom = String(from || "").toLowerCase(); const normalizedTo = String(to || "").toLowerCase(); if ( !["proxy", "deposit"].includes(normalizedFrom) || !["proxy", "deposit"].includes(normalizedTo) || normalizedFrom === normalizedTo ) { throw new Error("Transfer direction must be proxy -> deposit or deposit -> proxy", { cause: 400 }); } return { from: normalizedFrom, to: normalizedTo }; } /** * 生成 pUSD ERC20 transfer 调用数据 * @param {Object} options * @param {string} options.to 收款钱包地址 * @param {bigint} options.amount pUSD 最小单位金额 * @returns {string} */ const createPusdTransferData = ({ to, amount }) => { return encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [to, amount], }); } /** * 统一格式化钱包转账返回结果 * @param {Object} options * @returns {Object} */ const buildTransferResult = ({ owner, from, to, sourceAddress, destinationAddress, amount, sourceBalance, transactionID, transactionHash, confirmed, }) => { return { owner, pUSD: PUSD_ADDRESS, from, to, sourceAddress, destinationAddress, amount: String(amount), sourceBalance: formatUnits(sourceBalance, PUSD_DECIMALS), transactionID, transactionHash, confirmed, }; } /** * 校验来源钱包 pUSD 余额是否足够 * @param {Object} options * @param {string} options.wallet 钱包类型 * @param {bigint} options.balance 当前余额 * @param {bigint} options.amount 转账金额 */ const ensureSufficientPusdBalance = ({ wallet, balance, amount }) => { if (balance < amount) { throw new Error(`Insufficient ${wallet} wallet pUSD balance: ${formatUnits(balance, PUSD_DECIMALS)}`, { cause: 400 }); } } /** * 根据转账方向提交 relayer 交易 * @param {Object} options * @param {"proxy"|"deposit"} options.from 来源钱包类型 * @param {"proxy"|"deposit"} options.to 目标钱包类型 * @param {Object} options.context 转账钱包上下文 * @param {string} options.data pUSD transfer 调用数据 * @param {string|number} options.amount 展示用转账数量 * @returns {Promise} */ const executePusdTransfer = ({ from, to, context, data, amount, }) => { if (from === "proxy" && to === "deposit") { return context.proxyRelayer.execute( [{ to: PUSD_ADDRESS, data, value: "0", }], `transfer ${amount} pUSD from proxy to deposit wallet`, ); } const depositRelayer = createRelayer({ signer: context.signer }); return depositRelayer.executeDepositWalletBatch( [{ target: PUSD_ADDRESS, data, value: "0", }], context.depositWalletAddress, String(Math.floor(Date.now() / 1000) + 600), ); } /** * 执行 pUSD 钱包间转账的公共流程 * @param {Object} options * @param {string|number} options.amount 转账数量 * @param {"proxy"|"deposit"} options.from 来源钱包类型 * @param {"proxy"|"deposit"} options.to 目标钱包类型 * @returns {Promise} */ const transferPusdBetweenWallets = async ({ amount, from, to, } = {}) => { const transferAmount = normalizeTransferAmount(amount); const context = await getTransferWalletContext(); const sourceAddress = from === "proxy" ? context.proxyWalletAddress : context.depositWalletAddress; const destinationAddress = to === "proxy" ? context.proxyWalletAddress : context.depositWalletAddress; const sourceBalance = await getRawPusdBalance({ publicClient: context.publicClient, address: sourceAddress, }); ensureSufficientPusdBalance({ wallet: from, balance: sourceBalance, amount: transferAmount, }); const data = createPusdTransferData({ to: destinationAddress, amount: transferAmount, }); const response = await executePusdTransfer({ from, to, context, data, amount, }); const confirmed = await response.wait(); return buildTransferResult({ owner: context.account.address, from, to, sourceAddress, destinationAddress, amount, sourceBalance, transactionID: response.transactionID, transactionHash: response.transactionHash || response.hash, confirmed, }); } /** * 获取足球联赛 * @returns {Promise} */ export const getSoccerSports = async () => { return requestMarketData({ url: "/sports" }) .then(sportsData => { return sportsData.filter(item => { const { tags } = item; const tagIds = tags.split(",").map(item => +item); return tagIds.includes(100350); }); }); } /** * 获取赛事数据 * @param {Object} options * @param {number} options.limit * @param {number} options.tag_id * @param {boolean} options.active * @param {boolean} options.closed * @param {string} options.endDateMin * @param {string} options.endDateMax * @returns {Promise} */ export const getEvents = async ({ limit = 500, tag_id = 100350, active = true, closed = false, endDateMin = "", endDateMax = "", } = {}) => { return requestMarketData({ url: "/events", params: { limit, tag_id, active, closed, end_date_min: endDateMin, end_date_max: endDateMax, } }).then(events => events.filter(item => !!item.series)); } /** * 获取订单簿数据 */ export const getOrderBook = async (tokenId) => { return requestClobData({ url: "/book", params: { token_id: tokenId, } }); } /** * 批量获取订单簿数据 * @param {Array} tokenIds * @returns {Promise} */ export const getMultipleOrderBooks = async (tokenIds) => { return requestClobData({ url: "/books", method: 'POST', headers: { 'Content-Type': 'application/json', }, data: tokenIds.map(tokenId => ({ token_id: tokenId })), }); } /** * 获取 USDC collateral 余额和授权信息 */ export const getBalanceAllowance = async ({ wallet = "both" } = {}) => { const walletMode = normalizeWalletMode(wallet); const { account, signer, publicClient, creds } = createPolymarketContext(); const wallets = []; if (walletMode === "proxy" || walletMode === "both") { wallets.push({ type: "proxy", address: getProxyWalletAddress({ ownerAddress: account.address }), signatureType: SignatureTypeV2.POLY_PROXY, }); } if (walletMode === "deposit" || walletMode === "both") { wallets.push({ type: "deposit", address: await getDepositWalletAddress({ signer }), signatureType: SignatureTypeV2.POLY_1271, }); } const results = []; for (const item of wallets) { const [chainPusdBalance, clobBalanceAllowance] = await Promise.all([ getChainPusdBalance({ publicClient, address: item.address }), getClobBalanceAllowance({ signer, creds, funderAddress: item.address, signatureType: item.signatureType, }), ]); results.push({ type: item.type, owner: account.address, address: item.address, signatureType: SignatureTypeV2[item.signatureType], chainPusdBalance, clobBalanceAllowance, }); } return results; } /** * 在 Proxy wallet 和 Deposit wallet 之间转 pUSD * @param {Object} options * @param {string|number} options.amount 转账数量 * @param {"proxy"|"deposit"} options.from 来源钱包类型 * @param {"proxy"|"deposit"} options.to 目标钱包类型 * @returns {Promise} */ export const transferWallet = async ({ amount, from, to } = {}) => { const direction = normalizeTransferDirection({ from, to }); return transferPusdBetweenWallets({ amount, from: direction.from, to: direction.to, }); } /** * 创建 Polymarket 限价挂单 */ export const createLimitOrder = async ({ tokenID, price, size, side = Side.BUY, tickSize = "0.01", negRisk = false, orderType = OrderType.GTC, postOnly = true, expiration, deferExec = false, } = {}) => { if (!tokenID) { throw new Error("tokenID is required", { cause: 400 }); } if (!Number.isFinite(Number(price))) { throw new Error("price is required", { cause: 400 }); } if (!Number.isFinite(Number(size))) { throw new Error("size is required", { cause: 400 }); } if (orderType !== OrderType.GTC && orderType !== OrderType.GTD) { throw new Error(`orderType must be ${OrderType.GTC} or ${OrderType.GTD}`, { cause: 400 }); } const client = createClobClient(); const orderData = { tokenID, price: Number(price), size: Number(size), side, ...(expiration ? { expiration: Number(expiration) } : {}), }; const orderOptions = { tickSize, negRisk }; Logs.outDev('polymarket create limit order data', orderData, orderOptions, orderType, postOnly, deferExec); return client.createAndPostOrder(orderData, orderOptions, orderType, postOnly, deferExec); } /** * 请求平台数据 * @param {*} options * @returns {Promise} */ export const platformRequest = async (options) => { const { url } = options; if (!url) { throw new Error("url is required"); } const internalToken = process.env.PPAI_INTERNAL_API_TOKEN; const mergedOptions = { ...axiosDefaultOptions, ...options, baseURL: "http://127.0.0.1:9020", headers: { ...axiosDefaultOptions.headers, ...options.headers, ...(internalToken ? { Authorization: `Bearer ${internalToken}` } : {}), }, httpAgent: null, httpsAgent: null, proxy: false, }; return axios(mergedOptions).then(res => res.data); } /** * 请求平台 POST 数据 * @param {string} url * @param {Object} data * @returns {Promise} */ export const platformPost = async (url, data) => { return platformRequest({ url, method: 'POST', headers: { 'Content-Type': 'application/json', }, data, }); } /** * 请求平台 GET 数据 * @param {string} url * @param {Object} params * @returns {Promise} */ export const platformGet = async (url, params) => { return platformRequest({ url, method: 'GET', params }); } /** * 市场 WebSocket 客户端 */ export class MarketWsClient extends WebSocketClient { #assetIds = []; constructor() { let agent; const proxy = process.env.NODE_HTTP_PROXY; if (proxy) { agent = new HttpsProxyAgent(proxy); } super("wss://ws-subscriptions-clob.polymarket.com/ws/market", { agent }); } connect() { super.connect(); this.on('open', () => { if (this.#assetIds.length > 0) { this.subscribeToTokensIds(this.#assetIds); } }); } subscribeToTokensIds(assetIds) { this.#assetIds = [...new Set([...this.#assetIds, ...assetIds])]; this.send({ operation: "subscribe", assets_ids: assetIds, }); } unsubscribeToTokensIds(assetIds) { const assetIdsSet = new Set(assetIds); this.#assetIds = this.#assetIds.filter(id => !assetIdsSet.has(id)); this.send({ operation: "unsubscribe", assets_ids: assetIds, }); } }