Polymarket venue notes (Phase 0 verification, Phase 1, 2, 3 and 8 additions)
Verified 2026-09-30 against the live docs (docs.polymarket.com), the official clients, and live public WebSockets. §13 adds what the Phase 1 read-only clients found against the live public APIs. §14 covers the Phase 2 account reads, and lists what still needs checking with real credentials. §15 covers Phase 3 trading: order signing, placement and cancels, the heartbeat, the session-key check and the clock-skew check, with its own UNVERIFIED list. §16 is the Phase 8 Combos research: the requester RFQ gateway, Exchange V3 signing, the Data API combo reads and the gaps A1–A17. The go-ethereum notes for security P2-10 are in go-ethereum.md. Scope is trade-only: Predictions CLOB trading and account reads. There are no deposit, withdrawal, bridge, relayer or Perps features. Sources used throughout:- Docs:
https://docs.polymarket.com/.... Appending.mdto a page URL returns its raw markdown. @polymarket/clob-client-v2@1.2.0: the package installed intools/vectors/node_modules. Its GitHub repo isPolymarket/clob-client-v2. Paths below are from itssrc/, which the package ships inside its source maps.@polymarket/client@0.11.0: the unified TS SDK (Polymarket/ts-sdk,packages/client/src), read from its shipped source maps.Polymarket/py-clob-client-v2andPolymarket/rs-clob-client-v2on GitHub.
Which SDK the vectors use
- The
clob-client-v2README says the unified@polymarket/client(ts-sdk) is recommended for new projects. - The unified SDK does not export its signing and amount primitives.
createExchangeOrderTypedDataPayload,createExchangeOrderSignature,computeLimitOrderAmountsand the session-key wrapper are all@internal. Its order workflow needs an authenticated client, which means calling/auth/derive-api-key. clob-client-v2exportscreateL1Headers,createL2Headers,OrderBuilder,orderToJsonV2andgetContractConfig, all usable offline.- The vectors therefore come from
clob-client-v2. Every HMAC vector is also checked against the unified SDK’s exportedbuildHmacSignature, and they agree. - I re-implemented the unified SDK’s fixed-point amount code (
actions/orders/amounts.ts,fixed.ts,context.ts) in a scratch script. It gives the same maker/taker amounts asclob-client-v2for all 26 amount vectors. - An exhaustive scratch search of tick-0.01 market BUYs (amounts 1.01–29.99, every price) found no case where
clob-client-v2’s float rounding differs from an integer floor. - For limit orders,
size(2 decimals) ×pricefits in the amount decimals, so integer math is exact.
1. Addresses (Polygon, chain 137)
Source: https://docs.polymarket.com/resources/contracts.md. The same addresses appear inclob-client-v2 src/config.ts (MATIC_CONTRACTS) and ts-sdk src/environments.ts (production.contracts).
Neg Risk Adapter. The contracts table lists
0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296 as “Neg Risk Adapter (CLOB v1, deprecated)”. The Jul 14, 2026 changelog (https://docs.polymarket.com/changelog/predictions.md) says “CLOB v2 integrations should use the current Neg Risk Adapter at 0xadA2005600Dec949baf300f4C6120000bDB6eAab”. It only matters for relayer actions (split, merge, redeem), which are out of scope.
EIP-712 domains. Sources: https://docs.polymarket.com/trading/place-orders.md, https://docs.polymarket.com/v2-migration.md, and clob-client-v2 src/order-utils/model/ctfExchangeV2TypedData.ts and src/signing/eip712.ts.
- Exchange domain:
{name: "Polymarket CTF Exchange", version: "2", chainId: 137, verifyingContract: <exchange>}. The migration guide says “Only the Exchange domain changes”. - Auth domain:
{name: "ClobAuthDomain", version: "1", chainId: 137}. It has noverifyingContract. - Order type string (186 bytes =
0x00ba):Order(uint256 salt,address maker,address signer,uint256 tokenId,uint256 makerAmount,uint256 takerAmount,uint8 side,uint8 signatureType,uint256 timestamp,bytes32 metadata,bytes32 builder). expiration,taker,nonceandfeeRateBpsare not signed in V2.- Side is signed as
uint8(BUY = 0, SELL = 1) but sent as the string"BUY"/"SELL". timestampis in milliseconds.- The vectors include
domainSeparator,structHash,orderHashandsignedDigestfor every order.
2. L1 auth
Sources: https://docs.polymarket.com/getting-started/api.md andclob-client-v2 src/headers/index.ts (createL1Headers) and src/signing/eip712.ts (buildClobEip712Signature).
- Typed data: primary type
ClobAuth(address address,string timestamp,uint256 nonce,string message).timestampis a string of Unix seconds.noncedefaults to 0.messageis exactlyThis message attests that I control the given wallet.
- Headers:
POLY_ADDRESS(signer address),POLY_SIGNATURE(65-byte hex, v = 27/28),POLY_TIMESTAMP,POLY_NONCE. - Create:
POST https://clob.polymarket.com/auth/api-key. - Derive:
GET https://clob.polymarket.com/auth/derive-api-key. - Response:
{"apiKey","secret","passphrase"}.secretis base64 (URL-safe alphabet in practice). - SDK behaviour: both SDKs try create first and fall back to derive on HTTP 400. See ts-sdk
src/actions/auth.tscreateOrDeriveApiKey. - Errors (https://docs.polymarket.com/resources/error-codes.md): 401
Invalid L1 Request headers, 400Could not create api key, 400Could not derive api key!. - Vectors:
clobAuth[]holds typed data, domain separator, digest, signature and headers for two nonce/timestamp pairs. Each signature is checked by recovering the signer.
3. L2 auth (HMAC)
Sources: https://docs.polymarket.com/getting-started/api.md,clob-client-v2 src/signing/hmac.ts and src/headers/index.ts, ts-sdk src/hmac.ts, py py_clob_client_v2/signing/hmac.py, and rs src/auth.rs (around lines 261–284).
Recipe:
POLY_ADDRESS, POLY_SIGNATURE, POLY_TIMESTAMP, POLY_API_KEY, POLY_PASSPHRASE.
The query string is not signed. requestPath is the bare path. The docs say, for GET /balance-allowance/update?...: “The query parameters are not part of the signed path: message = <ts> + "GET" + "/balance-allowance/update"” (https://docs.polymarket.com/trading/wallets-auth.md). All clients agree:
clob-client-v2signs the endpoint constant and passes query params to axios separately.- py signs
request_path=endpointand sendsparams=. - rs signs
request.url().path(). - ts-sdk signs
request.pathand sendssearchParamsseparately.
- The TS clients use
JSON.stringify(payload), which is compact. - py uses
json.dumps(payload, separators=(",", ":"), ensure_ascii=False), then replaces'with". - rs uses compact serde_json.
- Go must sign the same byte slice it sends.
DELETE endpoints carry JSON bodies: /order takes {"orderID"}, /orders takes an array of IDs, and /cancel-market-orders takes {"market","asset_id"}. DELETE /cancel-all has no body.
Signer address. With a Deposit Wallet, POLY_ADDRESS is the signing EOA or session key, not the wallet. In ts-sdk, POLY_ADDRESS: this.account.signer; rs sends it checksummed.
Vectors: hmac[] has 10 cases:
- GET with query:
/data/ordersand/balance-allowance - POST
/order - POST
/orders(batch) - DELETE
/order - DELETE
/orders - DELETE
/cancel-all - DELETE
/cancel-market-orders - POST
/v1/heartbeats - a URL-safe secret containing
-and_
signedMessage and urlQuery.
4. Deposit Wallet order signatures (signature type 3, POLY_1271)
Sources:
clob-client-v2src/order-utils/exchangeOrderBuilderV2.ts(buildOrderSignature) andsrc/order-builder/helpers/createOrder.ts- ts-sdk
src/exchange.ts(createExchangeOrderSignature) andsrc/wallet.ts(resolveOrderIdentity) - py
order_utils/exchange_order_builder_v2.py_build_poly_1271_order_signature - rs
src/clob/client.rssign_poly1271_order - https://docs.polymarket.com/trading/place-orders.md
maker = signer = <deposit wallet address> and signatureType = 3. The EOA or session key that actually signs appears only in the signature and in POLY_ADDRESS. The docs table reads “Deposit Wallet | 3 | Deposit Wallet address | Deposit Wallet address”.
What is signed. The EOA signs an ERC-7739 TypedDataSign payload.
- The domain is the Exchange domain.
- Types:
TypedDataSign(Order contents,string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)plusOrder. - Message:
{contents: <order>, name: "DepositWallet", version: "1", chainId: 137, verifyingContract: <deposit wallet>, salt: 0x00…00}.
0x + 634 hex characters). vectors.json orders[].signatureParts splits each sample. The generator checks every part against independently computed hashes and recovers the signer from innerSig.
Session key variant. With a session key, this signature is wrapped again (see §10).
Finding the deposit wallet address.
- The simplest option is to copy it from the polymarket.com profile menu (wallets-auth.md) and configure it.
- It can also be derived with CREATE2 (https://docs.polymarket.com/trading/wallets-auth.md, “Derive a Deposit Wallet Address”; ts-sdk
src/wallet.tsderiveBeaconDepositWalletAddress/deriveUupsDepositWalletAddress):
- Factory:
0x00000000000Fb5C9ADea0298D729A0CB3823Cc07. - Beacon:
0x7A18EDfe055488A3128f01F563e5B479D92ffc3a. - Wallets deployed before 2026-06-29 use the UUPS variant with implementation
0x58CA52ebe0DadfdF531Cde7062e76746de4Db1eB. - The derivation only works from the owner address. A session key cannot derive the wallet; ts-sdk falls back to “session key” classification when derivation does not match.
- The app should take the wallet address from config.
orders[] has 9 cases:
- signature type 0 and 3
- CTF Exchange V2 and Neg Risk CTF Exchange V2
- BUY and SELL
- one GTD, post-only, type-3 order with non-zero
metadataandbuilderand anexpiration
OrderBuilder does not accept an injected salt or timestamp; it uses Math.round(Math.random() * Date.now()) and Date.now(). The generator pins Date.now and Math.random during each call and records both pinned values in input.
Test deposit wallet. DEPOSIT_WALLET in the vectors (0x1F98431c8aD98523631AE4a59f267346ea31F984) is an arbitrary stand-in, not a real derived wallet.
5. Balance and allowance for a Deposit Wallet account
Sources: https://docs.polymarket.com/api-spec/clob-openapi.yaml (/balance-allowance), https://docs.polymarket.com/trading/wallets-auth.md, clob-client-v2 src/client.ts getBalanceAllowance, and ts-sdk src/actions/account.ts.
Read: GET /balance-allowance?asset_type=COLLATERAL&signature_type=3, L2-signed over /balance-allowance only.
- For outcome tokens use
asset_type=CONDITIONAL&token_id=<tokenId>. - The response is
{"balance": "<6-decimal base units>", "allowances": {"<spender>": "<amount>"}}.
GET /balance-allowance/update with the same parameters.
- The docs say: “Refresh the conditional-token allowance for each token before its first sell order.”
- Rate limits: GET 200/10s, UPDATE 50/10s.
signature_type. There is no wallet-address parameter. Both SDKs send the client’s signature type, which is 3 for a Deposit Wallet.
UNVERIFIED: whether the server accepts signature_type=3 for this endpoint.
- The docs example uses 3.
- But the OpenAPI enum for
signature_typeis0,1,2, and its error text reads “must beEOA,POLY_PROXY, orGNOSIS_SAFE”. - It needs an authenticated call, which is not allowed from this geoblocked server.
- Check it during Phase 2 live verification.
6. Order POST body, batches and statuses
Sources: https://docs.polymarket.com/trading/place-orders.md, the OpenAPISendOrder schema, clob-client-v2 src/types/ordersV2.ts orderToJsonV2, and https://docs.polymarket.com/concepts/order-lifecycle.md.
POST /order, with keys in the order clob-client-v2 emits them. vectors.json orders[].postBody has exact samples.
order fields
Top-level fields
clob-client-v2 also copies order.taker, which is undefined for V2 orders, so JSON.stringify drops it.
GTD (place-orders.md): “GTD orders expire one minute before their stated expiration … the expiration must be at least 3 minutes in the future.” ts-sdk enforces a 180-second minimum.
Batch: POST /orders takes an array of 1–15 of the same entries (OpenAPI maxItems: 15). DELETE /orders accepts up to 1000 IDs.
Response fields: success, errorMsg, orderID, status, makingAmount, takingAmount, transactionsHashes[], tradeIDs[]. Since the Jul 17, 2026 changelog, FAK and FOK matches return tradeIDs instead of hashes.
Statuses:
live: “The order is resting on the book.”matched: “The order matched immediately with resting liquidity.”delayed: “The order is marketable but subject to a matching delay.” This happens on configured sports markets.- The delay length is the market’s
secondsDelayfield. - “During either delay, the order is pending and cannot be canceled” (order-lifecycle.md).
- The delay length is the market’s
unmatched: place-orders.md says “The order is marketable but failed to delay; placement still succeeded”. order-lifecycle.md says “Marketable order placed on the book after the delay expired without a match”. The two pages disagree.
{"error": "<message>"} strings with no code enum, apart from post_only_mode. Examples:
invalid post-only order: order crosses bookorder {id} is invalid. Price ({price}) breaks minimum tick size rule: {tick}… Size ({size}) lower than the minimum: {min}not enough balance / allowanceinvalid expirationorder match delayed due to market conditions- 500
order timed out
UNVERIFIED:
- The exact response body for each status. There is no live order capture; it needs an authenticated POST.
- The docs error
the order signer address has to be the address of the API KEYconflicts with type-3 orders, wheresigneris the wallet and the API key belongs to the EOA or session key. How the server reconciles the two cannot be checked without a live order.
7. Heartbeat (dead-man switch)
Sources: https://docs.polymarket.com/trading/manage-orders.md (“Order Heartbeats”) andclob-client-v2 src/client.ts postHeartbeat (endpoint HEARTBEAT = "/v1/heartbeats").
Request: POST /v1/heartbeats, L2-signed. The first body is {"heartbeat_id":""}.
Response: {"heartbeat_id":"<id>"}. Send that ID in the next request.
Timing: “if a valid heartbeat is not received within 10 seconds, all open orders owned by those CLOB API credentials are canceled. The cancellation check runs every five seconds, so cancellation may occur up to five seconds after the timeout.” The docs recommend sending one every 5 s.
Bad ID: HTTP 400 {"error_msg":"Invalid Heartbeat ID","heartbeat_id":"<expected>"}. Retry with the returned ID.
- The OpenAPI example names the key
error, noterror_msg. The docs disagree.
hmac[post_heartbeat].
8. WebSockets
Sources: https://docs.polymarket.com/asyncapi.json,asyncapi-user.json and asyncapi-sports.json, https://docs.polymarket.com/market-data/realtime-data.md, ts-sdk src/websockets/clob/protocol.ts and src/websockets/heartbeat.ts, and the live captures described below.
Market channel
URL:wss://ws-subscriptions-clob.polymarket.com/ws/market
Subscribe frame. This is the exact frame used for the capture:
initial_dump: defaults to true.level: 1, 2 or 3, defaults to 2. Its meaning is not documented.custom_feature_enabled: enablesbest_bid_ask,new_marketandmarket_resolved.
{"operation":"subscribe"|"unsubscribe","assets_ids":[...]}.
Heartbeat: send the text frame PING every 10 s; the server replies with the text frame PONG. Observed: each PONG arrived 80–105 ms after the PING. ts-sdk treats the socket as stale after 30 s without a PONG.
Events: book, price_change, last_trade_price, tick_size_change, best_bid_ask, new_market, market_resolved. Field lists are in asyncapi.json.
Observed in the capture:
- The first frame is a JSON array of
bookobjects, one per asset. It includestick_sizeandlast_trade_price, and ends with a trailing\n. - Every later frame is a single JSON object.
- Bids arrive ascending (best bid last) and asks descending (best ask last).
- A full
bookis re-sent for both assets right after each trade, alongsidelast_trade_priceandbest_bid_ask. - Each
price_changeitem carriesbest_bidandbest_ask.
backend/internal/venue/polymarket/testdata/market_ws_frames.jsonl.
- Market: NHL “Canucks vs. Oilers” (
nhl-van-edm-2026-09-29, condition0xfe4a8a13…5547, tick 0.01), captured live in-game about 03:12:40Z for 32 s. - 1,646 JSON frames, one per line, byte-for-byte as received: 1 initial dump, 1,615
price_change, 12book, 12best_bid_askand 6last_trade_price. - The three
PONGtext frames are not JSON and are left out. - The initial dump’s own trailing
\nserves as its line terminator.
User channel
URL:wss://ws-subscriptions-clob.polymarket.com/ws/user
Subscribe frame: {"auth":{"apiKey","secret","passphrase"},"type":"user","markets":[<condition ids>]}. Omit markets to receive all markets.
Changing subscriptions: {"operation":"subscribe"|"unsubscribe","markets":[...]}.
Heartbeat: the same PING/PONG as the market channel.
Events:
orderhastypePLACEMENT, UPDATE or CANCELLATION.tradehasstatusMATCHED, MINED, CONFIRMED, RETRYING or FAILED.
Sports channel
URL:wss://sports-api.polymarket.com/ws. No subscribe frame is needed; the realtime-data docs, asyncapi-sports.json and the ts-sdk comment all agree.
Frames observed: one flat JSON object per frame, with no envelope or event_type:
{"gameId","leagueAbbreviation","homeTeam","awayTeam","status","score","period","live","ended"}elapsedis added for clock sports, for example NHL.
Esports score:
{current map}|{maps won}|Bo{n}. The current map’s score is padded to 000-000 only at zero (0-1|0-0|Bo3, 11-12|1-0|Bo3). The period is the map being played out of the series length (2/3). Captured in sports_ws_frames_esports.jsonl, sports_ws_frames_map_score.jsonl and sports_ws_frames_cricket_ended.jsonl.
Other values seen (2026-10-01): tennis period TB2; MLB periods Bot 1st, Mid 1st, End 9th; NHL End P1; FIFA VFT. A finished game adds finishedTimestamp (RFC 3339, nanoseconds).
Cricket frames carry metadataGameId and no gameId:
- Shape:
{"metadataGameId":"id2703320774459578","leagueAbbreviation":"cricket","score":"176-48","period":"Live","live":true,"ended":false}. There are nohomeTeam,awayTeam,statusorelapsedfields, andleagueAbbreviationis alwayscricket, which is not a Gamma league code (Gamma hascrint,cricwncl,crickcct20and others). - An ended game repeats its final frame and, in the 2026-10-01 capture, alternated between
156-158and0-1, bothFT. - Gamma cricket main events also have no
gameId. They carryeventMetadata.gameIdin one of two forms:idand 16 digits (id2703162375014782, withscoreandperiodfilled) or1000169732LIVE2026(noscoreorperiodat all). Companion events carryparentEventIdand no game id. In a scan of open sports events on 2026-10-01, only cricket events hadeventMetadata.gameId, and no event had both it andgameId. Gamma’sgame_idfilter refuses these ids (invalid integer).
metadataGameId equals the Gamma main event’s eventMetadata.gameId.
- For: both use the same
id+ 16 digits form and number range. The WS idid2703162375014780falls between the Gammacricwnclidsid2703162375014778andid2703162375014782. - Missing: no captured frame names a game that Polymarket lists. The 23 cricket ids seen on the WS on 2026-09-30 and 2026-10-01 (about 1 h 50 min of capture) matched no Gamma event’s
eventMetadata.gameIdacross the 1,516 cricket events starting 2026-09-01 to 2026-10-31, open and closed. The listedid…games then werecricwnclgames starting 2026-10-01 04:00Z and 06:00Z, after the capture. - Until it is confirmed, the backend skips cricket frames with a Debug log (
sports frame skipped,reasonno gameId), and Gamma cricket events map without a game. - To settle it: while a listed cricket game with an
id…eventMetadata.gameIdis live, capture the sports channel for 5 minutes andGET /events/{id}for its main event (live verification runbook row 16.46). It is confirmed when a frame’smetadataGameIdequals the event’seventMetadata.gameIdand the frame’sscoreandperiodmatch the event’s.
slug required, plus last_update, finished_timestamp, turn). No such frame was observed.
Heartbeat, observed: the server sends WebSocket protocol-level ping control frames (empty payload) every 15 s. No text ping frame arrived in 150 s (ws client, control frames logged).
- The docs say “The server sends the text frame
pingevery 5 seconds; reply withpongwithin 10 seconds”. - The ts-sdk code replies to a text
pingwithpong. - The Go client should answer protocol pings (coder/websocket does this automatically) and also reply
pongto a textping.
9. Rate limits
Source: https://docs.polymarket.com/api-reference/rate-limits.md. These limits are Cloudflare, per IP, and “throttled (delayed/queued) rather than immediately rejected”. General: 15,000 requests/10s./ok: 100/10s.
CLOB
CLOB trading
Gamma
Data API v2
Data API v1: general 1,000/10s,
/trades 200/10s, /positions 150/10s, /closed-positions 150/10s.
Per-signer token buckets (https://docs.polymarket.com/api-reference/trading-rate-limits.md). Warning mode has been on since 2026-07-24 and is signalled by the Poly-RateLimit-Warning: true header.
Token cost:
POST /order= 1POST /orders= number of ordersDELETE /order= 1DELETE /orders= number of IDscancel-allandcancel-market-orders= 1 + number cancelled- Batches are all-or-nothing.
Poly-RateLimit-Remaining, Poly-RateLimit-Reset, Poly-RateLimit-Tier, and Retry-After on 429.
UNVERIFIED: WebSocket connection and subscription limits for the market, user and sports channels. They are not in the docs.
10. Session Keys
Sources: https://docs.polymarket.com/trading/session-keys.md and ts-sdksrc/actions/session-keys.ts, src/wallet.ts (SignerType, classifyAccount, wrapDepositWalletSignature, createSessionSignerSignature) and src/clients.ts (beginAuthentication, #createL2Headers).
Answer: yes. A CLOB-scoped Session Key can sign Predictions CLOB orders and do L1 auth for a Deposit Wallet account. The feature is in beta (“Session Keys are in beta”) and works only with Deposit Wallets.
Scopes: CLOB, COMBOSRFQ, or ALL on its own. A CLOB-only key fits this app. The docs say “A Session Key cannot withdraw funds”.
On-chain scope (security P3-01, F4): not trade-only. See ADR 0012. In the verified Deposit Wallet source (beacon implementation 0xf7f27c29e60fe6325bef8da7f93250353d2e3294), the scopes exist only off-chain; the contract stores one validUntil per signer. isValidSignature accepts a session-key signature over any digest while block.timestamp < validUntil, and a session-signed Batch may call any contract except the wallet and its beacon, token transfers included. What keeps a key from moving funds is that only Polymarket’s relayer (the factory operator) can submit batches; its policy for session-signed batches is not public (UNVERIFIED). The docs sentence above is relayer policy, not a contract property. The working-balance cap is ADR 0013.
Expiry: always now + 180 days. The docs say “Other values are rejected”; ts-sdk uses 4_315 * 60 * 60 s. To end a key early, revoke it.
Creating a key is a one-off owner action, outside this app.
- The owner signs a Deposit Wallet
Batch(domainDepositWallet/1/137/wallet) that callsauthorizeSessionSigner(address sessionSigner, uint256 validUntil). - It is submitted to the relayer at
POST /v1/session-signers/authorizations. - Submission requires a Builder API key. During rollout, access is by approval via builder@polymarket.com.
- The owner can do this with the official SDK (
secureClient.authorizeSessionKey({ address, scopes: [CLOB] })) on a machine in a permitted location. The owner key never touches this app. - Check the result with
GET /v1/user/session-signers(L2). It returns{"wallet","signers":[{"address","scopes","valid_until"}]}.
-
L1: the session key signs
ClobAuthwith its own address, andPOLY_ADDRESSis the session key. The API credentials therefore belong to the session key. -
L2:
POLY_ADDRESSis the session key address.ownerin the order body is the session key’s API key. -
Orders:
maker = signer = deposit walletandsignatureType = 3. The session key signs the sameTypedDataSignpayload as in §4, giving the 317-byte ERC-7739 signature S. -
Wrapping S: the wire signature is
This is the 32-byte ERC-6492-style magic suffix (ts-sdk
wallet.tscreateSessionSignerSignature,SESSION_SIGNER_MAGIC_BYTES). The second word is the signer kind, 0 for a secp256k1 key; the wallet’sSessionSignerLib.decodeSessionSigneraccepts this layout only (verified in the contract source, ADR 0012).
@polymarket/client and the polymarket Python package). clob-client-v2, py-clob-client-v2 and rs-clob-client-v2 have no session-key code; their type-3 path produces only the owner signature.
Implementation note: ts-sdk picks the session-key path with a // TEMP fallback. When the wallet cannot be derived from the signer, it assumes a session key (wallet.ts classifyAccount).
UNVERIFIED:
- No golden vectors for the session-key wrapper.
createSessionSignerSignatureis internal to@polymarket/client@0.11.0, and its order workflow cannot run without an authenticated client. The wrapper is plainabi.encodeplus a constant suffix, built on the type-3 vectors above. - Server acceptance of a session-key L1 signature, API key, and wrapped order. This needs authenticated calls from a permitted location. Check it during Phase 3 live verification.
11. Sports WS on connect
Answer: it sends changes only. There is no current-state snapshot on connect. Evidence from two connections, run 2026-09-30 about 03:10–03:15Z, while many games were live:- There was no burst of frames after connecting, only individual game updates spread over time.
- Live games appeared only when they changed. MLB NYY–BOS was in progress but showed up only in run 1, at +14.6 s. MLB SD–CHC showed up only in run 2.
- The docs do not say either way (not stated in asyncapi-sports.json or realtime-data.md).
backend/internal/venue/polymarket/testdata/sports_ws_frames.jsonl holds 5 frames from run 1, verbatim: esports, MLB, NHL with elapsed, WTA, and ATP with a tiebreak score.
12. Market WS capture
See §8 (Market channel) for the subscribe frame sent and the capture details.Other rules picked up during verification
Tick sizes: 0.1, 0.01, 0.005, 0.0025, 0.001, 0.0001. 0.0025 is used only for World Cup markets (market-details.md). Decimal rules. Sizes are rounded down to 2 decimals. Amounts use the decimals below (place-orders.md;clob-client-v2 ROUNDING_CONFIG; ts-sdk resolveRoundingConfig).
Limit order amounts:
- BUY:
maker = floor(size × price)at amount decimals,taker = size. - SELL:
maker = size,taker = floor(size × price).
- BUY:
maker = amountin pUSD,taker = floor(amount / price). - SELL:
maker = shares,taker = floor(shares × price).
min_order_size from /book or the market info. UNVERIFIED whether it counts shares or pUSD: place-orders.md says shares, while market-details.md says orderMinSize is “Minimum order size in USDC”.
13. Phase 1 findings (read-only market data)
Checked 2026-09-30 about 03:45–04:10Z with the Phase 1 clients and scratch probes against the live public endpoints. Every fixture named here is inbackend/internal/venue/polymarket/testdata/, captured verbatim.
Gamma
Keyset listings treatclosed differently.
/events/keysetwithoutclosedreturns open and closed events alike.closed=falsereturns open ones, andclosed=truereturns only closed ones./markets/keysetwithoutclosedreturns only open markets.closed=truereturns only closed ones.- To find a market by condition id whether it is open or closed, the client asks
closed=falseand thenclosed=true. - Pages hold at most 100.
next_cursoris omitted on the last page, and it is passed back asafter_cursor.
/events/keyset?closed=falselists events by id, oldest first. About 20,000 events are open, 1.3 GB as JSON. The Go client asks for gzip and gets it: a 100-event page was about 4.8 MB decoded and 0.5 MB on the wire.exclude_tag_id=1leaves out the Sports events: the first page with it equals the first page without it minus its one Sports event, followed by the next events by id.tag_id=1andexclude_tag_id=1split the events by the event’s own tags, with no overlap and no gap. One end-date window (end_date_min=2026-10-01T00:59:00Z,end_date_max=2026-10-01T01:01:00Z) listed three ways returned 33 events unfiltered, 8 withtag_id=1and 25 withexclude_tag_id=1; the two filtered sets are disjoint and their union is the unfiltered set (gamma_events_window_*.json). The 8 sports events, one soccer game and its companions, carry other tags too (soccer, the league,games) and appear only undertag_id=1. In 700 captured events none was untagged, and none had agameIdor asportwithout the Sports tag.- Each catalog refresh lists the board’s sports events first (start time from 6 h back to 30 h ahead, then live), then
tag_id=1(EventFilter.Sports) andexclude_tag_id=1(EventFilter.NotSports). Only the board’s few pages are downloaded twice. Open Sports events numbered over 8,000 on 2026-10-01 (more than 80 pages of 100). - An event that gains or loses the Sports tag while a refresh runs can miss both listings, so absence alone does not close an event. The cached open events no listing returned are looked up by id, 50 to a request:
/events/keyset?id=<id>&id=<id>…&limit=<n>withoutclosed. That answer holds open and closed events alike and leaves unknown ids out (gamma_events_keyset_ids.json:id=16183&id=2890&id=999999999returned the open 16183 and the closed 2890, and nothing for 999999999; checked 2026-10-01). An answer naming an event not asked for, carrying anext_cursor(gamma_events_keyset_ids_truncated.json:id=16183&id=2890&limit=1returned one event and a cursor), or naming none of the ids asked for (gamma_events_keyset_ids_empty.json) fails the lookup, and the batch stays open until the next refresh. An event is closed only on a positive signal: the answer reports it closed, or the answer leaves it out and/events/{id}then answers 404 (gamma_event_id_not_found.json:{"type":"not found error","error":"id not found"}). An open answer stores it again (security PF-04). A batch whose every event Gamma has deleted is therefore never closed; it is retried, and counted in the Warn line, at each refresh. - 100-event pages measured up to 34.8 MB decoded (2026-09-30), so the Gamma client reads bodies up to 64 MiB. The CLOB and Data API clients keep 32 MiB.
- Decoding a 100-event page took about 40 ms and storing it about 340 ms on the development server; the catalog fetches the next page while the previous one is stored.
/tags and /teams return at most 100 rows whatever limit asks for. /tags?offset=20000 returns [] (gamma_tags_end.json); the total is between 5,000 and 20,000.
Filters combine. live=true, tag_id=1 (the Sports tag) and tag_slug can be sent together. live=true&closed=false returned 30 events (main and companion game events), about 1.8 MB with nested markets.
Numbers mix types.
The client keeps every numeric field as its literal text. Tick and minimum size convert to micros exactly. Volume and liquidity are rounded to the nearest micro because they have no exact 6-decimal form.
Not-yet-deployed markets. An event can hold a market with
conditionId: "" and no clobTokenIds. For example, event 16183 includes “Kraken IPO by June 30, 2026?”. The client omits such markets.
Errors.
/events/slug/<unknown>gives 404{"type":"not found error","error":"slug not found"}plus a trailing newline (gamma_event_not_found.json)./events/99999999999gives 422{"type":"validation error","error":"id is invalid"}.
/events/keyset?game_id=<id> returns the main event and its companions (gamma_events_game.json, 6 events). Companion slugs extend the main slug, so the client puts the event with the shortest slug first.
Score order follows the league’s ordering. Gamma /sports and each sports event’s sport.ordering name the team a league lists first. The score string uses that order, both in Gamma score and in the sports WS score. Confirmed three ways:
- MLB (
ordering: away): BOS at NYY ended"0-9"and the NYY market resolved Yes, so the away team comes first. - NHL (
ordering: away): Canucks (away) at Oilers showed"4-2"in P3 while the Canucks token traded at 0.96. - CONCACAF (
ordering: home): Belize (home) vs St. Vincent ended"0-1"and the St. Vincent market went to 0.9995.
away (US leagues: nba, nfl, mlb, nhl, wnba, cfb, cbb, …); the other 456 are home. The WS frame has no ordering of its own, so the sports client loads /sports at every connect.
gameStatus is in the Gamma OpenAPI Event schema but was absent from every live event seen. The client maps it to Game.Status when present.
CLOB market info. /clob-markets/{condition_id} returns compact keys: mts tick, mos min size, sd seconds delay, t[] tokens and outcomes, gst game start. Gamma already carries tick, minimum size and neg-risk, so Phase 1 does not call it (see the open question in the Phase 1 report about PLAN §3.7).
CLOB prices-history
interval=1wneedsfidelity≥ 5 andinterval=1mneeds ≥ 10. Without it the CLOB returns 400{"error":"invalid filters: minimum 'fidelity' for '1w' range is 5"}(clob_prices_history_error.json).- The client sends fidelity 1 (1h), 5 (6h, 1d), 30 (1w), 180 (1m) and 720 minutes (max).
pis a JSON number. Every captured value has 4 decimals or fewer.- The last timestamp can repeat (
clob_prices_history_1h.jsonends with{"t":1790740997,"p":0.56}twice). The client keeps the later point.
Market channel
- Empty subscribe.
{"assets_ids":[],"type":"market","custom_feature_enabled":true}is accepted. The connection stays open and PONGs arrive, so the client connects at start and adds assets later. - Adding assets.
{"operation":"subscribe","assets_ids":[...],"custom_feature_enabled":true}makes the server send a JSON array ofbookobjects for just those assets, the same form as the initial dump. - Removing assets.
unsubscribestops frames for those assets within a few frames. - Fresh book for one asset. A second
subscribefor an asset already subscribed sends nothing.unsubscribefollowed bysubscribesends a one-book array for it. The feed uses this to resync a single token after an event for it fails to parse. - Assets per connection. The feed puts at most 500 assets on one connection and opens more beyond that, so a dump stays well under its 16 MiB frame limit.
- Sibling outcomes. A
price_changeframe carries both outcomes of a market, so frames arrive for a sibling token that is not watched. The client filters by its watched set. - Deltas are absolute. In
market_ws_frames.jsonl, all 12 laterbookframes equal the first snapshot with everyprice_changeapplied, wheresizeis the level’s new total and"0"removes it. tick_size_changeis sent twice for each asset ({"old_tick_size":"0.01","new_tick_size":"0.001"}). On the crypto 5-minute markets it arrived both about a minute before the market’s end and after the end but before resolution.market_resolvedarrives about 2 minutes after a crypto 5-minute market ends. It carriesassets_ids,winning_asset_idandwinning_outcome. Fixture:market_ws_frames_resolved.jsonl, one complete 2-minute connection to one market (74 frames).new_marketframes arrive on every connection withcustom_feature_enabled, for markets that were never subscribed (30 in 2 minutes). The client ignores them.best_bid_askis ignored because the book is the single source (PLAN §3.7).- Book dump fields. The dump’s
last_trade_pricecan carry a trailing zero ("0.530"). Laterbookframes omittick_size. - Volume. A busy crypto 5-minute market sent about 120 frames/s (about 70 KB/s). One watched sports market sent about 26
price_changeframes/s in play.
14. Phase 2: account reads
Written 2026-09-30. Code:signing/ (keys, ClobAuth, L2 HMAC), auth.go (credential actor, L1 client, signed reads), data.go (Data API v2), account.go (core.Account), ws_user.go (user channel). Nothing here places, cancels or moves anything.
The Data API parsers are tested against responses captured from the public Data API for a public leaderboard wallet. The CLOB account responses and user channel frames cannot be captured without credentials, and this host makes no authenticated call (geoblocked; security F8). Their fixtures are the documented examples, listed with their sources in testdata/SOURCES.txt, and everything built on them is UNVERIFIED (see the end of this section).
Signing
signing.ParsePrivateKeytakes 64 hex digits with or without0x.signing.NewCredentialstakes the API key, secret and passphrase; the secret must be base64 (standard or URL-safe, padding optional).- Keys and credentials sit in unexported fields.
String,GoString,Format,LogValueandMarshalTextprint only the signer address or a fixed[redacted CLOB credentials], andHeadersprint only header names.Credentials.Revealis the one way back to the raw values; onlyauth.gocalls it, to build the user channel subscribe frame. - The package exposes no generic typed-data signing: only
ClobAuthHeaderstoday (security F9). Phase 3 adds order signing beside it, behind one typed-data allowlist (§15). - Header names go out through
http.Header.Set(Poly_address); names are case-insensitive, and HTTP/2 lower-cases them anyway. - Golden vectors: all 2
clobAuth[]cases (typed data, domain separator, digest, signature, headers, signer recovery) and all 10hmac[]cases (signed message, signature, all five headers) match byte for byte.
Configuration (for BE-Core)
New checks these and never logs or returns their values. Run Adapter.Credentials() and Adapter.UserFeed() under the supervisor; Adapter.Account() is the core.Account. The credential actor publishes its status once at start and on every change, so subscribe to core.TopicCredentialStatus before starting it. Account’s CLOB reads and the user feed ask the actor through its mailbox, so they wait for it to run.
Endpoints
The Data API host gets its own client-side limiter (5/s, burst 10), like Gamma and the CLOB. The v2 envelope is
{data, pagination}; has_more without a next_cursor is an error rather than a restart from page one.
Credential actor
- States:
none(no key and no credentials; no reason),pending(deriving),ready,failed. - Failure reasons (
core.CredentialReason*):auth_rejected(401 on L1, or credentials rejected twice),geoblocked(403 on L1),derive_failed(any other refusal, or a response whose secret is not base64),network(transport error). The full error goes only to the log. - Startup: configured credentials are used as they are. With only a key, the actor creates, then derives on 400, and holds the result in memory.
- 401: a signed read that gets 401 asks the actor, which derives once more (or fails at once without a key) and hands back new credentials; the read is repeated once. A 401 with credentials that came from such a re-derivation marks them
failed/auth_rejected, and later reads returncore.ErrNoCredentialswithout calling the venue until new credentials arrive. Concurrent 401s on the same credentials wait for the one re-derivation. - Retries (security P2-01): a derivation, at startup or after a 401, that fails on the network or a retryable status (429, 5xx) is tried again after 30 s, doubling to 10 min. After
auth_rejected, an actor with a key tries L1 again after 5, 15 and 60 min, then hourly; the schedule does not restart within a run. Without a key,auth_rejectedis final until restart.geoblockedand otherderive_failedrefusals are final. The CLOB/timeclock-skew check that would reportclock_skewinstead lands in Phase 3 (F16; TODO inauth.go). - Redaction (security P2-06): with
Config.Secretsset (a one-methodSecretRegistry, implemented byapp/log.go), the API key, secret and passphrase are registered the moment a derive response is decoded, and again with every base64 form of the secret (standard and URL-safe, padded and not) once they parse; configured credentials get their extra forms registered inNew. - Formatting (security P2-05):
signing.Credentialsandsigning.Headerskeep their values behind a pointer to an unexported struct, so a copy held by value in an unexported field prints an address throughfmtand slog, not the values.
Mappings
Numbers. The Data API sends money and sizes as JSON numbers with float noise ("price":0.5400000966). They are read from their literal text and rounded half away from zero to 6 decimals, never through float64. CLOB and user channel values are decimal strings and parse exactly. The CLOB balance is an integer in 6-decimal base units, which is already a Micros value.
Positions. Row status OPEN/MERGEABLE → open, REDEEMABLE/REDEEMABLE_LOST → redeemable, CLOSED → closed; any other value fails the call. An OPEN listing also returns resolved rows still held (with status REDEEMABLE); Positions(open) keeps only rows whose own status is open. Size = current_size, AvgPrice = avg_price, CostBasis = entry_cost_usdc (fee-exclusive), RealizedPnL = realized_pnl, EventID = pm:<event_id>. Won is true for a redeemable or closed row marked at current_price 1. end_date 1970-01-01 means none. EventTitle is left empty: the row carries only event_slug. The default dust floor (filter_amount, 0.1 shares) is kept.
Activity. Types map as below; any other type is other. Amount is usdc_size with a sign: negative for a BUY trade, SPLIT, WITHDRAWAL and an outgoing TIP, positive otherwise. ID is the first 16 bytes (hex) of SHA-256 over the transaction hash, type, condition, token, side, size, cash and time, since rows carry no id. Only CLOB token and condition ids cross the boundary; combo rows keep an empty TokenID/MarketID.
exclude_deposits_withdrawals=false is sent so transfers made on the venue site show in the history (the API leaves them out by default). Tips are opt-in on the API and are not requested.
P&L. The series is each point’s trade_pnl, which the spec calls “the compatibility chart series (p on the bare user-pnl route)”. Null points are left out, never read as zero. Windows: 1d → interval=1d&fidelity=1h (24 points), 1w → 1w/3h (56), 1m → 1m/12h (61), all → all/1d. Without a fidelity, all returns hourly points (2,840 points, 1.9 MB for the test wallet).
Order status (CLOB and user channel, any letter case): LIVE → live, DELAYED → delayed, MATCHED → matched, UNMATCHED → unmatched, CANCELED/CANCELLED → cancelled; anything else fails the read or skips the event. A user channel order event without status takes it from its type: PLACEMENT → live, UPDATE → matched once size_matched reaches original_size, else live, CANCELLATION → cancelled. ExpiresAt is set for GTD orders only. DelayEndsAt stays zero: neither source reports the end of a sports delay.
Trade status, with or without the TRADE_STATUS_ prefix: MATCHED and MATCHED_NOT_BROADCASTED → matched, MINED → mined, CONFIRMED → confirmed, RETRYING → retrying, FAILED → failed.
Fills. A trade event becomes this account’s fills by trader_side: TAKER gives one fill for taker_order_id with the event’s asset, side, price and size; MAKER gives one fill per maker_orders[] entry whose owner is this API key, with that entry’s asset, side, price and matched_amount. A maker trade naming no order of this key, or any other trader_side, is skipped as malformed. Fill.ID is the trade id, so a trade matching several of the account’s maker orders gives several fills with one ID; (ID, OrderID) is unique. Fee is 0: the event carries fee_rate_bps, not a fee amount. FeeRateBps is each leg’s rate: the trade’s fee_rate_bps for the taker leg, the maker order’s for a maker leg. A missing or malformed rate fails the event rather than reading as no fee. At is match_time (or matchtime), else timestamp.
User channel
- The first frame is
{"auth":{"apiKey","secret","passphrase"},"type":"user"}with nomarkets, so it covers every market. TextPINGevery 10 s; the connection is dropped after 30 s withoutPONG. - After every connect the feed publishes
core.AccountResync, thenFeedStatus{Feed:"pm.user",Connected:true}. Frames may be an object or an array of events. - An event that does not parse is skipped and logged,
decode_failedshows for a minute, andAccountResyncis published (at most once per 30 s) so consumers re-read orders and fills over REST (security P1-04). - A close frame from the server within 5 s of the subscribe frame is reported to the credential actor as a rejection (one re-derivation with a key, else
failed/auth_rejected), and the feed reportsauth_rejectedinstead of sending the same credentials again (security P2-04). - Without credentials the feed reports
Connected:false,Error:"no_credentials"and waits for areadyCredentialStatus. When the credentials change or fail, it disconnects and starts over. - Epoch fields are read as seconds below 10^12 and milliseconds from there on, because the docs give
timestampin both forms.
UNVERIFIED: check with real credentials from a permitted location
/data/ordersresponse shape. The OpenAPI spec shows a{limit, next_cursor, count, data}page (with placeholder asset ids), manage-orders.md shows a bare array, and the SDKs parse the page form. The client expects the page form and theMA==/LTE=cursors.- Order
statusvalues from REST and the user channel. Only the documentedLIVE,MATCHED,DELAYED,UNMATCHED,CANCELEDare known; the Rust client accepts lower case too. An unknown value failsOpenOrders. created_atandexpirationunits on/data/orders: the docs show Unix seconds. An example pairs a GTC order with a non-zeroexpiration, which is ignored for non-GTD orders.- Balance with
signature_type=3(§5): whether the server accepts it, and whetherbalanceis always an integer string in 6-decimal base units. - L2
POLY_ADDRESSfor a Deposit Wallet account: the session key (or signing EOA) address, not the wallet. With configured credentials and no key it comes fromPOLYMARKET_SIGNER_ADDRESS. - Session-key L1 (§10): that the CLOB creates or derives API credentials from a session key’s ClobAuth signature, and that the create → 400 → derive sequence behaves the same.
- Status codes: 401 for rejected L2 credentials and for a bad L1 signature; 403 for a geoblocked L1 call (mapped to
geoblocked). No response body for either was seen. - User channel frames:
timestampin seconds (asyncapi examples) or milliseconds (realtime-order-updates.md); whethertrader_sideis always present and relative to the receiving account; whethermaker_orders[].owneris the maker’s API key (what the fill attribution relies on); whether frames arrive as arrays; how a rejected subscribe is signalled (a close, an error frame, or silence). The feed treats a close frame from the server within 5 s of the subscribe frame as a rejection and reports it to the credential actor like a 401 (feed errorauth_rejected, security P2-04); a connection dropped without a close frame only reconnects. Whether the venue closes this way is the part to check. - P&L series choice: that
trade_pnlis what polymarket.com draws on the profile chart. Compare during QA;position_pnlandeconomic_pnlare the alternatives. - Activity signs for
CONVERSIONandMIGRATION(shown as positiveusdc_size), and whetherusdc_sizeof aREDEEMequals the pUSD received. - Fees: the fee paid on a fill is not reported by the user channel; if the portfolio needs it,
/data/tradesor the activity feed must be checked for a fee field.
15. Phase 3: trading
Written 2026-09-30. Code:signing/order.go (order typed data, ERC-7739 and session-key signatures), signing/amounts.go, signing/typeddata.go (the allowlist every signature goes through), trading.go (core.Trading), errors.go (reject and cancel-reason mapping), heartbeat.go (core.Heartbeat), auth.go and auth_checks.go (credential actor: session key check, clock skew).
Nothing here was sent to Polymarket: this host is geoblocked and makes no authenticated call (security F8). Signing is checked against the golden vectors; request and response handling against the documented examples (fixtures and sources in testdata/SOURCES.txt). Everything the vectors do not pin is in the UNVERIFIED list at the end, for the live verification run.
Public API (for BE-Core)
PlaceResult.OrderID is set for every order that was signed, whatever the outcome (security P3-07). It is the order’s EIP-712 hash, which the CLOB reports as the order id; an accepted order carries the id the CLOB returned. The engine reconciles an unknown outcome by it, and the audit log keeps it for a rejection. An intent refused before signing has none.
PlaceResult.PayloadHash is the SHA-256 (lower-case hex) of the exact POST body sent (security P3-07, F15). A batch is sent as one POST /orders body, so every order in it records the hash of the whole array body; the orders of one batch share one hash. An intent refused before sending has none.
Cancels and credentials (security P3-05). Cancel, CancelMarket and CancelAll use the credentials the actor holds in any state: pending while the session key check runs, failed with a transient check failure, the credentials held aside for clock_skew, and the last credentials the venue rejected (auth_rejected). A cancel only takes orders off the book. After a 401 a cancel is sent again only with other credentials. Placement, reads, the user feed and the heartbeat still need ready. Credentials the session key check refused (wallet_mismatch, geoblocked) are dropped and not used for anything.
Signing
- Domain:
{name: "Polymarket CTF Exchange", version: "2", chainId: 137, verifyingContract}with the CTF Exchange V2 or, whenOrderIntent.NegRiskis set, the Neg Risk CTF Exchange V2 (pinned insigning/order.go, from §1). - Order:
maker = signer = POLYMARKET_WALLET_ADDRESS,signatureType = 3,metadataandbuilderzero,timestampthe local clock in ms,saltfromcrypto/randin [1, 2^53−1]. - Signature: the session key signs the ERC-7739
TypedDataSignpayload (§4), giving the 317-byte signature S. S is wrapped asabi.encode(bytes32 leftpad(sessionKey), bytes32 0, bytes S) || 0x6492…6492(§10): 480 bytes on the wire. - Allowlist (gate item 13):
signTypedDatais the only path to a signature. It requires the primary type to beClobAuth,OrderorTypedDataSign, the type set to equal the package’s own exactly, chain 137, no domain salt, and the ClobAuth domain or the exchange domain on one of the two pinned exchanges. ForTypedDataSignit also requires the fixedDepositWallet/1/137/zero-salt fields, naming the order’s signer.TestSignTypedDataRefusesOtherscovers a permit, a Deposit WalletBatch, other chains, contracts and versions, extra fields and extra types. - Amounts: integer maths only. Size is rounded down to 2 decimals;
size × priceis rounded down to the tick’s amount decimals (price decimals + 2). BUY: maker = size × price, taker = size. SELL: maker = size, taker = size × price. A price off the tick grid, an unknown tick, a price outside (0, 1) or a size below 0.01 is refused before signing, never rounded. - Golden vectors: all 9
orders[]cases match byte for byte: domain separator, struct hash, order hash, signed digest, signature, each part of the 317-byte signature, and the exactpostBody. All 26amounts[]cases (limit and market, every tick size) match too. The session-key wrapper has no vector; its layout is checked against go-ethereum’s ABI encoder (UNVERIFIED).
Endpoints as implemented
All but
/time are L2-signed over the bare path and the exact body bytes sent (§3), with POLY_ADDRESS the session key. Bodies are never logged: they carry the API key and the signature. The cancel bodies equal the HMAC vectors’ bodies byte for byte.
Retries. A placement is sent once, whatever happens (CLAUDE.md). A cancel is idempotent: after a 401 it is sent once more if the actor hands back other credentials, and never repeated for any other failure. GET /time, the session key check and GET /data/order/{orderID} are reads and may retry once.
Placement outcome mapping
Error text → code (
errors.go venueRejectCode, case-insensitive substrings, first match wins; texts from resources/error-codes.md and the ts-sdk’s OrderResponseErrorCode mapping):
Rejected before sending. The risk engine should have caught these; the order is not signed.
- Off the tick grid, or an unknown tick →
tick_size. - Price outside (0, 1) →
price_range. - Size below 0.01 after rounding →
min_size. - A token id that is not a
pm:uint256, an unknown side or order type, post-only on FOK/FAK, or a GTD expiry less than 3 min ahead →invalid. - No session key, wallet or usable credentials →
no_credentials.
Reject.Message (errors.go rejectMessages). The venue’s text goes to the log line order rejected (venueError) and nowhere else.
Cancel reasons (cancelReason): text with “matched” → matched; “not found” or “already cancel…” → not_found; “delay” → delayed; anything else → venue_refused. The venue text is logged as venueReason on order not cancelled.
Heartbeat
- The first beat sends
{"heartbeat_id":""}, each later beat the id the last response returned. New credentials start a new chain. - A 400 naming the expected id is answered at once with that id. The key is
errorin the OpenAPI example anderror_msgin manage-orders.md; both are read. - Status:
Connected:truewithLastMessageAtafter each success. Otherwiserequest_failed,auth_rejected(a 401, also reported to the credential actor) orno_credentials. - It never exits on a venue error.
Session key check (security P2-02(c), P2-03)
With a session key configured, the credential actor readsGET /v1/user/session-signers with the credentials it is about to hand out (configured or derived). It stays pending until the answer confirms them:
walletequalsPOLYMARKET_WALLET_ADDRESS;- the session key’s address is listed in
signers; - that entry has scope
CLOBorALL; - its
valid_untilis in the future. It is recorded and published asCredentialStatus.ValidUntil.
- Any other answer fails the credentials as
wallet_mismatch, final for the run. So does any 4xx other than 401, 403 and 429: the venue did not confirm the key. - A 401 is handled like any other rejected credentials: clock check, one re-derivation, then the check again.
- A 403 is
geoblocked. - Network errors, 429 and 5xx are
network, retried with back-off.
scope:allow marker (the scope lint flags session-signers because the relayer’s authorisation endpoint shares the name); it is a read and needs security review like every marker.
Clock skew (security F16)
- The skew is measured against
GET /time. The CLOB answers whole seconds, so the venue clock is taken as that second plus 0.5 s and compared with the local midpoint of the request. The error is at most 0.5 s plus half the round trip. - It is published on
core.TopicClockSkewat credential actor start and every 10 min. - On a 401 the actor measures first. Above 2 s it fails the credentials as
clock_skewwithout spending the one re-derivation and holds them aside. It then measures every 30 s and hands them back (ready, new generation) once the skew is within 2 s. - At 2 s or less, or when
/timecannot be read, the 401 goes the Phase 2 way. - An L1 derivation refused with 401 is reported as
clock_skewtoo when the last measurement, under 10 min old, was above 2 s.
UNVERIFIED: check from a permitted location
- Session-key order signatures. No golden vector covers the 480-byte wrapper; it is built from the docs (§10) and ts-sdk
wallet.tscreateSessionSignerSignature. Check that the CLOB accepts a type-3 order withmaker = signer = wallet, the wrapped signature,owner= the session key’s API key andPOLY_ADDRESS= the session key. This includes the docs error “the order signer address has to be the address of the API KEY” (§6), which a type-3 order cannot literally satisfy. - Session key check with session-key credentials. The docs call
/v1/user/session-signerswith the owner’s credentials, and ts-sdkfetchSessionKeysasserts an owner client. If the CLOB refuses the session key’s own credentials (401, 403 or 404), the check fails closed and trading stays off. That result goes to the tech lead; it must not be patched around with a fallback. - Session-signers response. Only the docs example (placeholders and a
//comment) and the ts-sdk schema back the parser. The schema haswallet,signers[].address,scopesand an integervalid_until. Also check whetherALLreally coversCLOB. - Order id. The adapter assumes the returned
orderIDequals the V2OrderEIP-712 hash, not theTypedDataSigndigest (the HMAC vectors cancel by that hash). A difference is logged asorder id differs from order hash, and reconciling an unknown outcome by id would not work. - Response bodies per status. Only documented examples were used: for 425 and 429 no body at all, for 503 the three documented texts. For a single
POST /order, a processing error may come as a 400{"error"}or as a 200 withsuccess:false/errorMsg; both are handled. - Error texts. The mapping matches substrings of documented texts. If the live wording differs, a specific code degrades to
venue_rejected, never to accepted. delayedandunmatched. Whether a delayed order’s 200 carries anerrorMsg. Whetherunmatchedcomes back as a successful placement (mapped to accepted,unmatched) or a failure; the docs pages disagree (§6).- Cancel reasons. Only “Order not found or already canceled”, “Order already matched” and “order already matched” are documented. The answer for an order inside a sports delay window is unknown; it maps to
delayedonly if the text mentions “delay”. - Heartbeat. The 400 key (
errororerror_msg), and whether a first beat with an empty id while an old chain exists gets the 400 with the expected id. Both paths are handled. Heartbeats are per API key, so they cover only the session key’s orders. - Clock-skew window. That the CLOB rejects L2 requests whose
POLY_TIMESTAMPis far off, and with which window. The 2 s threshold is the app’s, and/timehas 1 s resolution. - FOK/FAK amounts. FOK and FAK orders are built with limit amounts (size × price). clob-client-v2 builds market orders from a pUSD amount instead, and the CLOB may enforce market-order precision (2 decimals on a BUY maker amount). The app sends only GTC and GTD so far; verify before sending FOK/FAK.
- GTD lead time. The 3-minute minimum is enforced locally, from place-orders.md. The venue also ends GTD orders one minute before their stated expiration.
- Rate-limit headers.
Poly-RateLimit-*andPoly-RateLimit-Warningare not read. A 429 pauses the CLOB host for itsRetry-After(or 10 s), as in Phase 1. GET /data/order/{orderID}for an unknown id. The OpenAPI spec documents 404{"error":"Order not found"}, and only that is read as not found. No SDK special-cases it:@polymarket/clientfetchOrdervalidates the body as an order, and clob-client-v2, py-clob-client-v2 and rs-clob-client-v2 return the body as is. NautilusTrader’s Rust adapter (crates/adapters/polymarket/src/http/clob.rs,get_order_optional) treats a 200 with an empty ornullbody as not found, which suggests the live CLOB may answer that way. The adapter reads such an answer as an error, so reconciliation stays unknown rather than wrongly “not found”. Capture both answers in live verification (runbook row 16.28); the parser test uses the documented examples until then (TestOrder, marked temporary).- Order ids at the venue. Whether
/data/order/{orderID}accepts the order hash in the case the adapter holds it (0xand lower-case hex, from go-ethereumHash.Hex), and returnsidin the same form. The adapter compares ids without regard to case.
16. Phase 8: Combos
Research spike, written 2026-09-30 from public sources only. This host is geoblocked and made no authenticated call. Nothing was sent to a Combos gateway. Sources:- Docs (raw markdown, fetched 2026-09-30 about 17:00Z):
trading/combos/overview.md,trading/combos/requesters.md,trading/combos/builders.md,trading/combos/market-makers.md,trading/positions/combinatorial.md,trading/session-keys.md,resources/contracts.md,api-reference/rate-limits.md,changelog/sdks.md. - Specs:
https://docs.polymarket.com/api-spec/combos-rfq-openapi.yaml(the quoter side and the public catalog only) andhttps://data-api.polymarket.com/v2/openapi.json. - SDK:
@polymarket/client@0.11.0and@polymarket/bindings@0.11.0, read from their shipped source maps. Paths below are from theirsrc/. The canary0.0.0-canary-20260930151117has the same Combos code. - Contract:
Polymarket/polymarket-v2-externalon GitHub,src/exchange/Exchange.solandsrc/exchange/OrderStructs.sol. - Live public reads: the fixtures in
backend/internal/venue/polymarket/testdata/combos/(see itsSOURCES.txt).
Builder credentials: not needed
A requester does not need Builder API credentials. There are two gateways:- The Requester API is documented in requesters.md, “Request and Execute a Quote”, API tab. VERIFIED.
- The SDK implements only the Builder Gateway.
requestComboQuote,acceptComboQuoteandfetchRfqStatususe/v1/builder/rfq/requests(actions/rfq.tsL427). Create and accept throwUserInputErrorwithout a builder key (assertBuilderAuthorization, L513–519). The requesters.md TypeScript and Python tabs say SDK support is “coming soon”. The app should call the Requester API directly: it is plain HTTP plus the signing the app already has. - What Builder API credentials can do beyond RFQ. The SDK’s
builderApiKeyreportssupportGasless: true(node.tsL40–42), so the client sends it on every relayer request (clients.tsresolveRelayerHeaders, L209–216). It authenticates relayer/submit, which carries wallet deploys and wallet batches (approvals, split, merge, redeem, transfers; wallets-auth.md, “Create New Accounts”), andPOST /v1/session-signers/authorizations(session-keys.md). A builder key on this host would add relayer access. The draft’s blocking finding and user question 1 (C1) fall away with the Requester API. - Non-builder accounts use the Requester API. It accepts Deposit, Proxy and Safe wallets and rejects EOA accounts with
UNSUPPORTED_REQUESTER_SIGNATURE_TYPE(requesters.md, “Prepare the Trading Account”).
Session Key scope
- Scopes are
CLOB,COMBOSRFQ, orALLalone.COMBOSRFQis “Trade Combos through RFQs”;ALLcovers CLOB, Combos “and any future supported venues” (session-keys.md). VERIFIED. - requesters.md gives
POLY_ADDRESSfor a Deposit Wallet as “Account or session signer with CLOB credentials”. The docs allow a session key. VERIFIED. - The SDK refuses session keys for Combos.
assertCombosSupportedForAccountthrows “Combos is not supported with Session Keys” in every requester action (actions/rfq.tsL1437–1441). INFERRED FROM SDK SOURCE. - Which scope the gateway checks, and whether it refuses a
CLOB-only key: UNVERIFIED. The app’s key isCLOB-only (§10), so Combos needs a key authorised with["CLOB","COMBOSRFQ"]or["ALL"]. The session key check (§15) would then requireCOMBOSRFQorALLbefore Combos turn on. - Whether a session key’s accept signature needs the §10 session wrapper (
abi.encode(signer, 0, S) || 0x6492…) around the 317-byte ERC-7739 signature: the docs show only the ERC-7739 form. The Deposit Wallet accepts a session signer only in the wrapped layout (ADR 0012), so the wrapper is probably required. UNVERIFIED.
Endpoints
Requester API, basehttps://combos-rfq-gateway-requester-api.polymarket.com:
- Every call is L2-signed (§3).
POLY_TIMESTAMPis in seconds, andPOLY_SIGNATUREcoverstimestamp + METHOD + path + body, where the path includes/v1/requester/rfq/...and no query.POLY_ADDRESSis the signer (owner or session key), not the wallet. Each poll gets a new timestamp and signature. - For a Deposit Wallet:
signer_address = maker_address = wallet,signature_type = 3. - There is no cancel or decline call (A11).
Gamma and the catalog both say which markets are Combo-eligible. PLAN §3.7 needs one source; Gamma
comboStatus fits, since the app already reads Gamma. /markets/keyset leaves closed markets out of a position_ids lookup unless closed=true is sent.
Wire shapes
Create, quote ready (requesters.md, “Create the RFQ”; bindingscombos/builder-rfq.ts L252–280):
builder_code. The SDK checks that request echoes the sent direction, side, leg_position_ids and requested_size (assertBuilderRfqResponseMatchesRequest, L833–865); the app should check the same.
Create, no quote: {"rfq_id":"…","status":"FAILED","error":{"code":"NO_QUOTES","message":"no quotes"}}. The SDK accepts FAILED, EXPIRED or CANCELED here, with an optional error (bindings L282–300).
Accept and status: {"rfq_id","status","taker_order_hash"?,"tx_hash"?,"error"?} (bindings BuilderRfqStatusResponseSchema, L320–346). taker_order_hash is the EIP-712 Order hash of the requester order. Only the first accept response carries it; a repeated accept omits it.
Errors (non-200): {"error":"invalid acceptance","code":"INVALID_ACCEPTANCE"}. code is an open string.
Signed order, as the SDK sends it. Key order does not matter to the venue, but the HMAC covers the exact bytes sent.
side is a JSON number, unlike the CLOB V2 body (§6). salt, the amounts, timestamp and tokenId are decimal strings.
Data API rows (fixtures, and the v2 OpenAPI ComboPosition, ComboActivity and Activity):
/v2/positions/combosis{data, pagination}with JSON numbers (current_size,gross_entry_cost_usdc,entry_fees_usdc, …),status,redeemable,legs[], RFC 3339 times andupdated_at_micros./v2/activity/combosrows haveid(<tx>-<logIndex>),type(SPLIT,MERGE,CONVERT,COMPRESS,WRAP,UNWRAP,REDEEM),timestampin seconds andlegs[]. They carry no trades.- A combo trade in
/v2/activityhascondition_id= the combo condition (0x03…, 31 bytes),token_id= the combo position id,is_combo: true,outcome_index0 or 999, and an emptyslug.usdc_sizeincludes the fee;priceexcludes it (it equals the position’sentry_avg_price_usdc). A quoter’s row for the same trade is on the NO position id.
RFQ state machine and timings
CREATEDandCOLLECTING_QUOTESare internal. A requester does not see them (combos-rfq-openapi.yamlRFQStatus; the SDK status union omits them).REJECTEDis in the OpenAPI enum but not in the SDK’s requester status union. Treat it, and any unknown status, as a failed RFQ.- The execution states
MATCHED,MINED,CONFIRMED,RETRYINGandFAILEDare merged into the top-levelstatus;MATCHEDis reported asEXECUTING(bindings L311–319). - Terminal success:
CONFIRMEDorFILLED, withtx_hash. Terminal failure:FAILED,EXPIRED,CANCELED. Keep polling whileAWAITING_MAKER_CONFIRMATION,EXECUTING,MINEDorRETRYING(requesters.md, “Poll Status”).
Signing (Exchange V3)
- Domain:
{name: "Polymarket CTF Exchange", version: "3", chainId: 137, verifyingContract: 0xe3333700cA9d93003F00f0F71f8515005F6c00Aa}.- The address is resources/contracts.md, “Combos Contracts”, row “Exchange (proxy)”; the implementation is
0x7345C6842b244926125ed4054905cAc49620B5dc. SDKenvironments.tsL170 has the same. VERIFIED. - Name and version: the requesters.md typed data, SDK
exchange.ts(createExchangeV3OrderDomain), andExchange.sol_domainNameAndVersion(L1347–1350). That repo’sdocs/exchange.mdstill says"Polymarket Exchange"/"1"; the code and the other sources agree on"3". Whether the deployed implementation matches this source was not checked (no chain read).
- The address is resources/contracts.md, “Combos Contracts”, row “Exchange (proxy)”; the implementation is
- Order type: the same 11-field
Orderand type hash as V2 (OrderStructs.solL9–14). - Deposit Wallet: the signer signs the ERC-7739
TypedDataSignwrapper (§4) under the V3 domain. The wire signature has the same 317-byte layout. VERIFIED by the vectors below. - Fields (
createComboAcceptanceOrder, L1187–1206; requesters.md, “Build and Sign the Requester Order”):maker = signer = wallet,signatureType = 3.tokenId=request.yes_position_id(the combo YES position), for BUY and SELL.side= 0 for BUY, 1 for SELL.makerAmount=maker_amount_e6andtakerAmount=taker_amount_e6, copied unchanged.timestamp= Unix seconds, as a string.salt= 64 random bits as a decimal string (L1208–1216).metadata= zero.builder= zero on the Requester API.- No
expiration,nonce,feeRateBpsortaker.
- Allowlist: the pair (
"3",0xe333…00Aa) joins the two V2 pairs. The SDK also sends ordinary orders on Protocol V2 position ids through Exchange V3 (actions/orders/context.tsL82–84), so this entry may later serve more than Combos.
Amounts and fees
Sources: requesters.md, “Create the RFQ”; bindings
combos/builder-rfq.ts L159–172.
- The fee is not in the signed order. Exchange V3 pulls
takerFillAmount + takerFeefrom the taker (Exchange.solL729) and caps the fee at the immutableMAX_FEE_RATE, in basis points of the cash value (L673–675). The deployedMAX_FEE_RATEwas not read. Risk checks must usetotal_required_e6(BUY) andnet_receive_e6(SELL), nevermakerAmount. - The fee rule is not documented. Two live combo BUYs match
fee = 0.05 × p × (1 − p) × sharesto the micro, wherepis the row’sprice: 311.39105 pUSD on 27,568.946562 shares at 0.3447577849, and 363.386377 on 50,228.271426 at 0.1754910806 (data_activity_is_combo.json,data_positions_combos.json). INFERRED from two rows. - The app computes no order amount: every amount comes from the quote. Only the request’s
value_e6is the app’s own (6 decimals, greater than 0; SDKComboAmountToBaseUnitsSchema, L545–579).
Legs
- Legs are Protocol V2 position ids from Gamma
positionIds: not CLOBclobTokenIds, and not combo position ids.positionIds[0]is YES and[1]is NO; in every captured market NO = YES + 1. - 2 to 50 unique ids. The SDK sorts them ascending before sending (L521–543), and the response echoes the sorted list.
sidemust be"YES". A requester cannot buy the combo NO; leaving a combo position is a SELL quote on YES.- The Data API
leg_condition_idis not always the GammaconditionId. Indata_positions_combos.jsonevery leg has a0x01…id: Gamma0x7c6c…82a78a666b469c032dccf652d793d049appears as0x0182a78a666b469c032dccf652d793d049followed by zeros. Map legs byleg_position_idagainst GammapositionIds, never by condition id. comboStatusvalues:pending,enabled,disabled(market-makers.md, “Map Legs to Markets”; bindingsgamma/market.tsComboKnownStatus). The SDK passes unknown values through. The fixtures holdenabledanddisabled.
Rate limits
The app should cap quote requests locally below 15 a minute.
Error codes and proposed mapping
HTTP statuses (requesters.md, “Handle Errors”): 400INVALID_JSON, INVALID_RFQ, INVALID_IDENTITY, INVALID_ACCEPTANCE, INVALID_SIGNATURE, UNSUPPORTED_REQUESTER_SIGNATURE_TYPE, BUILDER_ATTRIBUTION_NOT_ALLOWED; 401 UNAUTHENTICATED; 403 ADDRESS_MISMATCH, REQUEST_FAILED; 404 UNKNOWN_RFQ; 409 REQUEST_FAILED, QUOTE_MISMATCH, EXPIRED_RFQ, INVALID_RFQ_STATE; 429 RATE_LIMITED; 503 SERVICE_UNAVAILABLE, PRE_EXECUTION_BALANCE_RESERVATION_FAILED, TRADE_SUBMISSION_FAILED.
The SDK adds RfqRejectionCode (bindings combos/builder-rfq.ts L54–103: CONTRADICTORY_LEGS, LEG_METADATA_UNAVAILABLE, BALANCE_VALIDATION_FAILED, ALLOWANCE_VALIDATION_FAILED, INVALID_QUOTE, SUBMISSION_WINDOW_CLOSED, …) and RfqKnownErrorCode (combos/rfq.ts L73–134: NO_QUOTES, SIZE_TOO_LARGE, MAKER_DECLINED, …). The maker-side codes in those lists (QUOTED_PRICE_ABOVE_SAFETY_THRESHOLD, SIGNED_ORDER_*, MAKER_*) do not reach a requester.
Proposed mapping. New core.RejectCode values are marked new.
A lost create response is harmless: without an accept nothing moves, and the RFQ expires. The docs warn there is no idempotency key or lookup for it, so a create is never repeated.
Gaps A1–A17
Golden vectors
tools/vectors/generate-combos.mjs (npm run vectors:combos) writes signing/testdata/combos_vectors.json, next to vectors.json.
- It runs the SDK’s own
createSecureClient,requestComboQuoteandacceptComboQuoteagainst a stubbedfetchthat fails any unexpected request. The create and accept answers are synthetic stubs, not venue captures. - The key is keccak256 of a fixed label, derived at run time, so no key literal is committed. The SDK derives the beacon Deposit Wallet from it and treats the signer as the owner.
Date.nowandcrypto.getRandomValuesare pinned. Reruns are byte-identical.- 3 cases: BUY and SELL with a zero
builder(the Requester API order), and a BUY with a builder code. Each holds the create and accept bodies as sent, their L2 and builder HMAC headers, L2 signatures recomputed for the/v1/requester/rfqpaths, the order, the V3 domain separator, struct hash, order hash,TypedDataSigndigest, and the 317-byte signature with its parts. The script checks signer recovery and every part. - Not covered: session-key signatures. The SDK refuses Combos for session keys. The wrapped form would need its internal
wrapDepositWalletSignatureandcreateSessionSignerSignature(wallet.tsL91–115); it is the §10 wrapper around the vector’s 317-byte signature.
Go implementation (Phase 8 signing)
signing.Exchangeselects the order domain:ExchangeCTFV2,ExchangeNegRiskV2(both version"2") andExchangeV3(version"3",0xe333…00Aa).SignOrderandSignSessionOrdertake it; the CLOB path passesV2Exchange(negRisk).SignedOrder.Exchangerecords it.- The order check refuses a V2 timestamp below 10^11 (seconds, not milliseconds) and a V3 timestamp at or above it. On V3 it also refuses a non-zero
builder(the Requester API rejects it) and any signature type other than 3. A V3 salt may use all 64 bits (NewV3Salt); a V2 salt stays at most 2^53−1. - The typed-data allowlist pins each (domain version,
verifyingContract) pair:"2"only with the two V2 exchanges,"3"only with V3. A crossed pair or any other address is refused before signing. signing.CheckQuoteAmountsruns before an accept is signed. It refuses a quote whose implied price (pUSD over shares, frommaker_amount_e6andtaker_amount_e6) is more than one micro fromblended_price_e6, whosetotal_required_e6exceeds the app’s requestedvalue_e6or is belowmaker_amount_e6, a BUY whosenet_receive_e6differs from the shares received, and a SELL whosenet_receive_e6exceeds the gross pUSD. Integer arithmetic only.- The accept body is
rfqAcceptRequestin the Combos adapter (venue/polymarket/combos/types.go), built there byrfqAcceptBody. Wallet addresses are lower case, as the SDK sends them. - Vector results: all 3 cases match byte for byte: every order field, the V3 domain separator, struct hash, order hash,
TypedDataSigndigest (ours, and the SDK’s payload hashed by go-ethereum), the 317-byte signature and each of its parts, and the accept body.SignOrderrefuses the builder-code case; the same signing path without the check reproduces its signature. - UNVERIFIED: V3 with a session key.
SignSessionOrder(ExchangeV3, …)is the V2 session path: the §10 wrapper around the V3 ERC-7739 signature, 480 bytes. No SDK vector covers it, and whether the gateway accepts it (with or without the wrapper) needs the live run (fixtures item 5).
Fixtures captured
Inbackend/internal/venue/polymarket/testdata/combos/, verbatim (see SOURCES.txt): combo positions (resolved and open), combo lifecycle activity (REDEEM, SPLIT), /v2/activity rows with is_combo, Gamma markets with comboStatus enabled and disabled and positionIds, a Gamma lookup by position_ids, and the public RFQ catalog. The catalog carries a pending field that the combos-rfq OpenAPI spec omits.
Fixtures still needed from the live run
All need CLOB credentials from a permitted location.- Create: a BUY quote, a SELL quote,
FAILEDNO_QUOTES,FAILEDSIZE_TOO_LARGE, and 400CONTRADICTORY_LEGS. - Accept:
EXECUTINGwithtaker_order_hash,AWAITING_MAKER_CONFIRMATION,FAILEDMAKER_DECLINED, 409EXPIRED_RFQ. - Status: 409 before acceptance,
EXECUTING,MINED,CONFIRMEDandFILLEDwithtx_hash,FAILEDwitherror, 404UNKNOWN_RFQ. - Error bodies for 401, 403 (
ADDRESS_MISMATCH, and the answer to a geoblocked request), 429RATE_LIMITEDand 503. - Session key: whether the gateway accepts session-key L2 credentials and a session-key accept signature (with and without the §10 wrapper); whether it refuses a
CLOB-only key; thesession-signersanswer showingCOMBOSRFQ. - The app’s own trade in the Data API: its
is_comborow in/v2/activityand its/v2/positions/combosrow, to link both to the RFQ bytx_hash. - Whether the fill shows on the CLOB user channel (A15).