PolyTools Trade Cost 0.1.1 / src/lib/api.js
| 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); |