Clearing an off grid price bug out of Polymarket's order path
DEV Community

Clearing an off grid price bug out of Polymarket's order path

Project Overview

Polymarket ships a unified Python SDK, py-sdk, for building on their prediction market: constructing, pricing, signing, and submitting orders. It's currently in beta and moving fast, which is exactly where money path bugs like to hide. I've been running the SDK against live markets, so I pointed an audit pass at its order validation and signing code and found a real one.

Bug Fix or Performance Improvement

Before an order is signed, the SDK validates the price against the market's tick size (the smallest allowed price increment). Two functions do this, _resolve_price for limit orders and _resolve_protected_market_price for market orders, and both check the wrong thing. They validate the price's decimal place count, not whether the price is an actual multiple of the tick:

if decimal_places(price) > config.price:
    raise UserInputError(f"price must conform to tick size {tick_size} ...")
return round_normal(price, config.price)

Decimal place count equals tick grid membership only for power of ten ticks (0.1, 0.01, 0.001, 0.0001). The SDK also supports two half step ticks, 0.005 and 0.0025, and for those the two measures diverge. A price like 0.007 has three decimal places, so it passes the check, but it is not a multiple of 0.005. The grid is {0.005, 0.010, 0.015, ...}, and 0.007 is not on it.

from decimal import Decimal
from polymarket._internal.actions.orders.limit import _resolve_price

_resolve_price(Decimal("0.007"), Decimal("0.005"))
# returns Decimal("0.007"), no error, even though 0.007 is off the tick grid

The consequence is worse than a cosmetic slip. Polymarket orders are signed with EIP-712, and the price is baked into the signature. That means the exchange cannot round an off grid price onto the grid without invalidating the signature, so it can only reject the order. The client side guard that exists specifically to prevent that wasted signing round trip does not fire, on exactly the markets where it is needed. It stays invisible on every classic market, because there decimal count and grid membership happen to agree.

Code

  • Merged PR: GiulioDER/py-sdk#1
  • Reported upstream: Polymarket/py-sdk#162

The fix is eight lines, a grid membership check added after the existing decimal check in both validators:

if price % tick_size != 0:
    raise UserInputError(f"price {price} must be a multiple of tick size {tick_size}.")

It is purely additive. Any price that validated before is a tick multiple, so it still passes; only genuinely off grid prices are newly rejected, and those were going to be rejected by the exchange anyway, just later and less clearly.

My Improvements

I did not want to ship a "looks right to me" patch into someone else's live money path, so the fix carries its proof:

  • A red to green test suite. New unit tests assert that off grid prices are rejected on both validators, that on grid prices (including the exact range boundaries price == tick and price == 1 - tick) still pass, and that the pre existing decimal place error is unchanged. Plus an end to end test that drives the real public prepare_limit_order_draft path with a mocked 0.005 tick market and confirms the guard fires there too.
  • An exhaustive correctness sweep. For every supported tick, I enumerated every on grid multiple across the whole valid range and every in allowance off grid probe: about 23,000 cases, zero false rejects and zero false accepts. Decimal % Decimal is exact, so there is no floating point residue to worry about.
  • Clean gates. ruff format, ruff check, and pyright all pass; the full order path unit suite stays green.
  • An adversarial review. I had the change reviewed by an independent pass whose only job was to find a reason a maintainer would reject it. Its strongest counter argument, "maybe the server is meant to snap off grid prices," is exactly what EIP-712 signing rules out: a signed order cannot be silently repriced. That turned into the clearest line in the writeup.

Since the repository limits pull requests to collaborators, I merged the fix on a fork (allowed by the contest rules) and filed a full report as an upstream issue, so the maintainers have the bug, the repro, and the patch in one place.

What I took away

A validation check should test the invariant it actually claims to enforce, not a proxy that happens to coincide with it. This one promised the price "must conform to tick size" but tested decimal place count, and those two agree on every classic market and diverge exactly on the newer half step ticks. A single assertion on a 0.005 tick would have caught it.

Comments

No comments yet. Start the discussion.