Исполнение ставок (/bets)
Описание
Ставка на Polymarket проходит через три процесса, связанные двумя Redis-стримами. Ни один из них не ходит в другой по HTTP: бэкенд масштабируется репликами, а оператор обязан быть строго в одном экземпляре, поэтому очередь — единственная точка стыка.
POST /bets/orders bet_orders.status = 'queued'
│
▼
polymarket:order ──────────────► polymarket-operator
(OrderProducerService) ├─ Vault.openBuy/openSell (эскроу)
├─ CLOB FOK-ордер
└─ Vault.settleBuy/settleSell
┌────────────────────────────┘
▼
polymarket:order-update ── ───────► backend
(OrderQueueService.publishUpdate) OrderUpdateConsumerService
bet_orders.status = open/filled/killed/rejected
Портфель (позиции, активность, комиссии) наполняется не из стрима, а
VaultIndexerService — он читает события Vault напрямую с Polygon. Поэтому
сломанный стрим апдейтов и сломанный индексатор дают разные симптомы:
| Что сломано | Симптом |
|---|---|
| Оператор не запущен | status вечно queued, позиций нет, ончейн ничего не было |
| Консьюмер апдейтов молчит | status вечно queued, но позиция и активность появились |
| Индексатор молчит | status доходит до filled, но портфель пустой |
Контракт стримов
Оба стрима используют раскладку полей xadd(key, '*', 'payload', json) — ту же,
что P2-продюсеры. Поле читается по имени, а не по позиции.
| Стрим | Пишет | Читает | Группа |
|---|---|---|---|
polymarket:order | backend | polymarket-operator | polymarket-operator |
polymarket:order-update | polymarket-operator | backend (все реплики) | bets-backend |
Обе группы создаются с id 0 и флагом MKSTREAM: накопленный backlog
доставляется один раз, а курсор группы переживает рестарт, не переигрывая
историю. Ошибка BUSYGROUP при создании — норма (кто-то успел раньше).
Семантика доставки различается по стримам
Интенты — at-most-once. OrderQueueService.read() подтверждает запись
(XACK) до исполнения. Повторная доставка интента открыла бы второй эскроу
по той же ставке; потерянный при падении интент дешевле — ставка просто остаётся
в queued, ничего не списано.
Апдейты — at-least-once. OrderUpdateConsumerService подтверждает запис ь
после записи в БД, а зависшие в pending записи подбирает XAUTOCLAIM через
60 секунд. Апдейт идемпотентен, дубль ничего не портит.
Из-за at-least-once апдейт может прийти после более позднего, поэтому apply()
не даёт статусу откатиться: open применяется только к строке в queued, любой
другой статус — только к нетерминальной строке (filled/killed/rejected
считаются терминальными).
На более старом сервере консьюмер логирует предупреждение и продолжает работать без подбора зависших записей — теряются только апдейты, на которых реплика умерла в момент записи в БД.
Устаревшие интенты
Интент несёт цену, которую пользователь выбрал в конкретный момент. Исполнять
его после простоя оператора нельзя — стакан уехал. read() проставляет
queuedAtMs из таймстампа id записи (<unix-ms>-<seq>), а
OrderExecutorService.execute() отклоняет интент старше
POLYMARKET_MAX_INTENT_AGE_SEC (по умолчанию 300) с причиной intent_expired.
Пользователь видит rejected с внятной причиной вместо вечного queued.
Сбой до эскроу
registerToken и openBuy/openSell обёрнуты в try/catch внутри execute(), а не
оставлены на общий catch в start(). Тот только логирует, поэтому реверт контракта
раньше приводил к тому, что ставка навсегда оставалась в queued — пользователю не
сообщалось ничего.
На этом участке ещё ничего не заэскроено, поэтому ставку безопасно объявить
rejected. Реверты контракта мапятся в стабильные коды (insufficient_balance,
order_already_open, …) — сырой текст execution reverted: InsufficientBalance()
не годится для ветвления в UI. Незамапленное падает в escrow_failed: <текст>.
Полный список — в ТЗ по интеграции фронта.
Сбои после эскроу (подпись, ожидание филла, сеттлмент) сюда не попадают: там
средства уже заблокированы и нужен settleEmpty, а не отчёт об отказе.
Развёртывание
Оператор — строго один экземпляр: пользовательский канал CLOB и nonce
оператора предполагают единственного писателя, две реплики дублируют эскроу. В
compose это replicas: 1 + update_config.order: stop-first; ослаблять оба
нельзя.
Сервис подключён к стеку только на preprod-start-v2
(docker/docker-compose.preprod-start-v2.yml). Для остальных окружений блок
копируется как есть — но до заведения секретов сервис будет крешлупить: Wallet
падает на пустом POLYMARKET_OPERATOR_PRIVATE_KEY прямо на старте DI.
Переменные
REDIS_* и RPC_URL приезжают из .env.private, остальное — в
.env.polymarket-operator (см. scripts/ci-initialize-env.sh). Обязательны
только две; всё остальное имеет дефолты в
apps/polymarket-operator/src/config/operator.config.ts и пишется через
append_if_set, чтобы незаданная CI-переменная не затирала дефолт пустой
строкой.
| Переменная | Обязательна | Дефолт |
|---|---|---|
POLYMARKET_OPERATOR_PRIVATE_KEY | да | — |
MAGNET_POLYMARKET_VAULT_ADDRESS | да | — |
POLYMARKET_VAULT_DEPLOY_BLOCK | де-факто да | 0 |
POLYMARKET_RPC_URL | нет | RPC_URL |
POLYMARKET_CHAIN_ID | нет | 137 |
POLYMARKET_COLLATERAL_ADDRESS | нет | USDC.e |
POLYMARKET_CLOB_URL / _WS_URL | нет | POLYMARKET_API |
POLYMARKET_BUILDER_CODE | нет | 0x00…00 |
POLYMARKET_ORDER_TTL_SEC | нет | 600 |
POLYMARKET_MAX_INTENT_AGE_SEC | нет | 300 |
POLYMARKET_MAX_ORDER_NOTIONAL | нет | 100000000000 |
POLYMARKET_POLL_INTERVAL_MS | нет | 5000 |
POLYMARKET_RESOLUTION_INTERVAL_MS | нет | 60000 |
Ключ оператора — горячий: он подписывает и ордера CLOB, и все транзакции Vault.
POLYMARKET_VAULT_DEPLOY_BLOCK формально имеет дефолт 0, но с ним job резолюции
неработоспособен: queryFilter с нулевого блока — это архивный запрос, и публичные
RPC (polygon-bor-rpc.publicnode.com и подобные) отвечают на него 403 Archive requests require a personal token на каждом проходе. Переменная уже заведена в CI
для бэкенда — теперь пишется и в .env.polymarket-operator.
Диагностика
# сервис вообще поднят?
docker service ls | grep polymarket-operator
# сколько интентов лежит в очереди и кто их читает
redis-cli XLEN polymarket:order
redis-cli XINFO GROUPS polymarket:order
# необработанные (pending) апдейты на стороне бэкенда
redis-cli XPENDING polymarket:order-update bets-backend
XINFO GROUPS без строки с нужной группой означает, что потребитель ни разу не
стартовал — стрим при этом растёт, а ставки висят в queued.