> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sesameterminal.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Polymarket venue notes (Phase 0 verification, Phase 1, 2, 3 and 8 additions)

# 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](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](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`).

| Contract | Address | Needed for |
| - | - | - |
| CTF Exchange V2 | `0xE111180000d2663C0091e4f400237545B87B996B` | Order `verifyingContract` (non-neg-risk) |
| Neg Risk CTF Exchange V2 | `0xe2222d279d744050d28e00520010520000310F59` | Order `verifyingContract` (neg-risk markets) |
| Conditional Tokens (CTF) | `0x4D97DCd97eC945f40cF65F87097ACe5EA0476045` | Outcome token (ERC-1155) balance and allowance |
| pUSD collateral (proxy) | `0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB` | Collateral balance and allowance |
| Neg Risk Adapter | see note | Not needed for order signing |

**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](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/trading/place-orders.md), [https://docs.polymarket.com/v2-migration.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](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](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](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:**

```
message   = str(unixSeconds) + METHOD + requestPath + (body ?? "")
key       = base64decode(secret)          // accepts URL-safe '-' '_' and missing padding
signature = base64url(HMAC_SHA256(key, message))   // '+'->'-', '/'->'_', '=' padding KEPT
```

**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](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](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:

```
innerSig (65 bytes, r||s||v)
|| appDomainSeparator (32)   = Exchange domain separator
|| contentsHash (32)         = hashStruct(Order)
|| contentsType (186 bytes)  = ASCII of the Order type string
|| uint16 big-endian length of contentsType (0x00ba)
```

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](https://docs.polymarket.com/trading/wallets-auth.md), "Derive a Deposit Wallet Address"; ts-sdk `src/wallet.ts` `deriveBeaconDepositWalletAddress` / `deriveUupsDepositWalletAddress`):

```
walletId = bytes32(leftpad(signer)); args = abi.encode(factory, walletId); salt = keccak256(args)
depositWallet = CREATE2(factory, salt, SoladyLibClone.initCodeHashERC1967Beacon(beacon, args))
```

* 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](https://docs.polymarket.com/api-spec/clob-openapi.yaml) (`/balance-allowance`), [https://docs.polymarket.com/trading/wallets-auth.md](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](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](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.

```json theme={null}
{"deferExec":false,"postOnly":false,
 "order":{"salt":895000002500,"maker":"0x…","signer":"0x…","tokenId":"<uint256 decimal>",
          "makerAmount":"12255000","takerAmount":"21500000","side":"BUY","signatureType":3,
          "timestamp":"<unix ms>","expiration":"0","metadata":"0x<32 bytes>","builder":"0x<32 bytes>",
          "signature":"0x…"},
 "owner":"<API key UUID>","orderType":"GTC"}
```

**`order` fields**

| Field | Wire type | Notes |
| - | - | - |
| `salt` | JSON **number** | Must stay ≤ 2^53−1. ts-sdk masks the salt to 53 bits because the wire is a JS number. |
| `side` | string `"BUY"`/`"SELL"` | |
| `signatureType` | number | |
| `makerAmount`, `takerAmount`, `tokenId` | decimal strings | |
| `timestamp` | string | Milliseconds |
| `expiration` | string | Unix seconds; `"0"` for GTC, FOK and FAK |

**Top-level fields**

| Field | Values and rules |
| - | - |
| `owner` | The L2 API key |
| `orderType` | `GTC`, `GTD`, `FOK` or `FAK`. Not signed. |
| `postOnly` | GTC and GTD only. `clob-client-v2` throws for FOK/FAK. |
| `deferExec` | Boolean, defaults to false |

`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](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](https://docs.polymarket.com/trading/matching-engine.md)):

| Status | Meaning |
| - | - |
| 425 | The matching engine is restarting. Back off, honouring `Retry-After`. After a restart the engine is post-only for two minutes. |
| 503, fully disabled | "Trading is currently disabled…" |
| 503, cancel-only | "Trading is currently cancel-only…" |
| 503, post-only | `{"error":"post-only mode: only post-only orders and cancels are allowed","code":"post_only_mode","retry_after_seconds":79}` plus a `Retry-After` header. In a batch, a post-only rejection comes back per order with `success: true` and that `errorMsg`. |
| 429 | Rate limited, with `Retry-After`. |

**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](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](https://docs.polymarket.com/asyncapi.json), `asyncapi-user.json` and `asyncapi-sports.json`, [https://docs.polymarket.com/market-data/realtime-data.md](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:

```json theme={null}
{"assets_ids":["99853173104990688338093880260846336068357323093533009368456412293249893123771","90042847429189999267210928921294731121534049133077079277079884949394263715589"],"type":"market","custom_feature_enabled":true}
```

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:**

| Sport | `status` | `score` | `period` |
| - | - | - | - |
| MLB, NHL | `InProgress` | `2-1` | `P2`, `End 8th` |
| Tennis | `inprogress` | `7-6(7-2), 0-0` | `S2` |
| Esports | `running` | `000-000\|1-1\|Bo3` | `3/3` |
| Cricket | (none) | `176-48` | `Live`, `Scheduled`, `FT` |

**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](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**

| Endpoint | Limit |
| - | - |
| General | 9,000/10s |
| `/book`, `/price`, `/midpoint` | 1,500/10s each |
| `/books`, `/prices`, `/midpoints` | 500/10s each |
| `/prices-history` | 1,000/10s |
| Market tick size | 200/10s |
| GET balance-allowance | 200/10s |
| UPDATE balance-allowance | 50/10s |
| `/data/orders` | 500/10s |
| `/data/trades` | 500/10s |
| `/trades`, `/orders`, `/notifications`, `/order` | 900/10s |
| `/notifications` | 125/10s |
| API key endpoints | 100/10s |

**CLOB trading**

| Endpoint | Burst | Sustained |
| - | - | - |
| `POST /order` | 5,000/10s | 120,000/10min |
| `DELETE /order` | 5,000/10s | 120,000/10min |
| `POST /orders` | 2,000/10s | 21,000/10min |
| `DELETE /orders` | 2,000/10s | 15,000/10min |
| `DELETE /cancel-all` | 250/10s | 6,000/10min |
| `DELETE /cancel-market-orders` | 1,500/10s | 21,000/10min |

**Gamma**

| Endpoint | Limit |
| - | - |
| General | 4,000/10s |
| `/events` | 500/10s |
| `/markets` | 300/10s |
| `/markets` + `/events` listing | 900/10s |
| `/comments` | 200/10s |
| `/tags` | 200/10s |
| `/public-search` | 350/10s |

**Data API v2**

| Endpoint | Limit |
| - | - |
| General | 800/10s |
| `/v2/trades` | 300/10s |
| `/v2/positions` | 200/10s |
| `/v2/activity` | 200/10s |
| `/v2/prices-history` | 200/10s |
| `/v2/status` | 100/10s |

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](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.

| Tier | 30-day volume | Order rate/s | Order burst | Cancel rate/s | Cancel burst |
| - | -: | -: | -: | -: | -: |
| Standard | — | 40 | 60 | 80 | 120 |
| Copper | \$30k+ | 60 | 90 | 120 | 180 |
| Bronze | \$50k+ | 80 | 120 | 160 | 240 |
| Silver | \$100k+ | 200 | 300 | 400 | 600 |
| Gold | \$500k+ | 400 | 600 | 800 | 1,200 |
| Platinum | \$2.5M+ | 450 | 675 | 900 | 1,350 |
| Diamond | \$5M+ | 525 | 787 | 1,050 | 1,575 |
| Elite | \$10M+ | 600 | 900 | 1,200 | 1,800 |

**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](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](../adr/0012-session-key-scope.md). 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](../adr/0013-working-balance-cap.md).

**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](mailto: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

  ```
  abi.encode(bytes32 leftpad(sessionKeyAddress), bytes32 0, bytes S) || 0x6492649264926492649264926492649264926492649264926492649264926492
  ```

  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:

| | Run 1 | Run 2 |
| - | - | - |
| Length | 65 s | 150 s |
| Frames | 32 | 69 |
| Distinct games | 19 | 19 |
| First frame | +0.7 s, a single game | +3.9 s, a single game |

* 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`).

