Polymarket · Field notes

The Python client is archived

Every order we signed came back invalid order version. The library was not misconfigured — it was retired, and the last release on PyPI still ships stale contract addresses. Here is the whole migration, and the five traps that cost us three days.

The short version. py-clob-client is archived. Its own README says it is “no longer functional, should not be used for new or existing integrations”. The final PyPI release (0.34.6) is archived too, and points at exchange contracts Polymarket has moved off. Replace it with polymarket-client. Everything below follows from that one fact.

The migration, in one block

# archived - every order returns "invalid order version"
pip install py-clob-client
from py_clob_client.client import ClobClient
client = ClobClient(host, key=pk, chain_id=137,
                    signature_type=2, funder=PROXY)
pip install polymarket-client        # the package imports as `polymarket`
from polymarket import SecureClient
client = SecureClient.create(private_key=pk, wallet=PROXY)
client.place_limit_order(token_id, price, size, side, post_only=True)

wallet= replaces the old signature_type/funder pair. Pass the proxy address that actually holds the collateral, not the signing EOA — they are different accounts, and using the EOA reports a zero balance with no error.

Five traps we paid for

Trap 01 · two days

Collateral moved to pUSD. Your USDC is not the collateral.

The CLOB reports balance: 0 while your wallet visibly holds USDC.e. The reason is that Polymarket migrated to its own collateral token, pUSD (0xc011a7e1…, 6 decimals), and the archived client’s config.py never learned the new exchange addresses. Approvals you set from that config land on contracts nobody uses.

The pUSD token itself is minted only by Polymarket: the implementation behind its EIP-1967 proxy exposes mint(address,uint256) and nothing else — no deposit, no wrap, no redeem. Read that alone and you would conclude the deposit screen is the only way in. It is not. A separate pair of onramp/offramp contracts does the conversion, and both are live on Polygon mainnet:

ONRAMP   0x93070a847efEf7F70739046A929D47a521F5B8ee
         wrap(address,address,uint256)     selector 0x62355638

OFFRAMP  0x2957922Eb93258b93368531d39fAcCA3B4dC5854
         unwrap(address,address,uint256)   selector 0x8cc7104f

We checked both: 2,651 and 2,653 bytes of code respectively, each carrying the matching selector in its bytecode. Credit for the addresses goes to py-sdk#261 — we verified them on chain, we did not discover them. As of writing that request is open and unanswered, so the SDK gives you no wrapper for either call yet.

If your balance reads zero and your wallet says otherwise, stop debugging the client. You are holding the wrong token — and there is a contract that fixes that.

Trap 02 · silent, and it empties positions

Positions are keyed by token_id, not asset

Older examples read asset or asset_id off a position row. The current SDK returns token_id. Read the wrong name and every position looks unreadable.

This one is worse than a crash, because a careful bot does the right thing with bad data: ours refused to sell what it could not identify, held through a live match, and turned a market-making position into a directional bet. Guard the failure, but read the field correctly.

token = getattr(row, "token_id", None) or getattr(row, "asset", None)

Trap 03 · looks like it works

A Paginator yields pages, not rows

Iterating the result of list_positions() or list_open_orders() hands you Page objects. Each one is truthy, countable, and completely wrong if you treat it as a record. Flatten first, and read .items — a Page is not iterable.

rows = [row for page in paginator for row in page.items]

Trap 04 · keyword-only, positionally rejected

Signatures that refuse to be guessed

Several calls take keyword-only arguments and raise on the obvious positional form. The balance also comes back in micro-units — divide by 1e6, or your risk limits are off by a factor of a million.

client.get_balance_allowance(asset_type="COLLATERAL")   # keyword required
client.get_order_book(token_id=tid)                     # positional rejected
client.place_market_order(..., shares=n)                # SELL wants shares=, not amount=
balance = raw / 1_000_000                               # micro-units

Trap 05 · costs you the spread, quietly

Always pass post_only=True when you mean to make

Without it, a limit order that crosses the spread is accepted as a taker. You then pay the fee you were trying to earn, on the trade you were trying to avoid. post_only rejects the order instead of crossing — a structural protection, not a preference.

What the order book taught us, separately

Two measurements, in case they save someone a week. Both are ours, both are on mainnet, and both contradicted what the screen suggested.

A wide spread usually marks a slow book, not an opportunity. Our first resting order sat fourteen hours without a fill. The book was not empty — it had depth on both sides and an eight-tick spread. Nobody was tightening it because nothing was happening in that market. Screening on 24h volume changed our candidate set far more than any spread filter did.

Full-book arbitrage is structurally dead. Across 1,937 markets with both books readable, there were zero where buying YES and NO together cost under $1. 37% sat locked between 1.000 and 1.0015. Market makers hold asks one tick above parity and bids one tick below; the quotes never cross. That is not scarcity, it is the design.

Disclosure, because it belongs next to the advice. We run a builder on Polymarket. If you route orders through our remote signer, Polymarket attributes them to our builder code and you pay 10 bps on taker fills (maker is currently 0). That fee comes out of your trade, not ours — so only route through us if the tooling is worth a tenth of a percent to you.

Everything on this page works without us, with no builder code, for free. It is written down because we needed it and could not find it, and that is the whole reason it exists.