The design for the first system that writes REAL trades to Polymarket’s CLOB — a source-agnostic auto-trade engine whose first signal source is first-mover copy of Core-tier cohort traders. Spec is LOCKED (after 3 adversarial review passes) at docs/superpowers/specs/2026-06-14-pmv2-autotrade-design.md in the repo. This note captures the load-bearing decisions and hard-won facts so a future session doesn’t re-derive them.

For Agents

This is the live-money lane. It runs alongside (does not replace) the paper lane. The frozen scoring/topbets/consensus/decide_copy math is unchanged — the engine only consumes its outputs. Before any engine code, a mandatory Phase-0 spike (§14) must pin three things that cannot be settled on paper. Status: LOCKED, spike in progress.

Goal & Shape

  • A source-agnostic auto-trade engine living in a new auto-trade lane. Signal sources emit a normalized TradeSignal; engine::ingest(&TradeSignal) is the single pluggable seam. Sizing, risk-checks, execution, persistence, dry-run/live, exits, reconcile and breaker are all reused by any source unchanged.
  • First (and only-at-launch) signal source: first-mover entries from Core-tier cohort traders → real CLOB orders. Adapts the existing first-mover detection (see first-mover-overlap-watcher) plus a new exit read.
  • A second exit source reads pmv2_position_events SELL/exit events of copied traders via its own cursor (autotrade_exits in pm_detection_cursors) — it does not reuse the exits.rs notifier/shared cursor (see pmv2-exit-alerts).
  • Adding a source later = a small emitter + an enable flag. A mock source in tests asserts ingest is fully source-agnostic.

Execution: vendored Rust CLOB client

  • Vendor + pin the official Polymarket/rs-clob-client-v2 Rust crate (alloy-based) into crates/polymarket-clob/. NOT a Python sidecar, NOT hand-rolled EIP-712 signing.
  • That crate is the only place that signs. Wrapper exposes derive_creds, tick_size, neg_risk, market_status, book_top/book_depth, build_order (→ signed order + order_hash + domain verifyingContract), place_order, cancel, open_orders, trades.
  • Pin an exact -canary revision; keep it patchable.

Wallet & key custody

  • Trade from the Bandi Polymarket PROXY wallet 0xdb0d23c2c3468da13c6e8abfe96edfecc72d9efe, signatureType=1 (maker = proxy, signer = controlling EOA 0xfa7772b9aa1230a53d3bea9a11f5c52f110684f7).
  • EOA private key read from a terraform secrets file (/Users/levander/levandor/terraform/secrets/poly_private_key); held in-memory only, never logged or persisted. LIVE is refused if the key is missing.
  • No relayer / no gas management / no on-chain allowance setup. Orders are gasless off-chain EIP-712 signatures; the operator settles + pays gas; proxy allowances are pre-set. Preflight only reads allowances.
  • Self-trade guard: Bandi is ∉ the cohort, so the engine’s own fills can’t loop back as a buy signal.

The V1/V2 landmine

V1/V2 contract generation must be resolved at RUNTIME, never hardcoded

Polymarket runs two live contract generations simultaneously, and they differ in ways that silently corrupt signing if you guess:

  • Different exchange addresses (the EIP-712 verifyingContract).
  • Different EIP-712 domain version — version 1 vs 2.
  • Different collateralUSDC.e vs pUSD.

The active contract config and the collateral the Bandi proxy actually holds are resolved at preflight (spike-confirmed values). The engine then asserts the built order’s domain verifyingContract matches the market’s neg_risk flag at runtime, non-deferrable, before signing — and refuses LIVE if the proxy’s held collateral doesn’t match the resolved exchange. Collateral is resolved even before DRY_RUN signing. This is a §9.2 blocking item.

Control plane

  • Owner-only Telegram commands via a new reusable authorization layer (crates/polymarket-fetch/src/authz.rs): Scope::{Public,Owner}, OwnerSet, authorize(scope, owners, user_id), scope_for(cmd). Owner ids come from TELEGRAM_OWNER_IDS into BotConfig.owner_ids (mirrors allowed_chats). Authorize once at the shared run_command chokepoint (message + callback both converge there). The per-chat allowed_chats gate is preserved; owner gate is additive.
  • Kill-switch / modes: OFF / DRY_RUN / LIVE, gating entries. Commands: /autotrade status|off|dryrun|live|exits auto|notify|source <id> on|off|filter <expr>|set <param> <value>|maker <addr>|reconcile|resolve <id> canceled|filled.
  • Exits are governed by the per-order mode column, NOT the live kill-switch. A row whose order has mode='live' exits LIVE regardless of the current kill-switch or a tripped breaker — closing / risk-reduction must never be disabled by the open-position kill-switch. A mode='dry_run' row (no real holding) never submits a real sell.

Safety architecture (the expensive part)

The bulk of the design is the crash-safe, double-spend-safe execution machinery — this is where the real-money risk lives.

Economic gates (around frozen decide_copy, never inside)

  • Marketable-limit FAK execution with a slippage cap and a reference-anchored chase guard (reference_price frozen at first detection — see below).
  • Price bounds [min_entry_price, max_entry_price] (no edge near 0/1).
  • EV-after-cost floor: skip unless size_usd × min_round_trip_edge_bps/1e4 > est_round_trip_fee_usd (round-trip ≈ buy_fee + sell_fee + 2×expected_slippage).
  • Min-conviction floor on bet_confidence.
  • Market-liveness gate: open/active/accepting-orders + min-time-to-resolution; unknown/<2000 end_date → fail-closed (skip).
  • Category filter reuses /topbetsparse_topbets_filter/passes_filter (no new filter logic) — see pmv2-topbets-categories-filters.

