Перейти к основному содержимому

Исполнение ставок (/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:orderbackendpolymarket-operatorpolymarket-operator
polymarket:order-updatepolymarket-operatorbackend (все реплики)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 считаются терминальными).

XAUTOCLAIM требует Redis 6.2+

На более старом сервере консьюмер логирует предупреждение и продолжает работать без подбора зависших записей — теряются только апдейты, на которых реплика умерла в момент записи в БД.

Устаревшие интенты

Интент несёт цену, которую пользователь выбрал в конкретный момент. Исполнять его после простоя оператора нельзя — стакан уехал. 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.