| Tick | Price decimals | Amount decimals |
| - | - | - |
| 0.1 | 1 | 3 |
| 0.01 | 2 | 4 |
| 0.005 | 3 | 5 |
| 0.0025 | 4 | 6 |
| 0.001 | 3 | 5 |
| 0.0001 | 4 | 6 |

**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.**

| Field | Wire form |
| - | - |
| Event `volume`, `liquidity` | JSON number with float noise, e.g. `351738.3405579999` |
| Market `volume`, `liquidity` | JSON string, e.g. `"252347.73982399993"` |
| `orderPriceMinTickSize`, `orderMinSize` | JSON number: `0.01`, `0.001`, `5` |
| `line` | JSON number, e.g. `-1.5` |
| `secondsDelay`, `gameId` | JSON number |
| `gameStartTime` | `"2026-09-30 02:00:00+00"`, not RFC 3339 |

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)

| Env | `polymarket.Config` field | Notes |
| - | - | - |
| `POLYMARKET_WALLET_ADDRESS` | `WalletAddress` | `0x` and 40 hex digits. Required when a key or credentials are set; without it every account read returns `core.ErrNoCredentials` |
| `POLYMARKET_SESSION_KEY` | `SessionKey` (`*signing.Signer`) | Parse with `signing.ParsePrivateKey`. The error never quotes the input |
| `POLYMARKET_CLOB_API_KEY`, `_SECRET`, `_PASSPHRASE` | `APICredentials` (`*signing.Credentials`) | Build with `signing.NewCredentials` when all three are set; nil otherwise. With a key and no credentials they are derived at startup |
| `POLYMARKET_SIGNER_ADDRESS` (proposed) | `SignerAddress` | The address the API key belongs to, sent as `POLY_ADDRESS`. Required with credentials and no key (the F8 dev-host case); must match the key's address when both are set |
| `POLYMARKET_DATA_API_URL` | `DataURL` | Required with a wallet |
| `POLYMARKET_WS_USER_URL` | `WSUserURL` | Required with a wallet |

