Skip to main content

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 .md to a page URL returns its raw markdown.
  • @polymarket/clob-client-v2@1.2.0: the package installed in tools/vectors/node_modules. Its GitHub repo is Polymarket/clob-client-v2. Paths below are from its src/, 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-v2 and Polymarket/rs-clob-client-v2 on GitHub.
Items marked UNVERIFIED could not be confirmed here. Each one says what was tried.

Which SDK the vectors use

  • The clob-client-v2 README 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, computeLimitOrderAmounts and the session-key wrapper are all @internal. Its order workflow needs an authenticated client, which means calling /auth/derive-api-key.
  • clob-client-v2 exports createL1Headers, createL2Headers, OrderBuilder, orderToJsonV2 and getContractConfig, all usable offline.
  • The vectors therefore come from clob-client-v2. Every HMAC vector is also checked against the unified SDK’s exported buildHmacSignature, 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 as clob-client-v2 for 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) × price fits 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 in clob-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 no verifyingContract.
  • 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, nonce and feeRateBps are not signed in V2.
  • Side is signed as uint8 (BUY = 0, SELL = 1) but sent as the string "BUY"/"SELL".
  • timestamp is in milliseconds.
  • The vectors include domainSeparator, structHash, orderHash and signedDigest for every order.

2. L1 auth

Sources: https://docs.polymarket.com/getting-started/api.md and clob-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).
    • timestamp is a string of Unix seconds.
    • nonce defaults to 0.
    • message is exactly This 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"}. secret is 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.ts createOrDeriveApiKey.
  • Errors (https://docs.polymarket.com/resources/error-codes.md): 401 Invalid L1 Request headers, 400 Could not create api key, 400 Could 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:
Headers: 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-v2 signs the endpoint constant and passes query params to axios separately.
  • py signs request_path=endpoint and sends params=.
  • rs signs request.url().path().
  • ts-sdk signs request.path and sends searchParams separately.
The body is signed as the exact bytes sent.
  • 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.
Endpoints and bodies. 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/orders and /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 _
Each case records signedMessage and urlQuery.

4. Deposit Wallet order signatures (signature type 3, POLY_1271)

Sources:
  • clob-client-v2 src/order-utils/exchangeOrderBuilderV2.ts (buildOrderSignature) and src/order-builder/helpers/createOrder.ts
  • ts-sdk src/exchange.ts (createExchangeOrderSignature) and src/wallet.ts (resolveOrderIdentity)
  • py order_utils/exchange_order_builder_v2.py _build_poly_1271_order_signature
  • rs src/clob/client.rs sign_poly1271_order
  • https://docs.polymarket.com/trading/place-orders.md
Order fields. 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) plus Order.
  • Message: {contents: <order>, name: "DepositWallet", version: "1", chainId: 137, verifyingContract: <deposit wallet>, salt: 0x00…00}.
