Polymarket · Field notes
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.
# 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.
Trap 01 · two days
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
token_id, not assetOlder 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
Paginator yields pages, not rowsIterating 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
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
post_only=True when you mean to makeWithout 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.
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.