`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

| Read | Request | Auth | Paging |
| - | - | - | - |
| Positions | `GET data-api /v2/positions?user=<wallet>&status=OPEN\|REDEEMABLE\|CLOSED&limit=500` | none | `pagination.next_cursor`, sent back as `cursor` with the same `user` and `status`; at most 20 pages |
| Activity | `GET data-api /v2/activity?user=<wallet>&limit=<n>&exclude_deposits_withdrawals=false[&cursor=]` | none | one page per call; `next_cursor` passed through |
| P\&L | `GET data-api /v2/user-pnl?user=<wallet>&interval=&fidelity=` | none | none |
| Open orders | `GET clob /data/orders?next_cursor=MA==` | L2 over `/data/orders` | `next_cursor` until `LTE=`; at most 50 pages |
| Cash | `GET clob /balance-allowance?asset_type=COLLATERAL&signature_type=3` | L2 over `/balance-allowance` | none |
| Create key | `POST clob /auth/api-key` | L1 (ClobAuth, nonce 0) | sent once, never retried |
| Derive key | `GET clob /auth/derive-api-key` | L1 | after a 400 from create, as the SDKs do |
| User channel | `wss://ws-subscriptions-clob.polymarket.com/ws/user` | credentials in the subscribe frame | |

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`.

| Venue type | `core.ActivityType` |
| - | - |
| `TRADE` | `trade` |
| `REDEEM`, `MERGE`, `SPLIT` | `redeem`, `merge`, `split` |
| `REWARD`, `MAKER_REBATE`, `TAKER_REBATE`, `REFERRAL_REWARD`, `YIELD` | `reward` |
| `CONVERSION`, `MIGRATION` | `conversion` |
| `DEPOSIT`, `WITHDRAWAL`, `TIP` | `transfer` |

`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)

