A Rust bot that detects and executes binary prediction market arbitrage on Polymarket. It watches active markets' orderbooks in real time and, when it finds a mispricing (buying both YES and NO costs less than $1), can place orders to lock in risk-free profit.
A separate React dashboard in dashboard/ visualizes everything the bot does in real time via Supabase Realtime.
Status at a glance. Detection + execution work end-to-end on Polymarket CLOB V1. Default mode is dry-run. Live mode is wired (EIP-712 signing, HMAC L2 auth, on-chain allowance check at boot) but should only be opted into with tiny size. CLOB V2 migration is in flight on a branch. 77 tests,
cargo test --workspacegreen onmain.Current
mainis configured for a blockchain-club demo run — telemetry on, loosermin_net_spread, dashboard caps bumped. See DEMO.md for the runbook and the slide-number queries. To return to production-style defaults, see the bottom ofDEMO.md.
For day-to-day engineering notes, per-PR rationale, and the ordered backlog, see devlog.md. For the term-project plan and scope decisions, see docs/TERM_PROJECT_PLAN.md.
cargo run --release -p arb-botThe binary reads config/default.toml from the current working directory — run from the repo root. Default config is dry-run; no key needed.
cd dashboard
cp .env.example .env # already pre-populated with the public Supabase anon key
npm install
npm run dev # http://localhost:5173The dashboard reads from a Supabase project gated read-only by RLS. It works on its own (empty state until events arrive). To see live data, also run the bot with telemetry enabled — see Wiring the bot to the dashboard below.
In a binary prediction market, exactly one of two outcomes happens: YES or NO. You can buy YES tokens and NO tokens. When the market resolves:
- If YES wins: each YES token pays $1, each NO token pays $0
- If NO wins: each NO token pays $1, each YES token pays $0
So if you hold one YES and one NO, you get $1 no matter what. If you can buy that pair for less than $1, you profit when the market resolves.
Arbitrage condition: price(YES) + price(NO) < 1.0 (after fees)
The bot looks for exactly that on the live orderbook: best ask on YES + best ask on NO < $1, with enough size on both sides and after accounting for fees.
The bot is a Rust workspace; the dashboard is a separate Vite/React app that reads from Supabase. Everything is loosely coupled — the trading core has no dependency on the recorder, and the dashboard has no dependency on the bot beyond the shared schema.
┌────────────────┐
│ Polymarket │
│ Gamma / CLOB │
│ / WebSocket │
└────────┬───────┘
│
┌─────────────────┐ ┌─────────────────┐ ┌──────────▼──────┐
│ MarketScanner │──▶│ PriceMonitor │──▶│ ArbitrageDetect │
│ (Gamma API) │ │ (CLOB REST/WS) │ │ (strategy) │
└─────────────────┘ └────────┬────────┘ └────────┬────────┘
│ │
│ BinaryOrderBook │ opportunity
│ ▼
│ ┌─────────────────┐
│ │ RiskManager │
│ │ (limits, etc.) │
│ └────────┬────────┘
│ │ approved order
│ ▼
│ ┌─────────────────┐
│ │ OrderExecutor │ ──▶ Polymarket
│ │ (CLOB + auth) │ CLOB
└────────────┴────────┬────────┘
│
▼
┌─────────────────┐ Realtime ┌──────────────┐
│ arb-recorder │ ─────────────▶ │ Supabase │
│ (telemetry) │ PostgREST │ activity + │
└─────────────────┘ │ snapshots │
└──────┬───────┘
│ websocket
▼
┌──────────────┐
│ Dashboard │
│ (React) │
└──────────────┘
Asymmetric mode also uses InventoryManager (arb-inventory) to track unpaired legs and pairing; the main loop can run simultaneous, asymmetric, or hybrid strategy (see strategy.mode in config).
| Crate | Role |
|---|---|
arb-bot |
Entrypoint and main loop. Wires the scanner, monitor, strategy, risk, executor, recorder. |
arb-scanner |
Fetches active binary markets from Polymarket's Gamma API. Filters by liquidity / volume / time-to-resolution; refreshes periodically. |
arb-monitor |
Pulls orderbook data per market (WebSocket live or REST polling). Emits BinaryOrderBook snapshots into a channel. Can fetch balance from the CLOB for risk checks. |
arb-strategy |
Detects arb on each book. Single-level, multi-level sweep, and asymmetric leg logic. Checks staleness, fees, and min_net_spread. |
arb-risk |
Before any trade: max order size, exposure, reserve balance, per-market cooldown, max concurrent positions, unpaired limits. Records execution results. |
arb-inventory |
Tracks open legs and paired positions for asymmetric / hybrid modes. |
arb-executor |
L2 auth (derive API key via EIP-712, then HMAC POLY_* per request), places YES + NO buys (FOK/GTC). EIP-712 signed order body per the Polymarket CLOB V1 ABI. |
arb-recorder |
Additive telemetry sink — fire-and-forget POSTs to Supabase. Trading core does not depend on this crate; no-op when telemetry is disabled. |
arb-config |
Loads config/default.toml and env overrides (e.g. STRATEGY__MIN_NET_SPREAD). Private key comes from POLYMARKET_PRIVATE_KEY only. |
arb-types |
Shared types: BinaryMarket, BinaryOrderBook, StrategyMode, opportunities, execution orders, etc. |
- Load config; build scanner, monitor, detector, risk manager, inventory (for asymmetric/hybrid), executor, recorder.
- Executor mode: if
execution.mode = "live"andPOLYMARKET_PRIVATE_KEYis set, the bot callsOrderExecutor::new_live(derive API key + HMAC, then verify on-chain allowances). On missing key or derivation failure, it falls back to dry-run with a warning. - Initial scan: fetch binary markets from Gamma, build
condition_id → marketlookup. - Background tasks:
- Market refresh on
scanner.refresh_interval_secs. - Balance fetch +
RiskManager::update_balanceon interval (and arecord_snapshotif telemetry is on). - Stale-leg logging for asymmetric flows.
- Price monitor pushes
BinaryOrderBookinto a channel.
- Market refresh on
- Main loop: for each book, resolve market → detect → risk → spawn execution → record.
The bot is event-driven: it reacts to orderbook updates and only acts when an opportunity passes strategy and risk.
Live execution targets Polymarket's CLOB V1. CLOB V2 migration is in flight (drops taker/expiration/nonce/feeRateBps, adds timestamp/metadata/builder; domain version "1" → "2") on feat/clob-v2-migration and not yet on main.
- Dry-run (default): no key, or
execution.mode = "dry_run"→ logs what would be traded; no signed orders. - Authenticated HTTP: with a key, the executor attaches L2 HMAC headers and posts to
/order. - Signed order body (V1): full EIP-712-signed
Orderstruct on the wire (order_signing.rsreference-tested against Polymarket's vectors;proxy_address.rsderives the fundedmakerfor proxy + Gnosis Safe accounts; BUY amount quantization pinned againstpy-clob-client'sget_order_amounts). - Per-market fee rate (V1):
Order.feeRateBpsis fetched lazily fromGET /fee-rate?token_id=<id>and cached per token — the value goes into the EIP-712 digest, so a wrong default would cause silent CLOB rejections. - Startup allowance check: live mode verifies USDC.e and ConditionalTokens approvals on both CTF Exchange contracts (standard + neg-risk) on Polygon before enabling order placement. Missing approvals fail at boot rather than at first attempted fill.
Edit config/default.toml (and optionally override with env vars using __, e.g. STRATEGY__MIN_NET_SPREAD=0.01).
| Section | Key | Meaning |
|---|---|---|
| polymarket | clob_url, gamma_url, ws_url, chain_id |
CLOB, Gamma, WebSocket endpoints + chain ID. |
polygon_rpc_url |
Polygon JSON-RPC for the startup allowance check (default https://polygon-rpc.com; override if rate-limited). |
|
| strategy | mode |
simultaneous, asymmetric, or hybrid. |
min_net_spread, base_fee_rate, max_price_staleness_ms, use_fok_orders |
Core thresholds and ergonomics. | |
max_sweep_levels, min_sweep_profit_usdc |
Multi-level sweep tuning (0 disables sweep-first path). | |
asymmetric_target_total_cost, max_unpaired_hold_secs, max_unpaired_exposure_usdc, max_unpaired_legs_per_market |
Asymmetric tuning. | |
| risk | max_order_size_usdc, max_total_exposure_usdc, min_reserve_balance_usdc, per_market_cooldown_secs, max_concurrent_positions |
Trading limits. |
max_unpaired_exposure_usdc, max_unpaired_per_market_usdc |
Extra limits for unpaired inventory. | |
| scanner | refresh_interval_secs, max_markets, min_liquidity_usdc |
Discovery cadence and filters. |
| monitor | use_websocket, poll_interval_ms, max_concurrent_requests |
Orderbook transport and concurrency. |
| execution | mode |
"dry_run" (default) or "live" (explicit opt-in). |
| telemetry | enabled |
Send activity + snapshots to Supabase for the dashboard. Default false. Requires SUPABASE_URL + SUPABASE_SERVICE_KEY in .env. |
The dashboard reads from Supabase; the bot writes to Supabase via the arb-recorder crate. They're decoupled — running one without the other is fine.
To turn on telemetry:
- Copy
.env.exampleto.env(if you haven't). - Set
SUPABASE_URLto the project URL andSUPABASE_SERVICE_KEYto the service_role key from Supabase → Project Settings → API. The service key bypasses RLS — server-side only, never commit, never ship to the frontend. - In
config/default.toml, set[telemetry] enabled = true. - Run the bot. The snapshot loop fires every ~30s so the dashboard shows balance/exposure curves within a minute, even before a real arb appears.
The dashboard subscribes via Supabase Realtime — rows appear within a couple hundred milliseconds of insert.
Live mode is opt-in (execution.mode = "live" and POLYMARKET_PRIVATE_KEY set). Even then:
- Cap
risk.max_order_size_usdcto a few dollars andrisk.max_total_exposure_usdcto ~$10–20 for the first session. The startup check verifies approvals exist; it does not verify the full wire format end-to-end against the live CLOB. A small-size shakedown is the cheapest way to learn if anything still bites. - Set
polymarket.polygon_rpc_urlto a private provider if you have one — public RPC rate limits will surface asRPCerrors at startup. - Never commit
.envor a real key.
├── config/default.toml # bot config
├── crates/
│ ├── arb-bot/ # entrypoint, main loop, wiring
│ ├── arb-config/
│ ├── arb-executor/ # auth, HMAC, EIP-712 signing, place_order
│ ├── arb-inventory/ # asymmetric / hybrid leg tracking
│ ├── arb-monitor/
│ ├── arb-recorder/ # Supabase telemetry sink (additive)
│ ├── arb-risk/
│ ├── arb-scanner/
│ ├── arb-strategy/
│ └── arb-types/
├── dashboard/ # React + Vite frontend (this PR's work)
│ ├── src/
│ │ ├── components/ # sidebar, ticker, charts, feed, drawer
│ │ ├── hooks/ # useActivity, useSnapshots (Realtime)
│ │ ├── lib/ # supabase client, types, aggregations
│ │ └── App.tsx
│ └── README.md # dashboard-specific run + layout notes
├── supabase/migrations/ # SQL schema for activity + snapshots tables
├── docs/
│ ├── TERM_PROJECT_PLAN.md
│ └── ASYMMETRIC_MAKER_THESIS.md
├── devlog.md
├── DEMO.md # demo runbook + slide queries
├── .env.example
└── README.md
This repo is shared as a learning artifact and reference for anyone curious how a Polymarket-style arbitrage stack fits together. It is not investment advice and not a hardened production system. Running it with real funds means accepting that markets, on-chain transactions, exchange APIs, and your own bugs will sometimes lose money — read the relevant modules, size positions tiny, and don't trade with anything you can't afford to lose.