PolyTools Trade Cost 0.1.1 / src/lib/api.js

246 lines · 11,392 bytes · SHA-256 bc0ea1d6e825afe55e75bd03dba2fd8d2381a4d1e1fef13db786ebf865a33d17

1/*
2 * PolyTools — thin client for Polymarket public read APIs (no keys, no auth).
3 * Gamma https://gamma-api.polymarket.com events/markets/tags/profiles
4 * CLOB https://clob.polymarket.com order books
5 * Data https://data-api.polymarket.com/v2 positions/activity/pnl/tokens (cursor pagination, { data, pagination })
6 * Used by the background service worker (importScripts) and the journal page (<script>).
7 */
8(function (root) {
9 'use strict';
10
11 const GAMMA = 'https://gamma-api.polymarket.com';
12 const CLOB = 'https://clob.polymarket.com';
13 const DATA = 'https://data-api.polymarket.com';
14
15 class HttpError extends Error {
16 constructor(status, url, body) {
17 super(`HTTP ${status} for ${url.split('?')[0]}`);
18 this.status = status; this.url = url; this.body = body;
19 }
20 }
21
22 const sleep = (ms, signal) => new Promise((resolve, reject) => {
23 const t = setTimeout(resolve, ms);
24 if (signal) signal.addEventListener('abort', () => { clearTimeout(t); reject(new DOMException('Aborted', 'AbortError')); }, { once: true });
25 });
26
27 /**
28 * GET JSON with timeout (covers headers AND body), retries on network errors / timeouts / 429 (Retry-After) / 5xx.
29 * A user abort (o.signal) rejects with AbortError; an exhausted timeout rejects with TimeoutError so callers can
30 * tell "the user pressed Stop" from "the API is not answering".
31 */
32 async function getJson(url, opts) {
33 const o = Object.assign({ timeout: 25000, retries: 4, signal: null }, opts);
34 let attempt = 0;
35 for (;;) {
36 if (o.signal && o.signal.aborted) throw new DOMException('Aborted', 'AbortError');
37 const ctl = new AbortController();
38 const onAbort = () => ctl.abort(new DOMException('Aborted', 'AbortError'));
39 if (o.signal) o.signal.addEventListener('abort', onAbort, { once: true });
40 const timer = setTimeout(() => ctl.abort(new DOMException('Request timed out', 'TimeoutError')), o.timeout);
41 const done = () => { clearTimeout(timer); if (o.signal) o.signal.removeEventListener('abort', onAbort); };
42 let res, data;
43 try {
44 res = await fetch(url, { signal: ctl.signal, credentials: 'omit', headers: { Accept: 'application/json' } });
45 if (res.ok) data = await res.json();
46 } catch (e) {
47 done();
48 if (o.signal && o.signal.aborted) throw new DOMException('Aborted', 'AbortError');
49 if (attempt++ >= o.retries) {
50 throw ctl.signal.aborted ? new DOMException('Request timed out: ' + url.split('?')[0], 'TimeoutError') : e;
51 }
52 await sleep(600 * 2 ** attempt, o.signal);
53 continue;
54 }
55 if (res.ok) { done(); return data; }
56 if ((res.status === 429 || res.status >= 500) && attempt++ < o.retries) {
57 done();
58 const ra = Number(res.headers.get('Retry-After'));
59 await sleep(Number.isFinite(ra) && ra > 0 ? Math.min(ra, 30) * 1000 : 700 * 2 ** attempt, o.signal);
60 continue;
61 }
62 let body = '';
63 try { body = (await res.text()).slice(0, 300); } catch (_) { /* ignore */ }
64 done();
65 throw new HttpError(res.status, url, body);
66 }
67 }
68
69 const qs = (params) => Object.entries(params)
70 .filter(([, v]) => v !== undefined && v !== null && v !== '')
71 .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
72
73 const parseList = (v) => {
74 if (Array.isArray(v)) return v;
75 if (typeof v === 'string' && v.trim().startsWith('[')) { try { return JSON.parse(v); } catch (_) { return []; } }
76 return [];
77 };
78
79 // ---------------- Gamma ----------------
80 function trimMarket(m) {
81 return {
82 id: String(m.id || ''), conditionId: m.conditionId || '', question: m.question || '',
83 groupItemTitle: m.groupItemTitle || '', slug: m.slug || '',
84 outcomes: parseList(m.outcomes).map(String), tokenIds: parseList(m.clobTokenIds).map(String),
85 outcomePrices: parseList(m.outcomePrices).map(Number),
86 endDate: m.endDate || m.endDateIso || null, closed: !!m.closed, archived: !!m.archived, active: m.active !== false,
87 acceptingOrders: m.acceptingOrders !== false, enableOrderBook: m.enableOrderBook !== false,
88 feesEnabled: m.feesEnabled, feeSchedule: m.feeSchedule || null, feeType: m.feeType || null,
89 negRisk: !!m.negRisk, tick: Number(m.orderPriceMinTickSize) || null, minSize: Number(m.orderMinSize) || null,
90 holdingRewardsEnabled: !!m.holdingRewardsEnabled, sportsMarketType: m.sportsMarketType || null,
91 bestBid: m.bestBid != null ? Number(m.bestBid) : null, bestAsk: m.bestAsk != null ? Number(m.bestAsk) : null,
92 };
93 }
94
95 function trimEvent(ev) {
96 const J = root.PWL && root.PWL.journal;
97 const tags = (ev.tags || []).map((t) => ({ slug: t.slug, label: t.label }));
98 return {
99 id: String(ev.id || ''), slug: ev.slug || '', title: ev.title || '', endDate: ev.endDate || null,
100 archived: !!ev.archived, closed: !!ev.closed,
101 tags, category: J ? J.categorize(tags.map((t) => t.slug)) : null,
102 holdingRate: J ? J.holdingRateFromTags(tags) : null,
103 markets: (ev.markets || []).map(trimMarket),
104 };
105 }
106
107 async function eventBySlug(slug, opts) {
108 const ev = await getJson(`${GAMMA}/events/slug/${encodeURIComponent(slug)}`, opts);
109 return trimEvent(ev);
110 }
111
112 async function eventTags(eventId, opts) {
113 const tags = await getJson(`${GAMMA}/events/${encodeURIComponent(eventId)}/tags`, opts);
114 return (tags || []).map((t) => t.slug);
115 }
116
117 async function publicProfile(address, opts) {
118 try { return await getJson(`${GAMMA}/public-profile?${qs({ address })}`, opts); } catch (e) { return null; }
119 }
120
121 /** Accept 0x address, polymarket.com/profile/0x…, polymarket.com/@name or a bare username → proxy wallet. */
122 async function resolveUser(input, opts) {
123 const s = String(input || '').trim();
124 const addr = /(?:^|[^0-9a-fA-F])(0x[0-9a-fA-F]{40})(?![0-9a-fA-F])/.exec(s); // not the prefix of a tx hash / condition id
125 if (addr) return { address: addr[1].toLowerCase(), name: null };
126 const m = /(?:polymarket\.com\/(?:[a-z]{2}(?:-[a-z]{2,4})?\/)?@|^@?)([A-Za-z0-9_.\-]{2,64})\/?$/i.exec(s.replace(/[?#].*$/, ''));
127 if (!m) return null;
128 const name = m[1];
129 const res = await getJson(`${GAMMA}/public-search?${qs({ q: name, search_profiles: 'true', limit_per_type: 10 })}`, opts);
130 const profiles = (res && res.profiles) || [];
131 const exact = profiles.find((p) => (p.name || '').toLowerCase() === name.toLowerCase())
132 || profiles.find((p) => (p.pseudonym || '').toLowerCase() === name.toLowerCase());
133 return exact && exact.proxyWallet ? { address: exact.proxyWallet.toLowerCase(), name: exact.name || exact.pseudonym } : null;
134 }
135
136 // ---------------- CLOB ----------------
137 async function book(tokenId, opts) {
138 return getJson(`${CLOB}/book?${qs({ token_id: tokenId })}`, Object.assign({ timeout: 12000, retries: 2 }, opts));
139 }
140
141 // ---------------- Data API v2 ----------------
142 /**
143 * Walk a cursor-paginated v2 route. The cursor must be resent with the same anchor params.
144 * Stops at `max` rows. onPage(rowsSoFar) is called after each page.
145 */
146 async function paginate(path, params, o) {
147 // A 1000-row page is ~1.2 MB: give slow links a full minute per page.
148 // sink(pageRows) streams pages to the caller instead of keeping every row in memory.
149 const opts = Object.assign({ limit: 1000, max: Infinity, onPage: null, signal: null, timeout: 60000, sink: null }, o);
150 const rows = [];
151 let count = 0;
152 let cursor = null;
153 let truncated = false;
154 for (;;) {
155 const p = Object.assign({}, params, cursor ? { cursor } : { limit: opts.limit });
156 const res = await getJson(`${DATA}${path}?${qs(p)}`, { signal: opts.signal, timeout: opts.timeout });
157 const data = (res && res.data) || [];
158 const page = [];
159 for (const r of data) {
160 if (count >= opts.max) { truncated = true; break; }
161 page.push(r); count++;
162 }
163 if (opts.sink) await opts.sink(page); else for (const r of page) rows.push(r);
164 if (opts.onPage) opts.onPage(count);
165 cursor = res && res.pagination && res.pagination.next_cursor;
166 if (truncated || !cursor || !data.length) {
167 if (cursor && count >= opts.max) truncated = true;
168 break;
169 }
170 }
171 return { rows, truncated, count };
172 }
173
174 async function userStats(user, opts) {
175 const r = await getJson(`${DATA}/v2/user-stats?${qs({ user })}`, opts);
176 return r ? r.data : null;
177 }
178
179 async function userPnl(user, opts) {
180 const r = await getJson(`${DATA}/v2/user-pnl?${qs({ user, interval: 'all', fidelity: '1d' })}`, opts);
181 return r && r.data ? r.data.points || [] : [];
182 }
183
184 function positions(user, status, o) {
185 return paginate('/v2/positions', { user, status, sort_by: 'TIMESTAMP', sort_direction: 'DESC' }, o);
186 }
187
188 /**
189 * Resolve token ids → {resolved, final_price, …}. Batches of 50 per docs; runs `concurrency` requests at once.
190 * onBatch(Map) sees each batch as it lands (incremental caching); a failed batch goes to onError(ids) instead of
191 * failing the whole call, unless no onError is given.
192 */
193 async function tokens(ids, o) {
194 const opts = Object.assign({ concurrency: 4, onProgress: null, onBatch: null, onError: null, signal: null }, o);
195 const out = new Map();
196 const batches = [];
197 for (let i = 0; i < ids.length; i += 50) batches.push(ids.slice(i, i + 50));
198 let done = 0;
199 await pool(batches, opts.concurrency, async (b) => {
200 let r;
201 try {
202 r = await getJson(`${DATA}/v2/tokens?${qs({ token_id: b.join(',') })}`, { signal: opts.signal });
203 } catch (e) {
204 if ((opts.signal && opts.signal.aborted) || !opts.onError) throw e;
205 opts.onError(b, e);
206 r = null;
207 }
208 const part = new Map();
209 for (const t of (r && r.data) || []) { out.set(String(t.token_id), t); part.set(String(t.token_id), t); }
210 if (opts.onBatch && part.size) await opts.onBatch(part);
211 done += b.length;
212 if (opts.onProgress) opts.onProgress(done, ids.length);
213 });
214 return out;
215 }
216
217 const ACTIVITY_TYPES = 'TRADE,SPLIT,MERGE,REDEEM,CONVERSION,MIGRATION,REWARD,YIELD,MAKER_REBATE,TAKER_REBATE,REFERRAL_REWARD,DEPOSIT,WITHDRAWAL,TIP';
218
219 /** Full activity ledger for [start, end] (epoch seconds), oldest first, deposits/withdrawals and tips included. */
220 function activity(user, start, end, o) {
221 return paginate('/v2/activity', {
222 user, start: start || 1, end: end || 0, sort_direction: 'ASC',
223 type: ACTIVITY_TYPES, exclude_deposits_withdrawals: 'false',
224 }, o);
225 }
226
227 /** Run `fn` over items with at most `n` in flight. */
228 async function pool(items, n, fn) {
229 let i = 0;
230 const workers = Array.from({ length: Math.min(n, items.length) }, async () => {
231 while (i < items.length) {
232 const idx = i++;
233 await fn(items[idx], idx);
234 }
235 });
236 await Promise.all(workers);
237 }
238
239 const api = {
240 GAMMA, CLOB, DATA, HttpError, getJson, qs, parseList, trimEvent, trimMarket, eventBySlug, eventTags,
241 publicProfile, resolveUser, book, paginate, userStats, userPnl, positions, tokens, activity, pool, sleep,
242 };
243 root.PWL = root.PWL || {};
244 root.PWL.api = api;
245 if (typeof module !== 'undefined' && module.exports) module.exports = api;
246})(typeof globalThis !== 'undefined' ? globalThis : this);