@livo-build/runtime 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,508 @@
1
+ // Polymarket — prediction-market data + order placement on the CLOB, native to
2
+ // the runtime (no @polymarket/clob-client, no ethers). Reads (markets, prices,
3
+ // books) hit the public Gamma + CLOB REST APIs with no auth, so they work in
4
+ // bots AND frontends. Trading signs orders with the runtime's EIP-712 signer and
5
+ // authenticates with Polymarket's two-layer scheme:
6
+ // L1 — an EIP-712 wallet signature, once, to create/derive API credentials.
7
+ // L2 — an HMAC-SHA256 (Web Crypto) over each request, using the L1-derived secret.
8
+ //
9
+ // The order struct, contract addresses, amount-rounding, and header construction
10
+ // are ported faithfully from the official @polymarket/clob-client (v5) so signed
11
+ // orders match what their API expects.
12
+ //
13
+ // import { Polymarket } from "@livo-build/runtime";
14
+ // const pm = new Polymarket(env);
15
+ // const markets = await pm.gammaMarkets({ active: true, limit: 5 }); // no key
16
+ // await pm.placeOrder({ tokenId, side: "BUY", price: 0.55, size: 10 }); // needs a key + USDC
17
+ //
18
+ // Trading needs POLYMARKET_PRIVATE_KEY (falls back to the bot's RELAYER_PRIVATE_KEY)
19
+ // for an EOA whose USDC + allowances live on Polygon. Proxy/Gnosis-Safe funders
20
+ // (signatureType 1/2) are supported via options but you must pass the funder address.
21
+ //
22
+ // CLOB V2 (Apr 28 2026): Polymarket migrated to CTF Exchange V2 + pUSD collateral.
23
+ // ⚠️ V2 ORDER SIGNING IS NOT YET CORRECTLY IMPLEMENTED HERE, so trading is GATED OFF
24
+ // (data reads work). Inspecting the official @polymarket/client showed V2 differs from
25
+ // V1 in ways we could not verify from this build env (Polymarket is network-blocked
26
+ // here): the Order struct changed (added timestamp/metadata(bytes32)/builder(bytes32);
27
+ // removed taker/expiration/nonce/feeRateBps), the order-domain `version` is "2"/"3"
28
+ // (not "1"), and deposit/Safe funders use ERC-7739 (TypedDataSign) + CREATE2 deposit
29
+ // wallets. The correct path is to WRAP the official @polymarket/client SDK (it handles
30
+ // all of this) — see docs/POLYMARKET-V2-IMPLEMENTATION.md. The V2 contract addresses +
31
+ // the corrected struct are captured below for that work. { allowLegacyV1: true } enables
32
+ // the deprecated V1 path (testnet/legacy only — rejected on production).
33
+ import { signTypedData } from "./eip712.js";
34
+ import { privateKeyToAddress } from "./tx.js";
35
+ const CLOB_HOST = "https://clob.polymarket.com";
36
+ const GAMMA_HOST = "https://gamma-api.polymarket.com";
37
+ const DATA_HOST = "https://data-api.polymarket.com";
38
+ // Per-chain CTF Exchange contracts (the order EIP-712 verifyingContract).
39
+ //
40
+ // V2 (default): the CLOB V2 exchanges from the official @polymarket/client config
41
+ // (Polygon mainnet). Collateral is pUSD (0xC011…2DFB). V1 (legacy, allowLegacyV1):
42
+ // the deprecated CTF Exchange the old clob-client used — no longer accepted on
43
+ // production, kept only for legacy/testnet experiments.
44
+ const CONTRACTS_V2 = {
45
+ 137: { exchange: "0xE111180000d2663C0091e4f400237545B87B996B", negRiskExchange: "0xe2222d279d744050d28e00520010520000310F59" },
46
+ };
47
+ const CONTRACTS_V1 = {
48
+ 137: { exchange: "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E", negRiskExchange: "0xC5d563A36AE78145C45a50134d48A1215220f80a" },
49
+ 80002: { exchange: "0xdFE02Eb6733538f8Ea35D585af8DE5958AD99E40", negRiskExchange: "0xC5d563A36AE78145C45a50134d48A1215220f80a" },
50
+ };
51
+ // pUSD — the V2 collateral on Polygon (6 decimals, 1:1 USDC). Fund your signer with
52
+ // this (or wrap USDC.e→pUSD via the collateral adapter) + approve the exchange.
53
+ const PUSD_COLLATERAL = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB";
54
+ const COLLATERAL_DECIMALS = 6;
55
+ const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
56
+ const L1_MESSAGE = "This message attests that I control the given wallet";
57
+ // EIP-712 Order struct (Polymarket CTF Exchange v1).
58
+ const ORDER_TYPES = {
59
+ Order: [
60
+ { name: "salt", type: "uint256" },
61
+ { name: "maker", type: "address" },
62
+ { name: "signer", type: "address" },
63
+ { name: "taker", type: "address" },
64
+ { name: "tokenId", type: "uint256" },
65
+ { name: "makerAmount", type: "uint256" },
66
+ { name: "takerAmount", type: "uint256" },
67
+ { name: "expiration", type: "uint256" },
68
+ { name: "nonce", type: "uint256" },
69
+ { name: "feeRateBps", type: "uint256" },
70
+ { name: "side", type: "uint8" },
71
+ { name: "signatureType", type: "uint8" },
72
+ ],
73
+ };
74
+ // Tick-size → decimal-place config, ported from clob-client's ROUNDING_CONFIG.
75
+ const ROUNDING = {
76
+ "0.1": { price: 1, size: 2, amount: 3 },
77
+ "0.01": { price: 2, size: 2, amount: 4 },
78
+ "0.001": { price: 3, size: 2, amount: 5 },
79
+ "0.0001": { price: 4, size: 2, amount: 6 },
80
+ };
81
+ // ---- rounding helpers (ported from clob-client utilities.ts) ----
82
+ function decimalPlaces(num) {
83
+ if (Number.isInteger(num))
84
+ return 0;
85
+ const arr = num.toString().split(".");
86
+ return arr.length <= 1 ? 0 : arr[1].length;
87
+ }
88
+ function roundNormal(num, d) {
89
+ if (decimalPlaces(num) <= d)
90
+ return num;
91
+ return Math.round((num + Number.EPSILON) * 10 ** d) / 10 ** d;
92
+ }
93
+ function roundDown(num, d) {
94
+ if (decimalPlaces(num) <= d)
95
+ return num;
96
+ return Math.floor(num * 10 ** d) / 10 ** d;
97
+ }
98
+ function roundUp(num, d) {
99
+ if (decimalPlaces(num) <= d)
100
+ return num;
101
+ return Math.ceil(num * 10 ** d) / 10 ** d;
102
+ }
103
+ // parseUnits(value, 6) without a bignum lib — value is a positive decimal string/number.
104
+ function parseUnits6(value) {
105
+ const [whole, frac = ""] = value.toString().split(".");
106
+ const fracPadded = (frac + "000000").slice(0, COLLATERAL_DECIMALS);
107
+ const combined = `${whole}${fracPadded}`.replace(/^0+(?=\d)/, "");
108
+ return combined === "" ? "0" : combined;
109
+ }
110
+ // getOrderRawAmounts, ported from clob-client order-builder/helpers.ts.
111
+ function rawAmounts(side, size, price, cfg) {
112
+ const rawPrice = roundNormal(price, cfg.price);
113
+ if (side === "BUY") {
114
+ const rawTakerAmt = roundDown(size, cfg.size);
115
+ let rawMakerAmt = rawTakerAmt * rawPrice;
116
+ if (decimalPlaces(rawMakerAmt) > cfg.amount) {
117
+ rawMakerAmt = roundUp(rawMakerAmt, cfg.amount + 4);
118
+ if (decimalPlaces(rawMakerAmt) > cfg.amount)
119
+ rawMakerAmt = roundDown(rawMakerAmt, cfg.amount);
120
+ }
121
+ return { sideInt: 0, makerAmount: parseUnits6(rawMakerAmt), takerAmount: parseUnits6(rawTakerAmt) };
122
+ }
123
+ const rawMakerAmt = roundDown(size, cfg.size);
124
+ let rawTakerAmt = rawMakerAmt * rawPrice;
125
+ if (decimalPlaces(rawTakerAmt) > cfg.amount) {
126
+ rawTakerAmt = roundUp(rawTakerAmt, cfg.amount + 4);
127
+ if (decimalPlaces(rawTakerAmt) > cfg.amount)
128
+ rawTakerAmt = roundDown(rawTakerAmt, cfg.amount);
129
+ }
130
+ return { sideInt: 1, makerAmount: parseUnits6(rawMakerAmt), takerAmount: parseUnits6(rawTakerAmt) };
131
+ }
132
+ function randomSalt() {
133
+ // Mirrors clob-client's generateOrderSalt: a positive integer that survives a
134
+ // JSON round-trip (the POST body sends salt as a JS number).
135
+ return Math.floor(Math.random() * 9_000_000_000_000) + 1;
136
+ }
137
+ function base64ToBytes(b64) {
138
+ const clean = b64.replace(/-/g, "+").replace(/_/g, "/").replace(/[^A-Za-z0-9+/=]/g, "");
139
+ const bin = atob(clean);
140
+ const out = new Uint8Array(bin.length);
141
+ for (let i = 0; i < bin.length; i++)
142
+ out[i] = bin.charCodeAt(i);
143
+ return out;
144
+ }
145
+ function bytesToBase64(bytes) {
146
+ let bin = "";
147
+ for (let i = 0; i < bytes.length; i++)
148
+ bin += String.fromCharCode(bytes[i]);
149
+ return btoa(bin);
150
+ }
151
+ // Gamma returns clobTokenIds/outcomes as JSON-encoded strings (e.g. '["123","456"]').
152
+ function safeJsonArray(v) {
153
+ if (Array.isArray(v))
154
+ return v;
155
+ if (typeof v === "string") {
156
+ try {
157
+ const p = JSON.parse(v);
158
+ return Array.isArray(p) ? p : [];
159
+ }
160
+ catch {
161
+ return [];
162
+ }
163
+ }
164
+ return [];
165
+ }
166
+ export class Polymarket {
167
+ /** Polygon chain id this client signs for. */
168
+ chainId;
169
+ /** The signer's address (null when no key is configured → read-only). */
170
+ address;
171
+ host;
172
+ privateKey;
173
+ legacyV1;
174
+ builderApiKey;
175
+ contracts;
176
+ creds;
177
+ constructor(env, options = {}) {
178
+ this.chainId = options.chainId ?? Number(env?.POLYMARKET_CHAIN_ID ?? 137);
179
+ this.host = options.host ?? env?.POLYMARKET_CLOB_HOST ?? CLOB_HOST;
180
+ this.legacyV1 = options.allowLegacyV1 ?? (env?.POLYMARKET_ALLOW_LEGACY_V1 === "1" || env?.POLYMARKET_ALLOW_LEGACY_V1 === "true");
181
+ // Platform-injected Livo builder credential (one key for all projects). Read-only here;
182
+ // V2 order placement via @polymarket/client isn't wired yet, so this is detection only.
183
+ this.builderApiKey = options.builderApiKey ?? env?.POLYMARKET_BUILDER_API_KEY;
184
+ this.contracts = this.legacyV1 ? CONTRACTS_V1 : CONTRACTS_V2;
185
+ const keySecret = options.privateKeySecret ?? "POLYMARKET_PRIVATE_KEY";
186
+ this.privateKey =
187
+ options.privateKey ?? env?.[keySecret] ?? env?.RELAYER_PRIVATE_KEY;
188
+ this.address = this.privateKey ? privateKeyToAddress(this.privateKey) : null;
189
+ this.creds =
190
+ options.creds ??
191
+ (env?.POLYMARKET_API_KEY && env?.POLYMARKET_SECRET && env?.POLYMARKET_PASSPHRASE
192
+ ? {
193
+ key: env.POLYMARKET_API_KEY,
194
+ secret: env.POLYMARKET_SECRET,
195
+ passphrase: env.POLYMARKET_PASSPHRASE,
196
+ }
197
+ : undefined);
198
+ }
199
+ // ---- public market data (no key) ----
200
+ /** Gamma markets — the rich, searchable market catalog. Pass query params (e.g. { active: true, limit: 10 }). */
201
+ async gammaMarkets(query = {}) {
202
+ const qs = new URLSearchParams(Object.entries(query).map(([k, v]) => [k, String(v)])).toString();
203
+ return this.getJson(`${GAMMA_HOST}/markets${qs ? `?${qs}` : ""}`);
204
+ }
205
+ /** A single Gamma market by id. */
206
+ async gammaMarket(id) {
207
+ return this.getJson(`${GAMMA_HOST}/markets/${id}`);
208
+ }
209
+ /** CLOB markets (paginated; pass a cursor to page). */
210
+ async markets(cursor = "") {
211
+ return this.getJson(`${this.host}/markets${cursor ? `?next_cursor=${encodeURIComponent(cursor)}` : ""}`);
212
+ }
213
+ /** Order book for an outcome token. */
214
+ async book(tokenId) {
215
+ return this.getJson(`${this.host}/book?token_id=${tokenId}`);
216
+ }
217
+ /** Best price for a token on a side. */
218
+ async price(tokenId, side) {
219
+ const j = (await this.getJson(`${this.host}/price?token_id=${tokenId}&side=${side.toLowerCase()}`));
220
+ return Number(j.price ?? 0);
221
+ }
222
+ /** Midpoint price for a token. */
223
+ async midpoint(tokenId) {
224
+ const j = (await this.getJson(`${this.host}/midpoint?token_id=${tokenId}`));
225
+ return Number(j.mid ?? 0);
226
+ }
227
+ /** Minimum tick size for a token (e.g. "0.01"). */
228
+ async tickSize(tokenId) {
229
+ const j = (await this.getJson(`${this.host}/tick-size?token_id=${tokenId}`));
230
+ return String(j.minimum_tick_size ?? "0.01");
231
+ }
232
+ /** Whether a token's market is a neg-risk market. */
233
+ async negRisk(tokenId) {
234
+ const j = (await this.getJson(`${this.host}/neg-risk?token_id=${tokenId}`));
235
+ return Boolean(j.neg_risk);
236
+ }
237
+ /** Last traded price for a token. */
238
+ async lastTradePrice(tokenId) {
239
+ const j = (await this.getJson(`${this.host}/last-trade-price?token_id=${tokenId}`));
240
+ return Number(j.price ?? 0);
241
+ }
242
+ /** Bid/ask spread for a token. */
243
+ async spread(tokenId) {
244
+ return this.getJson(`${this.host}/spread?token_id=${tokenId}`);
245
+ }
246
+ /** Historical price points for charting a token. */
247
+ async priceHistory(tokenId, opts = {}) {
248
+ const p = new URLSearchParams({ market: tokenId });
249
+ if (opts.startTs && opts.endTs) {
250
+ p.set("startTs", String(opts.startTs));
251
+ p.set("endTs", String(opts.endTs));
252
+ }
253
+ else {
254
+ p.set("interval", opts.interval ?? "1d");
255
+ }
256
+ if (opts.fidelity)
257
+ p.set("fidelity", String(opts.fidelity));
258
+ return this.getJson(`${this.host}/prices-history?${p.toString()}`);
259
+ }
260
+ /**
261
+ * Resolve a market by slug, numeric Gamma id, or 0x condition id → its question
262
+ * and the token id for each outcome. THE way to turn "will X happen?" into the
263
+ * `tokenId` you trade: `(await pm.resolveMarket(slug)).tokens` → [{outcome:"Yes",tokenId},…].
264
+ */
265
+ async resolveMarket(slugOrId) {
266
+ let market;
267
+ if (/^0x[0-9a-fA-F]+$/.test(slugOrId)) {
268
+ const arr = (await this.getJson(`${GAMMA_HOST}/markets?condition_ids=${slugOrId}`));
269
+ market = Array.isArray(arr) ? arr[0] : undefined;
270
+ }
271
+ else if (/^\d+$/.test(slugOrId)) {
272
+ market = (await this.getJson(`${GAMMA_HOST}/markets/${slugOrId}`));
273
+ }
274
+ else {
275
+ const arr = (await this.getJson(`${GAMMA_HOST}/markets?slug=${encodeURIComponent(slugOrId)}`));
276
+ market = Array.isArray(arr) ? arr[0] : undefined;
277
+ }
278
+ if (!market)
279
+ return null;
280
+ const ids = safeJsonArray(market.clobTokenIds);
281
+ const outcomes = safeJsonArray(market.outcomes);
282
+ return {
283
+ question: String(market.question ?? ""),
284
+ conditionId: market.conditionId,
285
+ slug: market.slug,
286
+ negRisk: Boolean(market.negRisk),
287
+ tokens: ids.map((id, i) => ({ outcome: String(outcomes[i] ?? `#${i}`), tokenId: String(id) })),
288
+ };
289
+ }
290
+ /** The collateral token address to fund/approve (pUSD on V2; USDC.e on legacy V1). */
291
+ get collateralToken() {
292
+ return this.legacyV1 ? undefined : PUSD_COLLATERAL;
293
+ }
294
+ /** A user's open positions (holdings) from the public data API (default: the signer). */
295
+ async positions(user) {
296
+ const who = user ?? this.address;
297
+ if (!who)
298
+ throw new Error("Polymarket: pass a user address, or configure a key.");
299
+ return this.getJson(`${DATA_HOST}/positions?user=${who}`);
300
+ }
301
+ // ---- auth ----
302
+ /** Ensure API credentials exist (derive existing, else create). Cached on the instance. */
303
+ async ensureCreds() {
304
+ this.assertTradingEnabled();
305
+ if (this.creds)
306
+ return this.creds;
307
+ this.creds = await this.deriveOrCreateApiKey();
308
+ return this.creds;
309
+ }
310
+ // All trading + L2-auth methods route through ensureCreds, so this one guard covers
311
+ // them. Data reads never call it. V2 order signing isn't correctly implemented yet
312
+ // (see the header note) — gate it rather than submit invalid orders.
313
+ /** True once Livo's platform Polymarket builder key is configured (env.POLYMARKET_BUILDER_API_KEY). */
314
+ get builderConfigured() {
315
+ return Boolean(this.builderApiKey);
316
+ }
317
+ assertTradingEnabled() {
318
+ if (this.legacyV1)
319
+ return; // deprecated V1 path (testnet/legacy)
320
+ // V2 trading needs BOTH (1) Livo's platform builder key and (2) the @polymarket/client
321
+ // wrap — which isn't wired yet. Message reflects which prerequisite is missing.
322
+ if (this.builderConfigured) {
323
+ throw new Error("Polymarket builder key is configured, but V2 order placement via @polymarket/client isn't wired up in " +
324
+ "this runtime yet (deposit-wallet deploy + EIP-1271 signing) — tracked in docs/POLYMARKET-V2-IMPLEMENTATION.md. " +
325
+ "Market-DATA reads work. { allowLegacyV1: true } enables the deprecated V1 path on testnet.");
326
+ }
327
+ throw new Error("Polymarket V2 trading needs Livo's platform builder key (env.POLYMARKET_BUILDER_API_KEY), which isn't " +
328
+ "configured. V2 (Apr 2026) requires the deposit-wallet flow — a plain EOA maker is rejected — and deploying " +
329
+ "a deposit wallet needs a Polymarket Builder/Relayer credential (one Livo-held key serves all projects; see " +
330
+ "docs/POLYMARKET-V2-IMPLEMENTATION.md). Market-DATA reads work without it. { allowLegacyV1: true } = V1 testnet.");
331
+ }
332
+ async deriveOrCreateApiKey() {
333
+ // Try derive (deterministic) first; fall back to create.
334
+ for (const [path, method] of [
335
+ ["/auth/derive-api-key", "GET"],
336
+ ["/auth/api-key", "POST"],
337
+ ]) {
338
+ try {
339
+ const headers = await this.l1Headers(0);
340
+ const res = await fetch(`${this.host}${path}`, { method, headers });
341
+ if (!res.ok)
342
+ continue;
343
+ const j = (await res.json());
344
+ if (j.apiKey && j.secret && j.passphrase)
345
+ return { key: j.apiKey, secret: j.secret, passphrase: j.passphrase };
346
+ }
347
+ catch {
348
+ /* try the next */
349
+ }
350
+ }
351
+ throw new Error("Polymarket: could not derive or create API credentials (is the signer funded/registered?).");
352
+ }
353
+ // ---- trading ----
354
+ /** Build, sign, and submit an order. Returns the CLOB response JSON. */
355
+ async placeOrder(params) {
356
+ const signer = this.requireSigner();
357
+ const creds = await this.ensureCreds();
358
+ const tickSize = params.tickSize ?? (await this.tickSize(params.tokenId));
359
+ const cfg = ROUNDING[tickSize];
360
+ if (!cfg)
361
+ throw new Error(`Polymarket: unsupported tick size "${tickSize}"`);
362
+ const negRisk = params.negRisk ?? (await this.negRisk(params.tokenId));
363
+ const contracts = this.contracts[this.chainId];
364
+ if (!contracts) {
365
+ throw new Error(`Polymarket: no ${this.legacyV1 ? "V1" : "V2"} exchange contracts for chainId ${this.chainId}` +
366
+ (this.legacyV1 ? "" : " (V2 is on Polygon mainnet 137; for testnet use { allowLegacyV1: true } on Amoy)"));
367
+ }
368
+ const verifyingContract = negRisk ? contracts.negRiskExchange : contracts.exchange;
369
+ const { sideInt, makerAmount, takerAmount } = rawAmounts(params.side, params.size, params.price, cfg);
370
+ const signatureType = params.signatureType ?? 0;
371
+ const maker = params.funder ?? signer;
372
+ const salt = randomSalt();
373
+ const message = {
374
+ salt: String(salt),
375
+ maker,
376
+ signer,
377
+ taker: ZERO_ADDRESS,
378
+ tokenId: params.tokenId,
379
+ makerAmount,
380
+ takerAmount,
381
+ expiration: String(params.expiration ?? 0),
382
+ nonce: String(params.nonce ?? 0),
383
+ feeRateBps: String(params.feeRateBps ?? 0),
384
+ side: sideInt,
385
+ signatureType,
386
+ };
387
+ const signature = signTypedData({
388
+ domain: { name: "Polymarket CTF Exchange", version: "1", chainId: this.chainId, verifyingContract },
389
+ types: ORDER_TYPES,
390
+ primaryType: "Order",
391
+ message,
392
+ privateKey: this.privateKey,
393
+ });
394
+ const payload = {
395
+ order: {
396
+ salt,
397
+ maker,
398
+ signer,
399
+ taker: ZERO_ADDRESS,
400
+ tokenId: params.tokenId,
401
+ makerAmount,
402
+ takerAmount,
403
+ side: params.side,
404
+ expiration: message.expiration,
405
+ nonce: message.nonce,
406
+ feeRateBps: message.feeRateBps,
407
+ signatureType,
408
+ signature,
409
+ },
410
+ owner: creds.key,
411
+ orderType: params.orderType ?? "GTC",
412
+ };
413
+ const body = JSON.stringify(payload);
414
+ const headers = await this.l2Headers("POST", "/order", body);
415
+ const res = await fetch(`${this.host}/order`, {
416
+ method: "POST",
417
+ headers: { ...headers, "content-type": "application/json" },
418
+ body,
419
+ });
420
+ return res.json();
421
+ }
422
+ /** Cancel one order by id. */
423
+ async cancelOrder(orderId) {
424
+ await this.ensureCreds();
425
+ const body = JSON.stringify({ orderID: orderId });
426
+ const headers = await this.l2Headers("DELETE", "/order", body);
427
+ const res = await fetch(`${this.host}/order`, {
428
+ method: "DELETE",
429
+ headers: { ...headers, "content-type": "application/json" },
430
+ body,
431
+ });
432
+ return res.json();
433
+ }
434
+ /** Cancel all of the signer's open orders. */
435
+ async cancelAll() {
436
+ await this.ensureCreds();
437
+ const headers = await this.l2Headers("DELETE", "/cancel-all");
438
+ const res = await fetch(`${this.host}/cancel-all`, { method: "DELETE", headers });
439
+ return res.json();
440
+ }
441
+ /** The signer's open orders (L2-authed). */
442
+ async openOrders() {
443
+ await this.ensureCreds();
444
+ const headers = await this.l2Headers("GET", "/data/orders");
445
+ const res = await fetch(`${this.host}/data/orders`, { method: "GET", headers });
446
+ return res.json();
447
+ }
448
+ /**
449
+ * The signer's USDC collateral balance + exchange allowance (or, with a tokenId,
450
+ * the conditional-token balance). Check this before trading — orders fail if the
451
+ * CTF Exchange isn't approved to spend your USDC.e.
452
+ */
453
+ async balances(tokenId) {
454
+ await this.ensureCreds();
455
+ const headers = await this.l2Headers("GET", "/balance-allowance");
456
+ const qs = tokenId ? `?asset_type=CONDITIONAL&token_id=${tokenId}` : "?asset_type=COLLATERAL";
457
+ const res = await fetch(`${this.host}/balance-allowance${qs}`, { method: "GET", headers });
458
+ return res.json();
459
+ }
460
+ // ---- header construction ----
461
+ async l1Headers(nonce) {
462
+ const signer = this.requireSigner();
463
+ const ts = Math.floor(Date.now() / 1000);
464
+ const signature = signTypedData({
465
+ domain: { name: "ClobAuthDomain", version: "1", chainId: this.chainId },
466
+ types: {
467
+ ClobAuth: [
468
+ { name: "address", type: "address" },
469
+ { name: "timestamp", type: "string" },
470
+ { name: "nonce", type: "uint256" },
471
+ { name: "message", type: "string" },
472
+ ],
473
+ },
474
+ primaryType: "ClobAuth",
475
+ message: { address: signer, timestamp: String(ts), nonce, message: L1_MESSAGE },
476
+ privateKey: this.privateKey,
477
+ });
478
+ return { POLY_ADDRESS: signer, POLY_SIGNATURE: signature, POLY_TIMESTAMP: String(ts), POLY_NONCE: String(nonce) };
479
+ }
480
+ async l2Headers(method, path, body) {
481
+ const signer = this.requireSigner();
482
+ const creds = await this.ensureCreds();
483
+ const ts = Math.floor(Date.now() / 1000);
484
+ const message = `${ts}${method}${path}${body ?? ""}`;
485
+ const key = await crypto.subtle.importKey("raw", base64ToBytes(creds.secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
486
+ const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
487
+ const sig = bytesToBase64(new Uint8Array(sigBuf)).replace(/\+/g, "-").replace(/\//g, "_");
488
+ return {
489
+ POLY_ADDRESS: signer,
490
+ POLY_SIGNATURE: sig,
491
+ POLY_TIMESTAMP: String(ts),
492
+ POLY_API_KEY: creds.key,
493
+ POLY_PASSPHRASE: creds.passphrase,
494
+ };
495
+ }
496
+ requireSigner() {
497
+ if (!this.address || !this.privateKey) {
498
+ throw new Error("Polymarket: trading needs a signing key — set the POLYMARKET_PRIVATE_KEY secret (or rely on the bot's RELAYER_PRIVATE_KEY).");
499
+ }
500
+ return this.address;
501
+ }
502
+ async getJson(url) {
503
+ const res = await fetch(url, { headers: { accept: "application/json" } });
504
+ if (!res.ok)
505
+ throw new Error(`Polymarket: ${res.status} ${res.statusText} for ${url}`);
506
+ return res.json();
507
+ }
508
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,121 @@
1
+ // Polymarket order math + auth header construction must match the official
2
+ // @polymarket/clob-client. The maker/taker amounts below are GOLDEN values
3
+ // produced by running clob-client's getOrderRawAmounts + viem parseUnits(…,6)
4
+ // (see the helper in the PR notes) — if our ported rounding drifts, orders get
5
+ // rejected on-chain, so we pin them exactly.
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import { Polymarket } from "./polymarket.js";
8
+ // A funded test EOA (well-known hardhat key #1). Never holds real funds.
9
+ const PK = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
10
+ // Reach the private rawAmounts via a placeOrder dry-run: stub fetch and capture
11
+ // the POST /order body.
12
+ function clientWithCapturedOrder() {
13
+ const calls = [];
14
+ const fetchMock = vi.fn(async (url, init = {}) => {
15
+ calls.push({ url, init });
16
+ if (url.endsWith("/auth/derive-api-key") || url.endsWith("/auth/api-key")) {
17
+ return new Response(JSON.stringify({ apiKey: "k", secret: btoa("topsecret"), passphrase: "p" }), { status: 200 });
18
+ }
19
+ if (url.includes("/order"))
20
+ return new Response(JSON.stringify({ success: true, orderID: "0xabc" }), { status: 200 });
21
+ return new Response("{}", { status: 200 });
22
+ });
23
+ vi.stubGlobal("fetch", fetchMock);
24
+ // allowLegacyV1: exercise the (V1) order-signing math/headers directly. V2 signing
25
+ // isn't implemented yet (gated), so the amount/HMAC unit tests run on the V1 path —
26
+ // the rounding + HMAC are identical across versions.
27
+ return { pm: new Polymarket({ POLYMARKET_PRIVATE_KEY: PK }, { allowLegacyV1: true }), calls };
28
+ }
29
+ const GOLDEN = [
30
+ { side: "BUY", size: 10, price: 0.55, tickSize: "0.01", makerAmount: "5500000", takerAmount: "10000000" },
31
+ { side: "SELL", size: 10, price: 0.55, tickSize: "0.01", makerAmount: "10000000", takerAmount: "5500000" },
32
+ { side: "BUY", size: 33.333, price: 0.123, tickSize: "0.001", makerAmount: "4099590", takerAmount: "33330000" },
33
+ { side: "SELL", size: 7.5, price: 0.4, tickSize: "0.1", makerAmount: "7500000", takerAmount: "3000000" },
34
+ { side: "BUY", size: 100, price: 0.99, tickSize: "0.0001", makerAmount: "99000000", takerAmount: "100000000" },
35
+ ];
36
+ describe("Polymarket order amounts match clob-client golden values", () => {
37
+ for (const g of GOLDEN) {
38
+ it(`${g.side} ${g.size} @ ${g.price} (tick ${g.tickSize})`, async () => {
39
+ const { pm, calls } = clientWithCapturedOrder();
40
+ await pm.placeOrder({
41
+ tokenId: "123456789",
42
+ side: g.side,
43
+ price: g.price,
44
+ size: g.size,
45
+ tickSize: g.tickSize,
46
+ negRisk: false,
47
+ });
48
+ const orderCall = calls.find((c) => c.url.endsWith("/order") && c.init.method === "POST");
49
+ expect(orderCall).toBeTruthy();
50
+ const body = JSON.parse(orderCall.init.body);
51
+ expect(body.order.makerAmount).toBe(g.makerAmount);
52
+ expect(body.order.takerAmount).toBe(g.takerAmount);
53
+ expect(body.order.side).toBe(g.side);
54
+ expect(body.order.signature).toMatch(/^0x[0-9a-f]{130}$/);
55
+ expect(body.owner).toBe("k");
56
+ vi.unstubAllGlobals();
57
+ });
58
+ }
59
+ });
60
+ describe("Polymarket V2 trading gate", () => {
61
+ it("placeOrder throws (V2 not implemented) unless allowLegacyV1", async () => {
62
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 })));
63
+ const pm = new Polymarket({ POLYMARKET_PRIVATE_KEY: PK }); // V2 default
64
+ await expect(pm.placeOrder({ tokenId: "1", side: "BUY", price: 0.5, size: 2, tickSize: "0.01", negRisk: false })).rejects.toThrow(/V2/);
65
+ vi.unstubAllGlobals();
66
+ });
67
+ it("exposes pUSD collateral on V2, none on legacy V1", () => {
68
+ expect(new Polymarket({}).collateralToken).toBe("0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB");
69
+ expect(new Polymarket({}, { allowLegacyV1: true }).collateralToken).toBeUndefined();
70
+ });
71
+ it("detects the platform builder key and adjusts the gate message", async () => {
72
+ // No key configured → message points at the missing platform builder key.
73
+ const noKey = new Polymarket({ POLYMARKET_PRIVATE_KEY: PK });
74
+ expect(noKey.builderConfigured).toBe(false);
75
+ await expect(noKey.placeOrder({ tokenId: "1", side: "BUY", price: 0.5, size: 2, tickSize: "0.01", negRisk: false })).rejects.toThrow(/platform builder key/);
76
+ // Platform key injected (env) → detected; gate now points at the un-wired SDK path.
77
+ const withKey = new Polymarket({ POLYMARKET_PRIVATE_KEY: PK, POLYMARKET_BUILDER_API_KEY: "blder_test" });
78
+ expect(withKey.builderConfigured).toBe(true);
79
+ await expect(withKey.placeOrder({ tokenId: "1", side: "BUY", price: 0.5, size: 2, tickSize: "0.01", negRisk: false })).rejects.toThrow(/isn't wired up/);
80
+ });
81
+ });
82
+ describe("Polymarket data helpers", () => {
83
+ it("resolveMarket maps Gamma clobTokenIds + outcomes to tradable tokens", async () => {
84
+ const market = {
85
+ question: "Will it rain tomorrow?",
86
+ conditionId: "0xabc",
87
+ slug: "will-it-rain",
88
+ negRisk: false,
89
+ clobTokenIds: JSON.stringify(["111", "222"]),
90
+ outcomes: JSON.stringify(["Yes", "No"]),
91
+ };
92
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify([market]), { status: 200 })));
93
+ const pm = new Polymarket({});
94
+ const r = await pm.resolveMarket("will-it-rain");
95
+ expect(r?.question).toBe("Will it rain tomorrow?");
96
+ expect(r?.tokens).toEqual([
97
+ { outcome: "Yes", tokenId: "111" },
98
+ { outcome: "No", tokenId: "222" },
99
+ ]);
100
+ vi.unstubAllGlobals();
101
+ });
102
+ it("lastTradePrice parses the price as a number", async () => {
103
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ price: "0.42" }), { status: 200 })));
104
+ const pm = new Polymarket({});
105
+ expect(await pm.lastTradePrice("111")).toBe(0.42);
106
+ vi.unstubAllGlobals();
107
+ });
108
+ });
109
+ describe("Polymarket L2 HMAC headers", () => {
110
+ it("signs timestamp+method+path+body with the base64 secret (url-safe)", async () => {
111
+ const { pm, calls } = clientWithCapturedOrder();
112
+ await pm.placeOrder({ tokenId: "1", side: "BUY", price: 0.5, size: 2, tickSize: "0.01", negRisk: false });
113
+ const orderCall = calls.find((c) => c.url.endsWith("/order") && c.init.method === "POST");
114
+ const h = orderCall.init.headers;
115
+ expect(h.POLY_API_KEY).toBe("k");
116
+ expect(h.POLY_PASSPHRASE).toBe("p");
117
+ expect(h.POLY_SIGNATURE).toBeTruthy();
118
+ expect(h.POLY_SIGNATURE).not.toMatch(/[+/]/); // url-safe base64
119
+ vi.unstubAllGlobals();
120
+ });
121
+ });
@@ -67,5 +67,36 @@ export declare class Telegram {
67
67
  command: string;
68
68
  description: string;
69
69
  }>): Promise<void>;