| Call | What it is |
| - | - |
| `Adapter.Trading() core.Trading` | Place (1 to 15 intents), Cancel (up to 1000 ids), CancelMarket, CancelAll, ServerTime. Needs the credential actor running. Without a session key or wallet every intent is rejected `no_credentials` and nothing is sent |
| `Adapter.Heartbeat() core.Heartbeat` | The order dead-man switch, FeedStatus `pm.heartbeat` (`polymarket.FeedHeartbeat`). Run it under the supervisor only while `heartbeat_enabled` is on. Once it has run, stopping it makes the venue cancel every open order of the API key within 10 to 15 s |
| `core.TopicClockSkew` | `time.Duration`, local minus venue (positive: local clock ahead). Published by the credential actor at start and every 10 min when a wallet is configured |
| `core.CredentialStatus.ValidUntil` | The session key's expiry from the venue; zero until the session key check passes. `engine/risk` refuses orders after it (gate item 8) |
| `core.CredentialReasonWalletMismatch`, `core.CredentialReasonClockSkew` | New failed reasons (`wallet_mismatch`, `clock_skew`) |
| `core.CancelReason*` | Fixed not-cancelled reasons: `matched`, `not_found`, `delayed`, `venue_refused` |
| `Adapter.Order(ctx, orderID)` | One order of the API key by id, in any status (`GET /data/order/{orderID}`); `found` is false only for the documented 404 `Order not found`. For reconciling an unknown placement by its order hash (security P3-03) |