Wire signature (owner key). The byte layout is identical in all four clients:
Total: 317 bytes (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.ts deriveBeaconDepositWalletAddress / 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.
Vectors: 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 metadata and builder and an expiration
How salt and timestamp are fixed. 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>"}}.
Refresh: 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.
Which account is read. The balance is read for the account behind the API key, selected by 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_type is 0,1,2, and its error text reads “must be EOA, POLY_PROXY, or GNOSIS_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 OpenAPI SendOrder 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 secondsDelay field.
    • “During either delay, the order is pending and cannot be canceled” (order-lifecycle.md).
  • 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.
Errors (https://docs.polymarket.com/resources/error-codes.md). All errors are plain {"error": "<message>"} strings with no code enum, apart from post_only_mode. Examples:
  • invalid post-only order: order crosses book
  • order {id} is invalid. Price ({price}) breaks minimum tick size rule: {tick}
  • … Size ({size}) lower than the minimum: {min}
  • not enough balance / allowance
  • invalid expiration
  • order match delayed due to market conditions
  • 500 order timed out
HTTP status semantics (https://docs.polymarket.com/trading/matching-engine.md): 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 KEY conflicts with type-3 orders, where signer is 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”) and clob-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, not error_msg. The docs disagree.
Scope: heartbeats are tied to API credentials, so a session key’s heartbeat only covers that key’s orders. Vector: 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:
Documented optional fields:
  • initial_dump: defaults to true.
  • level: 1, 2 or 3, defaults to 2. Its meaning is not documented.
  • custom_feature_enabled: enables best_bid_ask, new_market and market_resolved.
Changing subscriptions without reconnecting: send {"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 book objects, one per asset. It includes tick_size and last_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 book is re-sent for both assets right after each trade, alongside last_trade_price and best_bid_ask.
  • Each price_change item carries best_bid and best_ask.
UNVERIFIED: maximum assets per connection and maximum connections. Not found in asyncapi.json, wss/market.md, realtime-data.md, market-making.md or llms-full.txt. Capture fixture: backend/internal/venue/polymarket/testdata/market_ws_frames.jsonl.
  • Market: NHL “Canucks vs. Oilers” (nhl-van-edm-2026-09-29, condition 0xfe4a8a13…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, 12 book, 12 best_bid_ask and 6 last_trade_price.
  • The three PONG text frames are not JSON and are left out.
  • The initial dump’s own trailing \n serves 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:
  • order has type PLACEMENT, UPDATE or CANCELLATION.
  • trade has status MATCHED, MINED, CONFIRMED, RETRYING or FAILED.
The docs say: “Send the subscription frame immediately after connecting. The server may close a connection that remains unsubscribed.” They also say the feed does not replay missed events, which is why REST reconciliation after a reconnect stays in the plan. UNVERIFIED: live user-channel frames. Subscribing needs API credentials, which were not used here. §14 lists what the Phase 2 parser assumes.

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"}
  • elapsed is added for clock sports, for example NHL.
Field formats vary by sport: 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 no homeTeam, awayTeam, status or elapsed fields, and leagueAbbreviation is always cricket, which is not a Gamma league code (Gamma has crint, cricwncl, crickcct20 and others).
  • An ended game repeats its final frame and, in the 2026-10-01 capture, alternated between 156-158 and 0-1, both FT.
  • Gamma cricket main events also have no gameId. They carry eventMetadata.gameId in one of two forms: id and 16 digits (id2703162375014782, with score and period filled) or 1000169732LIVE2026 (no score or period at all). Companion events carry parentEventId and no game id. In a scan of open sports events on 2026-10-01, only cricket events had eventMetadata.gameId, and no event had both it and gameId. Gamma’s game_id filter refuses these ids (invalid integer).
UNVERIFIED: that a frame’s metadataGameId equals the Gamma main event’s eventMetadata.gameId.
  • For: both use the same id + 16 digits form and number range. The WS id id2703162375014780 falls between the Gamma cricwncl ids id2703162375014778 and id2703162375014782.
  • 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.gameId across the 1,516 cricket events starting 2026-09-01 to 2026-10-31, open and closed. The listed id… games then were cricwncl games 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, reason no gameId), and Gamma cricket events map without a game.
  • To settle it: while a listed cricket game with an id… eventMetadata.gameId is live, capture the sports channel for 5 minutes and GET /events/{id} for its main event (live verification runbook row 16.46). It is confirmed when a frame’s metadataGameId equals the event’s eventMetadata.gameId and the frame’s score and period match the event’s.
Schema discrepancy: asyncapi-sports.json documents a different shape (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 ping every 5 seconds; reply with pong within 10 seconds”.
  • The ts-sdk code replies to a text ping with pong.
  • The Go client should answer protocol pings (coder/websocket does this automatically) and also reply pong to a text ping.

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 = 1
  • POST /orders = number of orders
  • DELETE /order = 1
  • DELETE /orders = number of IDs
  • cancel-all and cancel-market-orders = 1 + number cancelled
  • Batches are all-or-nothing.
Response headers: 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-sdk src/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 (domain DepositWallet/1/137/wallet) that calls authorizeSessionSigner(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"}]}.
How the app uses a key:
  • L1: the session key signs ClobAuth with its own address, and POLY_ADDRESS is the session key. The API credentials therefore belong to the session key.
  • L2: POLY_ADDRESS is the session key address. owner in the order body is the session key’s API key.
  • Orders: maker = signer = deposit wallet and signatureType = 3. The session key signs the same TypedDataSign payload 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.ts createSessionSignerSignature, SESSION_SIGNER_MAGIC_BYTES). The second word is the signer kind, 0 for a secp256k1 key; the wallet’s SessionSignerLib.decodeSessionSigner accepts this layout only (verified in the contract source, ADR 0012).
Access boundaries: a session key sees only its own orders, trades and notifications, and revoking it cancels its open orders. Client support: only the unified SDKs support this (@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. createSessionSignerSignature is internal to @polymarket/client@0.11.0, and its order workflow cannot run without an authenticated client. The wrapper is plain abi.encode plus 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.
Phase 1 update: the sports client relies on those protocol pings for liveness. It reconnects after 45 s with neither a frame nor a ping, and never sends a text ping.

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).
PLAN §3.7 and §7 require the tech lead to decide what happens here before any code is written. Fixture: 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).
Market order amounts:
  • BUY: maker = amount in pUSD, taker = floor(amount / price).
  • SELL: maker = shares, taker = floor(shares × price).
The app never sends market orders; those vectors are for reference only. Minimum order size: 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 in backend/internal/venue/polymarket/testdata/, captured verbatim.

Gamma

Keyset listings treat closed differently.
  • /events/keyset without closed returns open and closed events alike. closed=false returns open ones, and closed=true returns only closed ones.
  • /markets/keyset without closed returns only open markets. closed=true returns only closed ones.
  • To find a market by condition id whether it is open or closed, the client asks closed=false and then closed=true.
  • Pages hold at most 100. next_cursor is omitted on the last page, and it is passed back as after_cursor.
Listing every open event (checked 2026-10-01).
  • /events/keyset?closed=false lists 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=1 leaves 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=1 and exclude_tag_id=1 split 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 with tag_id=1 and 25 with exclude_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 under tag_id=1. In 700 captured events none was untagged, and none had a gameId or a sport without 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) and exclude_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> without closed. That answer holds open and closed events alike and leaves unknown ids out (gamma_events_keyset_ids.json: id=16183&id=2890&id=999999999 returned the open 16183 and the closed 2890, and nothing for 999999999; checked 2026-10-01). An answer naming an event not asked for, carrying a next_cursor (gamma_events_keyset_ids_truncated.json: id=16183&id=2890&limit=1 returned 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.
Offset listings cap at 100 rows. /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/99999999999 gives 422 {"type":"validation error","error":"id is invalid"}.
Game events. /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.
Only 14 leagues are 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=1w needs fidelity ≥ 5 and interval=1m needs ≥ 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).
  • p is a JSON number. Every captured value has 4 decimals or fewer.
  • The last timestamp can repeat (clob_prices_history_1h.json ends 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 of book objects for just those assets, the same form as the initial dump.
  • Removing assets. unsubscribe stops frames for those assets within a few frames.
  • Fresh book for one asset. A second subscribe for an asset already subscribed sends nothing. unsubscribe followed by subscribe sends 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_change frame 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 later book frames equal the first snapshot with every price_change applied, where size is the level’s new total and "0" removes it.
  • tick_size_change is 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_resolved arrives about 2 minutes after a crypto 5-minute market ends. It carries assets_ids, winning_asset_id and winning_outcome. Fixture: market_ws_frames_resolved.jsonl, one complete 2-minute connection to one market (74 frames).
  • new_market frames arrive on every connection with custom_feature_enabled, for markets that were never subscribed (30 in 2 minutes). The client ignores them.
  • best_bid_ask is ignored because the book is the single source (PLAN §3.7).
  • Book dump fields. The dump’s last_trade_price can carry a trailing zero ("0.530"). Later book frames omit tick_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_change frames/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.ParsePrivateKey takes 64 hex digits with or without 0x. signing.NewCredentials takes 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, LogValue and MarshalText print only the signer address or a fixed [redacted CLOB credentials], and Headers print only header names. Credentials.Reveal is the one way back to the raw values; only auth.go calls it, to build the user channel subscribe frame.
  • The package exposes no generic typed-data signing: only ClobAuthHeaders today (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 10 hmac[] 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 return core.ErrNoCredentials without 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_rejected is final until restart. geoblocked and other derive_failed refusals are final. The CLOB /time clock-skew check that would report clock_skew instead lands in Phase 3 (F16; TODO in auth.go).
  • Redaction (security P2-06): with Config.Secrets set (a one-method SecretRegistry, implemented by app/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 in New.
  • Formatting (security P2-05): signing.Credentials and signing.Headers keep their values behind a pointer to an unexported struct, so a copy held by value in an unexported field prints an address through fmt and 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 no markets, so it covers every market. Text PING every 10 s; the connection is dropped after 30 s without PONG.
  • After every connect the feed publishes core.AccountResync, then FeedStatus{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_failed shows for a minute, and AccountResync is 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 reports auth_rejected instead of sending the same credentials again (security P2-04).
  • Without credentials the feed reports Connected:false, Error:"no_credentials" and waits for a ready CredentialStatus. 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 timestamp in both forms.

UNVERIFIED: check with real credentials from a permitted location

  1. /data/orders response 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 the MA==/LTE= cursors.
  2. Order status values from REST and the user channel. Only the documented LIVE, MATCHED, DELAYED, UNMATCHED, CANCELED are known; the Rust client accepts lower case too. An unknown value fails OpenOrders.
  3. created_at and expiration units on /data/orders: the docs show Unix seconds. An example pairs a GTC order with a non-zero expiration, which is ignored for non-GTD orders.
  4. Balance with signature_type=3 (§5): whether the server accepts it, and whether balance is always an integer string in 6-decimal base units.
  5. L2 POLY_ADDRESS for a Deposit Wallet account: the session key (or signing EOA) address, not the wallet. With configured credentials and no key it comes from POLYMARKET_SIGNER_ADDRESS.
  6. 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.
  7. 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.
  8. User channel frames: timestamp in seconds (asyncapi examples) or milliseconds (realtime-order-updates.md); whether trader_side is always present and relative to the receiving account; whether maker_orders[].owner is 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 error auth_rejected, security P2-04); a connection dropped without a close frame only reconnects. Whether the venue closes this way is the part to check.
  9. P&L series choice: that trade_pnl is what polymarket.com draws on the profile chart. Compare during QA; position_pnl and economic_pnl are the alternatives.
  10. Activity signs for CONVERSION and MIGRATION (shown as positive usdc_size), and whether usdc_size of a REDEEM equals the pUSD received.
  11. Fees: the fee paid on a fill is not reported by the user channel; if the portfolio needs it, /data/trades or 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, when OrderIntent.NegRisk is set, the Neg Risk CTF Exchange V2 (pinned in signing/order.go, from §1).
  • Order: maker = signer = POLYMARKET_WALLET_ADDRESS, signatureType = 3, metadata and builder zero, timestamp the local clock in ms, salt from crypto/rand in [1, 2^53−1].
  • Signature: the session key signs the ERC-7739 TypedDataSign payload (§4), giving the 317-byte signature S. S is wrapped as abi.encode(bytes32 leftpad(sessionKey), bytes32 0, bytes S) || 0x6492…6492 (§10): 480 bytes on the wire.
  • Allowlist (gate item 13): signTypedData is the only path to a signature. It requires the primary type to be ClobAuth, Order or TypedDataSign, 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. For TypedDataSign it also requires the fixed DepositWallet/1/137/zero-salt fields, naming the order’s signer. TestSignTypedDataRefusesOthers covers a permit, a Deposit Wallet Batch, other chains, contracts and versions, extra fields and extra types.
  • Amounts: integer maths only. Size is rounded down to 2 decimals; size × price is 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 exact postBody. All 26 amounts[] 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.
Each code carries one fixed 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 error in the OpenAPI example and error_msg in manage-orders.md; both are read.
  • Status: Connected:true with LastMessageAt after each success. Otherwise request_failed, auth_rejected (a 401, also reported to the credential actor) or no_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 reads GET /v1/user/session-signers with the credentials it is about to hand out (configured or derived). It stays pending until the answer confirms them:
  • wallet equals POLYMARKET_WALLET_ADDRESS;
  • the session key’s address is listed in signers;
  • that entry has scope CLOB or ALL;
  • its valid_until is in the future. It is recorded and published as CredentialStatus.ValidUntil.
Failures:
  • 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.
The check runs once per run; a later re-derivation for the same key does not repeat it. The path constant carries a 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.TopicClockSkew at credential actor start and every 10 min.
  • On a 401 the actor measures first. Above 2 s it fails the credentials as clock_skew without 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 /time cannot be read, the 401 goes the Phase 2 way.
  • An L1 derivation refused with 401 is reported as clock_skew too when the last measurement, under 10 min old, was above 2 s.

UNVERIFIED: check from a permitted location

  1. Session-key order signatures. No golden vector covers the 480-byte wrapper; it is built from the docs (§10) and ts-sdk wallet.ts createSessionSignerSignature. Check that the CLOB accepts a type-3 order with maker = signer = wallet, the wrapped signature, owner = the session key’s API key and POLY_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.
  2. Session key check with session-key credentials. The docs call /v1/user/session-signers with the owner’s credentials, and ts-sdk fetchSessionKeys asserts 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.
  3. Session-signers response. Only the docs example (placeholders and a // comment) and the ts-sdk schema back the parser. The schema has wallet, signers[].address, scopes and an integer valid_until. Also check whether ALL really covers CLOB.
  4. Order id. The adapter assumes the returned orderID equals the V2 Order EIP-712 hash, not the TypedDataSign digest (the HMAC vectors cancel by that hash). A difference is logged as order id differs from order hash, and reconciling an unknown outcome by id would not work.
  5. 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 with success:false/errorMsg; both are handled.
  6. Error texts. The mapping matches substrings of documented texts. If the live wording differs, a specific code degrades to venue_rejected, never to accepted.
  7. delayed and unmatched. Whether a delayed order’s 200 carries an errorMsg. Whether unmatched comes back as a successful placement (mapped to accepted, unmatched) or a failure; the docs pages disagree (§6).
  8. 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 delayed only if the text mentions “delay”.
  9. Heartbeat. The 400 key (error or error_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.
  10. Clock-skew window. That the CLOB rejects L2 requests whose POLY_TIMESTAMP is far off, and with which window. The 2 s threshold is the app’s, and /time has 1 s resolution.
  11. 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.
  12. 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.
  13. Rate-limit headers. Poly-RateLimit-* and Poly-RateLimit-Warning are not read. A 429 pauses the CLOB host for its Retry-After (or 10 s), as in Phase 1.
  14. 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/client fetchOrder validates 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 or null body 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).
  15. Order ids at the venue. Whether /data/order/{orderID} accepts the order hash in the case the adapter holds it (0x and lower-case hex, from go-ethereum Hash.Hex), and returns id in 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) and https://data-api.polymarket.com/v2/openapi.json.
  • SDK: @polymarket/client@0.11.0 and @polymarket/bindings@0.11.0, read from their shipped source maps. Paths below are from their src/. The canary 0.0.0-canary-20260930151117 has the same Combos code.
  • Contract: Polymarket/polymarket-v2-external on GitHub, src/exchange/Exchange.sol and src/exchange/OrderStructs.sol.
  • Live public reads: the fixtures in backend/internal/venue/polymarket/testdata/combos/ (see its SOURCES.txt).
Status words: VERIFIED (a source says it), INFERRED FROM SDK SOURCE (the SDK does it; the docs do not say), UNVERIFIED (needs the live run).

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, acceptComboQuote and fetchRfqStatus use /v1/builder/rfq/requests (actions/rfq.ts L427). Create and accept throw UserInputError without 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 builderApiKey reports supportGasless: true (node.ts L40–42), so the client sends it on every relayer request (clients.ts resolveRelayerHeaders, L209–216). It authenticates relayer /submit, which carries wallet deploys and wallet batches (approvals, split, merge, redeem, transfers; wallets-auth.md, “Create New Accounts”), and POST /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, or ALL alone. COMBOSRFQ is “Trade Combos through RFQs”; ALL covers CLOB, Combos “and any future supported venues” (session-keys.md). VERIFIED.
  • requesters.md gives POLY_ADDRESS for 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. assertCombosSupportedForAccount throws “Combos is not supported with Session Keys” in every requester action (actions/rfq.ts L1437–1441). INFERRED FROM SDK SOURCE.
  • Which scope the gateway checks, and whether it refuses a CLOB-only key: UNVERIFIED. The app’s key is CLOB-only (§10), so Combos needs a key authorised with ["CLOB","COMBOSRFQ"] or ["ALL"]. The session key check (§15) would then require COMBOSRFQ or ALL before 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, base https://combos-rfq-gateway-requester-api.polymarket.com:
  • Every call is L2-signed (§3). POLY_TIMESTAMP is in seconds, and POLY_SIGNATURE covers timestamp + METHOD + path + body, where the path includes /v1/requester/rfq/... and no query. POLY_ADDRESS is 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).
Public reads: 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”; bindings combos/builder-rfq.ts L252–280):
The Builder Gateway adds 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/combos is {data, pagination} with JSON numbers (current_size, gross_entry_cost_usdc, entry_fees_usdc, …), status, redeemable, legs[], RFC 3339 times and updated_at_micros.
  • /v2/activity/combos rows have id (<tx>-<logIndex>), type (SPLIT, MERGE, CONVERT, COMPRESS, WRAP, UNWRAP, REDEEM), timestamp in seconds and legs[]. They carry no trades.
  • A combo trade in /v2/activity has condition_id = the combo condition (0x03…, 31 bytes), token_id = the combo position id, is_combo: true, outcome_index 0 or 999, and an empty slug. usdc_size includes the fee; price excludes it (it equals the position’s entry_avg_price_usdc). A quoter’s row for the same trade is on the NO position id.

RFQ state machine and timings

  • CREATED and COLLECTING_QUOTES are internal. A requester does not see them (combos-rfq-openapi.yaml RFQStatus; the SDK status union omits them).
  • REJECTED is 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, RETRYING and FAILED are merged into the top-level status; MATCHED is reported as EXECUTING (bindings L311–319).
  • Terminal success: CONFIRMED or FILLED, with tx_hash. Terminal failure: FAILED, EXPIRED, CANCELED. Keep polling while AWAITING_MAKER_CONFIRMATION, EXECUTING, MINED or RETRYING (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. SDK environments.ts L170 has the same. VERIFIED.
    • Name and version: the requesters.md typed data, SDK exchange.ts (createExchangeV3OrderDomain), and Exchange.sol _domainNameAndVersion (L1347–1350). That repo’s docs/exchange.md still 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).
  • Order type: the same 11-field Order and type hash as V2 (OrderStructs.sol L9–14).
  • Deposit Wallet: the signer signs the ERC-7739 TypedDataSign wrapper (§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_e6 and takerAmount = 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, feeRateBps or taker.
  • 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.ts L82–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 + takerFee from the taker (Exchange.sol L729) and caps the fee at the immutable MAX_FEE_RATE, in basis points of the cash value (L673–675). The deployed MAX_FEE_RATE was not read. Risk checks must use total_required_e6 (BUY) and net_receive_e6 (SELL), never makerAmount.
  • The fee rule is not documented. Two live combo BUYs match fee = 0.05 × p × (1 − p) × shares to the micro, where p is the row’s price: 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_e6 is the app’s own (6 decimals, greater than 0; SDK ComboAmountToBaseUnitsSchema, L545–579).

Legs

  • Legs are Protocol V2 position ids from Gamma positionIds: not CLOB clobTokenIds, 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.
  • side must be "YES". A requester cannot buy the combo NO; leaving a combo position is a SELL quote on YES.
  • The Data API leg_condition_id is not always the Gamma conditionId. In data_positions_combos.json every leg has a 0x01… id: Gamma 0x7c6c…82a78a666b469c032dccf652d793d049 appears as 0x0182a78a666b469c032dccf652d793d049 followed by zeros. Map legs by leg_position_id against Gamma positionIds, never by condition id.
  • comboStatus values: pending, enabled, disabled (market-makers.md, “Map Legs to Markets”; bindings gamma/market.ts ComboKnownStatus). The SDK passes unknown values through. The fixtures hold enabled and disabled.

Rate limits

The app should cap quote requests locally below 15 a minute.

Error codes and proposed mapping

HTTP statuses (requesters.md, “Handle Errors”): 400 INVALID_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, requestComboQuote and acceptComboQuote against a stubbed fetch that 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.now and crypto.getRandomValues are 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/rfq paths, the order, the V3 domain separator, struct hash, order hash, TypedDataSign digest, 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 wrapDepositWalletSignature and createSessionSignerSignature (wallet.ts L91–115); it is the §10 wrapper around the vector’s 317-byte signature.

Go implementation (Phase 8 signing)

  • signing.Exchange selects the order domain: ExchangeCTFV2, ExchangeNegRiskV2 (both version "2") and ExchangeV3 (version "3", 0xe333…00Aa). SignOrder and SignSessionOrder take it; the CLOB path passes V2Exchange(negRisk). SignedOrder.Exchange records 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.CheckQuoteAmounts runs before an accept is signed. It refuses a quote whose implied price (pUSD over shares, from maker_amount_e6 and taker_amount_e6) is more than one micro from blended_price_e6, whose total_required_e6 exceeds the app’s requested value_e6 or is below maker_amount_e6, a BUY whose net_receive_e6 differs from the shares received, and a SELL whose net_receive_e6 exceeds the gross pUSD. Integer arithmetic only.
  • The accept body is rfqAcceptRequest in the Combos adapter (venue/polymarket/combos/types.go), built there by rfqAcceptBody. 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, TypedDataSign digest (ours, and the SDK’s payload hashed by go-ethereum), the 317-byte signature and each of its parts, and the accept body. SignOrder refuses 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

In backend/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.
  1. Create: a BUY quote, a SELL quote, FAILED NO_QUOTES, FAILED SIZE_TOO_LARGE, and 400 CONTRADICTORY_LEGS.
  2. Accept: EXECUTING with taker_order_hash, AWAITING_MAKER_CONFIRMATION, FAILED MAKER_DECLINED, 409 EXPIRED_RFQ.
  3. Status: 409 before acceptance, EXECUTING, MINED, CONFIRMED and FILLED with tx_hash, FAILED with error, 404 UNKNOWN_RFQ.
  4. Error bodies for 401, 403 (ADDRESS_MISMATCH, and the answer to a geoblocked request), 429 RATE_LIMITED and 503.
  5. 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; the session-signers answer showing COMBOSRFQ.
  6. The app’s own trade in the Data API: its is_combo row in /v2/activity and its /v2/positions/combos row, to link both to the RFQ by tx_hash.
  7. Whether the fill shows on the CLOB user channel (A15).