70
+ private api;
71
+ /** Parse a `chat_join_request` update (a user asking to join a join-request chat). */
72
+ joinRequest(update: Record<string, unknown>): ChatJoinRequest | null;
73
+ /** Admit a pending join request. */
74
+ approveJoinRequest(chatId: number | string, userId: number): Promise<unknown>;
75
+ /** Reject a pending join request. */
76
+ declineJoinRequest(chatId: number | string, userId: number): Promise<unknown>;
77
+ /** Remove (ban) a member — the "boot" when they no longer qualify. */
78
+ banMember(chatId: number | string, userId: number, opts?: {
79
+ untilDate?: number;
80
+ revokeMessages?: boolean;
81
+ }): Promise<unknown>;
82
+ /** Lift a ban so a re-qualified user can rejoin (does NOT add them back). */
83
+ unbanMember(chatId: number | string, userId: number, opts?: {
84
+ onlyIfBanned?: boolean;
85
+ }): Promise<unknown>;
86
+ /** Create an invite link. With createsJoinRequest, joins need bot approval (the gate). */
87
+ createInviteLink(chatId: number | string, opts?: {
88
+ name?: string;
89
+ createsJoinRequest?: boolean;
90
+ expireDate?: number;
91
+ }): Promise<string>;
92
+ /** A member's status in a chat ("member" | "left" | "kicked" | "administrator" | "creator" | "restricted"). */
93
+ memberStatus(chatId: number | string, userId: number): Promise<string | null>;
94
+ }
95
+ export interface ChatJoinRequest {
96
+ chatId: number;
97
+ userId: number;
98
+ username?: string;
99
+ from: Record<string, unknown>;
100
+ raw: Record<string, unknown>;
70
101
  }
71
102
  export {};