`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

| Call | Request | Body | Notes |
| - | - | - | - |
| Place 1 | `POST /order` | `{"deferExec":false,"postOnly":…,"order":{…},"owner":"<API key>","orderType":"GTC\|GTD\|FOK\|FAK"}` | Key order as clob-client-v2 emits (§6). `postOnly` is always present. `expiration` is Unix seconds for GTD (at least 3 min ahead), else `"0"` |
| Place 2–15 | `POST /orders` | array of the same | One request; results in intent order |
| Cancel 1 | `DELETE /order` | `{"orderID":"…"}` | |
| Cancel 2–1000 | `DELETE /orders` | `["…","…"]` | |
| Cancel market | `DELETE /cancel-market-orders` | `{"market":"<conditionId>","asset_id":"<tokenId>"}` | `asset_id` left out when no token is given |
| Cancel all | `DELETE /cancel-all` | none | |
| Heartbeat | `POST /v1/heartbeats` | `{"heartbeat_id":"<id or empty>"}` | Every 5 s; each request bounded to 4 s |
| Session key check | `GET /v1/user/session-signers` | none | At credential start, with a session key |
| One order | `GET /data/order/{orderID}` | none | L2-signed over `/data/order/<id>`. 200: one order object (the `/data/orders` element). 404 `{"error":"Order not found"}`: not found. Any other 404 body is an error. The id must be 1 to 100 letters, digits or `_`, and the answer must name the same id |
| Own trades (fill backfill) | `GET /data/trades?maker_address=<wallet>&after=<unix seconds>` | none | L2-signed. Pages by `next_cursor` to the last page. Each trade maps to fills exactly as on the user channel (taker leg, or the maker orders of this API key; `fee_rate_bps` per leg; ID = trade id); a trade that does not parse fails the read. Read at every open-orders snapshot, from the last seen fill less 2 min (P4R-01). Fixture `testdata/clob_trades_openapi.json` is the OpenAPI example, UNVERIFIED until runbook row 16.32 |
| Server time | `GET /time` | none | Public. Answers a bare integer of Unix seconds, `text/plain` (captured live: `testdata/clob_time.json`) |

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

| Venue answer | `PlaceStatus` | `RejectCode` | `RetryAfter` |
| - | - | - | - |
| 200 entry: `success`, non-empty `orderID`, `status` live/matched/delayed/unmatched, empty `errorMsg` | accepted, `OrderStatus` from `status` | | |
| 200 entry: `status` delayed with `errorMsg` "order match delayed due to market conditions" | accepted, delayed | | |
| 200 entry: empty `orderID` and an `errorMsg` | rejected | by text (below) | |
| 200 entry: anything else (unknown status, an id with an error, `success:false` without text) | unknown | | |
| 200 batch whose entry count differs from the orders sent, or a body that does not decode | unknown, every order | | |
| 400 `{"error"}` | rejected | by text (below) | |
| 401 | rejected; the credential actor is told (clock check, then one re-derivation) | `no_credentials` | |
| 403 | rejected | `geoblocked` | |
| 425 (engine restart) | rejected | `venue_restarting` | `Retry-After` |
| 429 | rejected | `venue_rate_limited` | `Retry-After` |
| 503 with `code: post_only_mode` or "post-only mode…" | rejected | `venue_post_only` | `Retry-After`, else `retry_after_seconds` |
| 503 "Trading is currently cancel-only…" | rejected | `venue_cancel_only` | |
| 503 "Trading is currently disabled…" | rejected | `venue_disabled` | |
| 503 with any other body | unknown | | |
| 408, 500 (including "order timed out") and every other 5xx | unknown | | |
| any other 4xx | rejected | `venue_rejected` | |
| transport error, timeout or lost response after the request left | unknown | | |
| failure before the request left (context ended in the rate-limit wait, signing failed) | `Place` returns an error; nothing was sent | | |

**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):

| Text or code | `RejectCode` |
| - | - |
| `code` `post_only_mode`, or starts with "post-only mode" | `venue_post_only` |
| "cancel-only" | `venue_cancel_only` |
| "trading is currently disabled" | `venue_disabled` |
| "crosses book", "crosses the book" | `venue_crosses_book` |
| "not enough balance" | `venue_balance` |
| "minimum tick size" | `tick_size` |
| "lower than the minimum" | `min_size` |
| "not yet ready to process new orders" | `market_closed` |
| "rate limit" | `venue_rate_limited` |
| anything else | `venue_rejected` |

**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:

| Gateway | Host | Base path | Auth | `builder` in the order |
| - | - | - | - | - |
| Requester API | `https://combos-rfq-gateway-requester-api.polymarket.com` | `/v1/requester/rfq` | CLOB L2 headers only | must be zero; non-zero is `BUILDER_ATTRIBUTION_NOT_ALLOWED` |
| Builder Gateway | `https://combos-rfq-gateway-builder.polymarket.com` (SDK `environments.ts` L208–210; the docs say it is "provided during builder onboarding") | `/v1/builder/rfq` | CLOB L2, plus `POLY_BUILDER_*` on create and accept | must equal the returned `builder_code` |

* 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`:

| Call | Request | Body | Answer |
| - | - | - | - |
| Create RFQ | `POST /v1/requester/rfq/requests` | `{signer_address, maker_address, signature_type, leg_position_ids[], direction, side:"YES", requested_size:{unit, value_e6}}` | held until the quote competition ends; 200 with a quote, or 200 with a terminal `status` and `error` |
| Accept | `POST /v1/requester/rfq/requests/{rfq_id}/accept` | `{quote_id, signed_order:{…, signature}}` | 200 with the latest status; holds up to 1 s for Last Look |
| Status | `GET /v1/requester/rfq/requests/{rfq_id}` | none | 200 `{rfq_id, status, tx_hash?, error?}`; 409 before acceptance |

* 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:

| Read | Request | Auth |
| - | - | - |
| Leg markets | Gamma `GET /markets/keyset?closed=false&combo_status=enabled&limit=100`, or `?position_ids=<id>&position_ids=<id>` | none |
| Combo catalog | `GET https://combos-rfq-api.polymarket.com/v1/rfq/combo-markets?limit=&cursor=&exclude=` | none |
| Combo positions | Data API `GET /v2/positions/combos?user=&status=&condition=&limit=&cursor=&sort_by=&sort_direction=&updated_after=` | none |
| Combo lifecycle activity | Data API `GET /v2/activity/combos?user=&condition=&limit=&cursor=` | none |
| Combo trades | Data API `GET /v2/activity?user=&type=TRADE`, rows with `is_combo: true` | none |

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):

