Polymarket Bot Tutorial · Chapter 9 of 32
Read Polymarket on-chain data directly: USDC/pUSD balances, CTF contract reads for outcome supply, UMA Optimistic Oracle proposed/disputed events, and Polygon transaction logs - with code.
What this chapter covers
Polymarket's APIs are convenient but eventually consistent. The chain is authoritative. This chapter walks the on-chain reads that a production bot uses to verify its own bookkeeping: pUSD balances, outcome-token inventory, UMA dispute events, and CTF contract state. The pattern most production bots converge on is API-first for speed plus periodic on-chain reconciliation for correctness.
- What lives on-chain (vs in CLOB]
- pUSD contract address and ABI
- Conditional Tokens Framework (CTF)
- UMA Optimistic Oracle: proposed and disputed events
- Reading Polygon event logs (web3.py / ethers)
- When to read on-chain vs trust the API
- Code: detect a UMA dispute via event subscription
What lives on-chain (vs in CLOB]
Two state machines, two truths.
On-chain (Polygon): pUSD balances, outcome-token inventory (ERC-1155 supply per token), allowance approvals, UMA Optimistic Oracle proposals and disputes, deposit and withdrawal events. Eventually correct; latency is one Polygon block (~2 seconds).
CLOB (Polymarket API): order book, recent trades, pending limit orders, match acknowledgments. Real-time but eventually consistent - a match is acknowledged before the ERC-1155 settles, which produces the phantom-fill problem covered in chapter 12.
The two should always converge. When they diverge, the chain is authoritative. A bot that trusts only the CLOB will drift; a bot that trusts only the chain will trade slowly. Production code uses both: CLOB for speed-critical decisions, chain for periodic reconciliation.
pUSD contract address and ABI
pUSD is Polymarket's stablecoin wrapper used since the 2026 V2 migration. The contract at 0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB on Polygon mainnet is a standard ERC-20.
Three reads that matter for a bot:
balanceOf(proxy)- your spendable pUSD. Compare against the CLOB's view of your balance on every restart.allowance(proxy, exchange_contract)- whether the CTF/NegRisk exchange contracts can spend your pUSD. Required for order matching.Transferevent subscription - detects deposits and withdrawals without polling.
USDC (0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174) remains the off-ramp pair. Most bots only need pUSD reads; USDC matters only during deposit/withdrawal cycles.
Conditional Tokens Framework (CTF)
Outcome shares are ERC-1155 tokens minted by Gnosis's Conditional Tokens Framework (CTF). The CTF contract on Polygon at 0x4D97DCd97eC945f40cF65F87097ACe5EA0476045 tracks per-position-id supply.
Three reads:
balanceOf(proxy, position_id)- how many outcome tokens you actually hold for that market+outcome.getOutcomeSlotCount(condition_id)- number of outcomes (2 for binary, N for multi-outcome).payoutNumerators,payoutDenominator- set when UMA resolves the market. Reading these tells you which side won before the CLOB UI updates.
The position_id is a hash of (condition_id, outcome_index). Compute it client-side via the CTF's getPositionId helper or replicate the keccak math in your stack.
UMA Optimistic Oracle: proposed and disputed events
UMA's Optimistic Oracle (OO) handles all Polymarket dispute resolution. Two events your bot may want to subscribe to.
ProposePrice(requester, identifier, timestamp, ancillaryData, proposer, proposedPrice)- fired when a Polymarket bot proposes an outcome.DisputePrice(requester, identifier, timestamp, ancillaryData, disputer)- fired when someone challenges the proposed outcome.
OO contract address: 0xeE3Afe347D5C74317041E2618C49534dAf887c24. Filter by Polymarket's requester address.
Why subscribe: a dispute means the resolution is now contested and will require a 24-72h UMA vote. During that window, the market may be paused for trading. A bot holding positions on a disputed market should know immediately.
Reading Polygon event logs (web3.py / ethers)
Two reference implementations.
Python (web3.py):
from web3 import Web3
w3 = Web3(Web3.HTTPProvider(POLYGON_RPC))
ctf = w3.eth.contract(address=CTF_ADDR, abi=CTF_ABI)
filter = ctf.events.PayoutRedemption.create_filter(fromBlock="latest")
for event in filter.get_new_entries():
print(event["args"])
Node (ethers v6):
import { ethers } from "ethers";
const p = new ethers.JsonRpcProvider(POLYGON_RPC);
const ctf = new ethers.Contract(CTF_ADDR, CTF_ABI, p);
ctf.on("PayoutRedemption", (redeemer, collateral, parentId, conditionId) => {
console.log("redemption", { redeemer, conditionId });
});
For continuous monitoring use WebSocket transport (wss://...) instead of HTTP polling - fewer requests and faster delivery. Most paid RPC providers include WebSocket on entry tiers.
When to read on-chain vs trust the API
Practical rules from production.
- Trust the API for: real-time order book, recent trades, your own pending orders, market metadata (slug, question, end date), event/market discovery.
- Trust the chain for: your own pUSD balance, your own outcome-token inventory, deposit and withdrawal verification, resolution outcomes, dispute state.
- Cross-check both for: anything financial that the bot recorded as a fill - match the API's "matched" event against the chain's CTF transfer to confirm settlement.
The 5-second-wait rule after a buy (chapter 12) is the on-chain reality intruding into API time. A GTC sell submitted immediately after a market buy will see balance: 0 from the chain check even though the CLOB matched moments ago.
Code: detect a UMA dispute via event subscription
Reference: watch Polymarket-related UMA disputes in real time.
from web3 import Web3
w3 = Web3(Web3.WebsocketProvider(POLYGON_WSS))
oo = w3.eth.contract(address=UMA_OO_ADDR, abi=UMA_ABI)
POLY_REQUESTER = "0x..." # Polymarket's UMA requester address
def on_dispute(event):
args = event["args"]
if args["requester"].lower() != POLY_REQUESTER.lower(): return
print(f"DISPUTE on Polymarket market: ancillary={args['ancillaryData'][:40]}...")
# decode ancillaryData to recover the market question
event_filter = oo.events.DisputePrice.create_filter(fromBlock="latest")
while True:
for ev in event_filter.get_new_entries():
on_dispute(ev)
time.sleep(2)
The ancillaryData field is hex-encoded JSON-ish text containing the market question. Decoding it gives you the slug-equivalent identifier to cross-reference against your open positions.


