Polymarket Bot Tutorial · Chapter 12 of 32
How to detect Polymarket phantom fills (orders that look filled but are not), implement idempotent retries, distinguish status=matched from rested-on-book, and survive transient failures.
What this chapter covers
A phantom fill is a Polymarket-specific failure mode where the CLOB acknowledges a match but the chain has not yet confirmed the ERC-1155 transfer. A follow-up order within ~5 seconds is rejected with a misleading "balance: 0" error. The cure is idempotence and a settlement wait. This chapter is the production playbook we paid for with real money.
- What is a phantom fill
- status=matched vs status=delayed vs status=posted
- Polling pattern: poll status before celebrating
- FOK as anti-phantom-fill
- Idempotent retries with client_order_id
- Real production incident: how we fixed ours
- Code: detect-then-act post-order pattern
What is a phantom fill
A phantom fill is when the CLOB API responds to your order with status: "matched" but the on-chain ERC-1155 transfer has not yet settled. The CLOB matcher is faster than Polygon block production (~2s per block). For roughly 2-5 seconds after the API match, your wallet does not on-chain hold the tokens the matcher says you own.
The bot bug emerges when a follow-up action - typically a GTC sell to post the take-profit - runs within that window. The CLOB checks the chain balance, sees zero, and rejects with not enough balance / allowance: balance: 0, order amount: N. The error message blames allowance; the cause is settlement lag.
The first time this happens you assume an allowance bug and waste an hour. The cure is simple: wait, verify, then post.
status=matched vs status=delayed vs status=posted
The order placement response includes a status field with three values that matter.
matched: the order matched against the book immediately. Inventory will settle in 2-5 seconds. This is what FOK/FAK return when successful.delayed: the matcher could not settle synchronously and queued the match. Rare; usually indicates congestion. Treat likematchedfor purposes of the wait + verify pattern.posted(also calledlive): the order is resting on the book unfilled. Returned by GTC orders that didn't immediately match. Inventory is unaffected; no follow-up action needed yet.
The decision rule: if status is matched or delayed, do not place any follow-up that requires the new inventory until you have verified the chain transfer.
Polling pattern: poll status before celebrating
The verification pattern: after a successful match, poll the CTF balance until it reflects the new tokens, then proceed.
def wait_for_settlement(token_id, expected_size, timeout=15):
"""Block until on-chain balance reaches expected_size or timeout."""
start = time.time()
while time.time() - start < timeout:
bal = ctf_contract.functions.balanceOf(PROXY, token_id).call()
if bal >= expected_size:
return True
time.sleep(0.5)
return False
Typical settlement: 2-5 seconds in good network conditions, up to 15s during Polygon congestion. A 5-second wait covers 95% of cases; for production set the timeout to 15s and alert on timeout.
For high-frequency bots that cannot afford to block, an alternative is event-subscription: watch the CTF's TransferSingle event for your proxy address and trigger downstream actions on receipt. This pushes the wait to a queue instead of blocking the strategy loop.
FOK as anti-phantom-fill
Choosing FOK over FAK is a partial defense against phantom-fill chaos. FOK either fills the entire order or returns cancelled; FAK can return filled_size that is partial. When a partial fill is followed by a GTC sell sized to the original order, the sell fails on settlement-lag plus size mismatch - two compounding bugs.
With FOK, the size is binary: either the full size matched or nothing did. The follow-up posting logic always knows what to expect.
This does not eliminate the need for the wait - even a perfect FOK match is subject to the 2-5 second settlement window. But it removes one class of bookkeeping divergence.
Idempotent retries with client_order_id
Network failures during order placement create a worst-case scenario: the bot's HTTP call timed out, but the order may or may not have been received. Retrying naively can double-place; not retrying can drop a position.
The fix is the client_order_id field on order placement. Generate a deterministic UUID per intended order; if the server has seen that ID before, it returns the existing order's status rather than creating a duplicate.
import uuid
oid = str(uuid.uuid4()) # generate once, retry with same value
for attempt in range(3):
try:
resp = c.create_and_post_order(order_args=args,
options=PartialCreateOrderOptions(tick_size="0.01"),
order_type=OrderType.FOK, client_order_id=oid)
return resp
except (TimeoutError, ConnectionError):
time.sleep(0.5 * (2 ** attempt))
raise RuntimeError("post failed after 3 attempts")
The pattern: generate the ID first, retry on transport failure, never on logical rejection. The server-side dedup is per-API-key, lasting ~5 minutes.
Real production incident: how we fixed ours
From our own production diary, May 2025. A 60-minute window where the trader bot placed 22 buy orders, all matched, but only 14 GTC sells got accepted. Eight positions had no exit posted.
Root cause: the bot posted the GTC sell within 800ms of the buy match, well before the chain confirmed the ERC-1155 transfer. The CLOB rejected with the "balance: 0" message; the bot logged the error but did not retry. Eight positions silently rode to resolution without take-profit protection. Three closed out of the money; one closed at 0.99 by luck.
The fix shipped as a 5-second blocking wait between any buy fill and any GTC post on the same token. Verified via 30 paper trades plus 30 live trades; zero balance-zero errors since.
The lesson: a silent error path is more expensive than a loud one. After this we made all phantom-fill errors trigger a Telegram alert, so a future drift mode would be visible within seconds.
Code: detect-then-act post-order pattern
Production buy-then-post pattern.
def buy_then_post_tp(token_id, size, buy_price, tp_price):
# 1. Place FOK buy
opts = PartialCreateOrderOptions(tick_size="0.01") # V2 options object
buy_args = OrderArgs(token_id=token_id, price=buy_price, side=Side.BUY, size=size)
buy_resp = c.create_and_post_order(order_args=buy_args, options=opts, order_type=OrderType.FOK)
if buy_resp.status != "matched":
return {"ok": False, "stage": "buy", "reason": buy_resp.status}
# 2. Wait for on-chain settlement (5s sane default; bump for congestion)
if not wait_for_settlement(token_id, expected_size=size, timeout=15):
return {"ok": False, "stage": "settle", "reason": "timeout"}
# 3. Confirm minimum size for GTC
if size < 5:
# GTC won't accept; fall back to ride-to-resolve or FOK sell at TP later
return {"ok": True, "stage": "buy_only", "note": "size<5, no GTC posted"}
# 4. Post GTC sell
sell_args = OrderArgs(token_id=token_id, price=tp_price, side=Side.SELL, size=size)
sell_resp = c.create_and_post_order(order_args=sell_args, options=opts, order_type=OrderType.GTC)
return {"ok": sell_resp.status in ("posted","live"), "buy": buy_resp, "sell": sell_resp}
The pattern survives the common failure modes: phantom-fill, transient network drop, under-minimum GTC size. Returns enough information for the strategy layer to decide what to retry vs. log vs. alert.