```json theme={null}
{"rfq_id":"…","status":"AWAITING_REQUESTER_ACCEPTANCE","expires_at":1773890763000,
 "request":{"rfq_id":"…","maker_address":"…","requestor_public_id":"…","leg_position_ids":["…","…"],
  "condition_id":"<combo condition>","yes_position_id":"…","no_position_id":"…","direction":"BUY","side":"YES",
  "requested_size":{"unit":"notional","value_e6":"1000000"},"created_at":1773890758000},
 "quote":{"quote_id":"…","blended_price_e6":"500000","maker_amount_e6":"966191","taker_amount_e6":"1932381",
  "total_required_e6":"1000000","net_receive_e6":"1932381"}}
```

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.

```json theme={null}
{"builder":"0x00…00","maker":"<wallet>","makerAmount":"966191","metadata":"0x00…00","salt":"1311768467463790320",
 "side":0,"signatureType":3,"signer":"<wallet>","takerAmount":"1932381","timestamp":"1790000000",
 "tokenId":"<combo yes position id>","signature":"0x<317 bytes>"}
```

`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

```
create --(held until the competition ends)--> AWAITING_REQUESTER_ACCEPTANCE --accept--> AWAITING_MAKER_CONFIRMATION --> EXECUTING --> MINED --> CONFIRMED or FILLED
  |                                             |                                         |  (Last Look, 1 s at most)       |       |
  +--> FAILED (NO_QUOTES, SIZE_TOO_LARGE, ...)  +--(no accept by expires_at)--> EXPIRED   +--> FAILED (MAKER_DECLINED)      +--> RETRYING, FAILED
```

* `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").

| Timing | Value | Source |
| - | - | - |
| Maker quote window | 400 ms | overview\.md |
| Acceptance window | "five seconds from quote readiness" (requesters.md); "10 seconds max" (overview\.md). Use `expires_at` | docs |
| Last Look | 1 s at most; the accept call holds up to 1 s for it | overview\.md, requesters.md |
| SDK create timeout | 30 s | `actions/rfq.ts` L430 |
| SDK accept | 30 s timeout; then polls status every 500 ms for up to 30 s while `AWAITING_MAKER_CONFIRMATION` | L433–435, L1117–1131 |
| SDK fill wait | polls every 1 s, 30 s by default (the docs example passes 120 s) | L436–437, L1320–1372 |

### 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

| Field | BUY | SELL |
| - | - | - |
| `requested_size` | `unit:"notional"`, a pUSD budget including fees | `unit:"shares"`, combo shares to sell |
| `makerAmount` (`maker_amount_e6`) | pUSD paid, fee excluded | combo shares given |
| `takerAmount` (`taker_amount_e6`) | combo shares received | gross pUSD limit |
| `total_required_e6` | pUSD needed, fee included | shares needed |
| `net_receive_e6` | shares received | pUSD received after fees |
| `blended_price_e6` | pUSD per share across legs | same |

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

| Limit | Value | Status |
| - | - | - |
| Create RFQ | 15 per rolling minute per `maker_address`; above it 429 `RATE_LIMITED` | VERIFIED (requesters.md, "Create the RFQ") |
| Accept, status | not documented | UNVERIFIED |
| `/v2/positions/combos`, `/v2/activity/combos` | 200/10s each, inside the Data API v2 800/10s | VERIFIED (rate-limits.md) |
| Combo catalog | not listed | UNVERIFIED |

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*.