Hard caps (config-row-locked, reserve-aware, fee-inclusive)

  • total_exposure_cap, daily_spend_cap, per_event_cap (keyed on event), max_copies_per_event, max_open_positions.
  • Computed including in-flight reservations (pending/submitted/selling at intended size) + open cost basis — under a SELECT … FOR UPDATE of the singleton config row (id=1), the single accounting mutex.
  • daily_spend keyed on the reserve day, carried forward to fill (one consistent clock). DRY_RUN reservations released at the pending→dry_run transition so rehearsal caps don’t fill with phantoms.

Double-sell / deadlock prevention

  • A committed selling reserve state serializes the inline Sell button, auto-sell, and resolution settlement — whoever flips open→selling first wins.
  • Global lock order (deadlock-free invariant): always acquire the config row (id=1) before any order row, never the reverse. Entries lock the config row only; sell/resolution/reconcile lock the order row only. The breaker increment runs in its own dedicated config-row-only tx, after the failure-recording order-row tx has committed and released. A test asserts no path locks order→config (prevents ABBA deadlock).
  • Symmetric ambiguous-failure handling: on a sell, unambiguous rejection (4xx/explicit-not-placed) → guarded selling→open; ambiguous failure (timeout) → stay selling for reconcile. Prevents the revert-then-double-sell.
  • Missed-exit race: if an exit matches an in-flight entry (no open row yet), set exit_pending; on the entry’s →open transition the engine immediately evaluates the queued exit.

Ground truth & recovery

  • On-chain CTF balanceOf(proxy, token_id) is the primary reconciliation truth (Polygon RPC reads in onchain.rs); CLOB trades/open-orders correlated by expected_order_hashclob_order_id is best-effort secondary (the hash↔orderID identity is spike-verified or calibrated on first fill, never a hard gate).
  • Reconcile (startup + periodic + /autotrade reconcile) operates only on non-terminal entry rows + stuck selling rows past a grace window, is forbidden from leaving terminal states, and alarms loudly on genuine ambiguity (never silently drops). /autotrade resolve is the owner escape-hatch for stuck rows.
  • Engine-ready is derived, not latched: ready = preflight_ok AND last_reconcile_ok AND creds_valid, re-evaluated each reconcile. Ready gates LIVE entries only; DRY_RUN entries and all exits do not require the on-chain reconcile. An error_class=auth failure → ready=false + re-run preflight (creds rotation).
  • Circuit breaker in the config row; counts only balance|auth|network|settlement (NOT validation); “consecutive” = since last success; re-checked before each POST via an unlocked atomic read. Tripping disables entries only — exits still submit.

State machine

(DRY_RUN) detected ▶ pending ▶ dry_run                       (terminal; reservation released)
(LIVE)    detected ▶ pending ▶ submitted ┬▶ filled ▶ open ─┐
                                         ├▶ partial ▶ open ─┤
                                         ├▶ unmatched ▶ canceled
                                         └▶ failed
                       open ▶ selling ▶ closed              (sell)
                       open ▶ closed                        (resolution settlement, periodic + idempotent)

Terminal: dry_run, closed, canceled, failed. Non-terminal (reservable/reconcilable): pending, submitted, filled, partial, open, selling. pending/selling are reserved states committed before their network call; the row lock is never held across a call. DB is the source of truth for accounting.

Phase-0 spike (MANDATORY, precedes engine planning)

Three things cannot be specified on paper — spike them first

Before writing the engine plan, vendor+pin the client and run a throwaway harness that builds+signs one known order for the Bandi proxy, to empirically pin:

  1. The exact CLOB amount-quantization rule — taken verbatim from the vendored client’s roundDown/roundNormal (the earlier “GCD of price and 1e6” guess was wrong). Capture a test vector (e.g. 0.82/$6.25).
  2. The order_hash ↔ CLOB orderID relationship — decides whether reconcile anchors on confirmed-identity or balance-primary + calibrate-on-first-fill.
  3. The active contract domain + collateral (USDC.e vs pUSD) the proxy actually requires (the V1/V2 landmine, §9.2).

Output: a short findings note that updates §4.9/§9.2/§10 with concrete values, unblocking a TDD-able plan.

Isolation guarantee

The frozen scoring/topbets/consensus/decide_copy math is unchanged — the engine only consumes its outputs. The only touches to the existing pipeline are additive read-path changes (no score change):

  • tier column pulled into the CohortTrader cohort read.
  • bet_confidence computed unconditionally by the source (today only inside if copy_cfg.enabled).
  • A new exit read over pmv2_position_events keyed (condition_id, outcome_index, proxy_wallet) with its own cursor.

New economic gates (price bounds, EV floor, liveness) live around decide_copy, never inside it. The frozen math is detailed in pmv2-two-tier-trader-scoring.

Process note

The spec went through 3 adversarial review passes and is now LOCKED. Rollout: Phase-0 spike → land mode=off + migrations + preflight green for Bandi → set TELEGRAM_OWNER_IDS + provision key → /autotrade dryrun rehearsal → small caps + /autotrade live + observe fills/fees/reconcile/restart → calibrate gates → add next source later via one emitter. Gate: bash scripts/gate.sh (see ci).