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 like matched for purposes of the wait + verify pattern.
  • posted (also called live): 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.

Frequently asked questions

What is a phantom fill on Polymarket?
A phantom fill is when your bot believes an order filled but the exchange records it as not yet filled (or partially filled). It happens when client code treats an HTTP 200 response as confirmation, when the response only means the order was accepted into the matching engine. We learned this the hard way - commits 06deaef, 8bb7761, and e68a087 in our trader history fix exactly this.
How do I avoid phantom fills?
Three rules: (1) Use FOK orders for buys - the order is either fully filled or fully gone, never ambiguous. (2) Treat any non-matched status as not filled - poll the order status until status=matched OR amount_filled > 0. (3) Use a client_order_id (clientOrderId in V2) for idempotence so retries do not double-fill.
What does status=delayed mean?
The order is in the matching engine but has not been fully matched yet. It might match within seconds or it might rest. Always poll - if status stays delayed for more than 5-10 seconds and amount_filled is 0, treat it as unfilled and consider canceling.
How do I retry safely without double-filling?
Generate a unique client_order_id per logical trade attempt and pass it on every retry. The exchange dedupes by client_order_id so a retried order with the same id is rejected as duplicate rather than placed again. Implementations: Python OrderArgs.client_order_id, Node CreateOrderOptions.clientOrderId.
Can I trust a 200 OK response from the order endpoint?
No - a 200 OK only means "your request was accepted by the matching engine," not "your order filled." You must poll the order status by orderId after submission and only treat status=matched (or amount_filled > 0) as a real fill.
What if my bot crashes between sending an order and seeing the response?
On restart, query open orders and recent fills via the SDK. Reconcile against your local diary/state - if you sent an order at time T but no record exists, query orders since T and match by client_order_id. If still missing, the order never reached the matching engine and you can safely re-send.