| Venue answer | Outcome | `RejectCode` |
| - | - | - |
| create 200 with a quote | quoted | |
| create 200 `FAILED` `NO_QUOTES` | no quote | `combo_no_quotes` *new* |
| create 200 `FAILED` `SIZE_TOO_LARGE` | no quote | `combo_size_too_large` *new* |
| create 200 `FAILED`, `EXPIRED` or `CANCELED` with another code | no quote | `venue_rejected` |
| accept 200 `EXECUTING`, `MINED`, `RETRYING` | accepted; poll | |
| accept 200 `AWAITING_MAKER_CONFIRMATION` | accepted; poll status every 500 ms | |
| accept or status `CONFIRMED` or `FILLED` with `tx_hash` | filled | |
| `FAILED` with `MAKER_DECLINED` | rejected | `combo_maker_declined` *new* |
| `EXPIRED`, or 409 `EXPIRED_RFQ` | rejected | `combo_quote_expired` *new* |
| `FAILED` or `CANCELED` after acceptance, any other code | failed | `venue_rejected` |
| 400 `CONTRADICTORY_LEGS` | rejected | `combo_contradictory_legs` *new* |
| 400, any other code | rejected | `venue_rejected` |
| 401 `UNAUTHENTICATED` | rejected; the credential actor is told, as for the CLOB (§15) | `no_credentials` |
| 403 `ADDRESS_MISMATCH` | rejected | `venue_rejected` |
| 403, any other code | rejected | `geoblocked` (the gateway's geoblock answer is UNVERIFIED) |
| 404 `UNKNOWN_RFQ` | rejected | `venue_rejected` |
| 409 `QUOTE_MISMATCH`, `REQUEST_FAILED` | rejected | `venue_rejected` |
| 409 `INVALID_RFQ_STATE` on accept | unknown; read status | |
| 429 `RATE_LIMITED` | rejected | `venue_rate_limited` |
| `BALANCE_VALIDATION_FAILED`, `ALLOWANCE_VALIDATION_FAILED`, `PRE_EXECUTION_BALANCE_RESERVATION_FAILED` | rejected | `venue_balance` |
| 503 on create | rejected | `venue_disabled` |
| 503 on accept, any other 5xx, a timeout, or a transport error after the request left | unknown; read status | |
| status 409 while reconciling an accept | the acceptance was not recorded: not accepted | `venue_rejected` |

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

| Gap | Answer | Status |
| - | - | - |
| A1 Gateway paths, bodies, auth | Requester API `/v1/requester/rfq/requests`, `/{id}/accept`, `/{id}` on `combos-rfq-gateway-requester-api.polymarket.com`, CLOB L2 only. Builder Gateway `/v1/builder/rfq/...` with L2 plus `POLY_BUILDER_*` on create and accept. Bodies above | VERIFIED (requesters.md, builders.md; SDK `actions/rfq.ts` L427, `clients.ts` L595–602 and L806–835) |
| A2 HTTP status table | 400, 401, 403, 404, 409, 429, 503 with the codes above; business outcomes come back as 200 | VERIFIED (requesters.md, "Handle Errors"; builders.md) |
| A3 Error codes | The list above plus the SDK enums. Codes are open-ended; an unknown code is a rejection | VERIFIED (docs, bindings) |
| A4 Exchange V3 | `0xe3333700cA9d93003F00f0F71f8515005F6c00Aa` (proxy), implementation `0x7345C684…B5dc`; domain `"Polymarket CTF Exchange"`, `"3"`, 137 | VERIFIED (contracts.md, SDK, `Exchange.sol`); deployed code not checked |
| A5 Rate limits | Create: 15 a minute per `maker_address`. Data API combos: 200/10s. Accept and status: not documented | VERIFIED in part; accept and status UNVERIFIED |
| A6 ComboStatus | `pending`, `enabled`, `disabled`; unknown values pass through | VERIFIED (market-makers.md; fixtures) |
| A7 Position and leg status | Rows: `OPEN`, `REDEEMABLE`, `RESOLVED_WIN`, `RESOLVED_LOSS`, `RESOLVED_PARTIAL`. The `status` filter also takes `PARTIAL`, and `REDEEMABLE` must be alone. Legs: `OPEN`, `RESOLVED_WIN`, `RESOLVED_LOSS` | VERIFIED (Data API v2 OpenAPI; bindings `data/portfolio.ts` L22–29; the fixtures show `OPEN`, `RESOLVED_LOSS`, `RESOLVED_WIN`) |
| A8 Timestamp units | Gateway `expires_at`, `created_at`: ms. Order `timestamp`: s, as a string. HMAC: s. `/v2/activity` and `/v2/activity/combos` `timestamp`: s (the SDK converts to ms). Positions: RFC 3339 plus `*_micros` | VERIFIED (requesters.md; OpenAPI; fixtures) |
| A9 Positions shape conflict | The scrape's `{combos:[…]}` with string numbers (L19185) is the v1 shape. `/v2/positions/combos` is `{data, pagination}` with JSON numbers | VERIFIED (live `data_positions_combos.json`; migrate/data-api-v1-to-v2.md) |
| A10 Accept amounts and fees | Copy `maker_amount_e6` and `taker_amount_e6`; `tokenId` is the YES position. The fee sits outside the order, capped by `MAX_FEE_RATE`; observed fee 0.05 × p × (1 − p) × shares | Copying VERIFIED (docs, SDK, vectors); fee rule INFERRED from two live rows |
| A11 Requester cancel or decline | None exists. Without an accept the RFQ reaches `EXPIRED`. Status reads return 409 before acceptance, so an unaccepted RFQ cannot be read | VERIFIED absent from the docs, the RFQ OpenAPI and the SDK. The app's Decline is local only |
| A12 Partial fill for the requester | A quote may bundle several makers (`FillBundle.allocations`), but the requester signs one order for the quoted amounts and gets one `tx_hash`, `FILLED` or `FAILED`. Whether `total_required_e6` can be below the requested budget is not documented | INFERRED; UNVERIFIED |
| A13 "Safely retry" accept | The docs and the SDK (one retry on a transport error or an unreadable response, L1091–1101) say a repeated accept does not execute twice. CLAUDE.md still forbids the automatic retry. Reconcile with `GET` status: 200 gives the state, 409 means the acceptance was not recorded | VERIFIED (builders.md, SDK); the decision stays with the tech lead |
| A14 Exchange V3 approvals | BUY: pUSD `approve(Exchange V3)`. SELL: PositionManager `0x006F54F7f9A22e0000CC2AB60031000000ae9fEF` `setApprovalForAll(Exchange V3)`. A missing one comes back as `ALLOWANCE_VALIDATION_FAILED` | INFERRED FROM SDK SOURCE (`actions/approvals.ts` L590–593, L633–636); a user action on polymarket.com |
| A15 Combo fills on the CLOB user channel | Not documented. Only the quoter WebSocket has `RFQ_EXECUTION_UPDATE`, and it is maker-only | UNVERIFIED |
| A16 FILLED vs CONFIRMED | Both are terminal success with `tx_hash`; the SDK treats them the same (`waitForComboFill`, L1335–1350). Which one comes last is not documented | VERIFIED as equivalent; order UNVERIFIED |
| A17 Minimum size and fees | No minimum is documented. `value_e6` must be greater than 0 with at most 6 decimals; too large gives `SIZE_TOO_LARGE`. Fees as A10 | No documented minimum VERIFIED; a venue minimum UNVERIFIED |

### 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).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.