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

Интеграция ставок Polymarket на фронт

Бэкенд, контракт и operator-сервис готовы. Фронту нужно: 4 ончейн-вызова кошельком и 8 REST-эндпоинтов. Вся торговля на Polymarket идёт мимо фронта — он не подписывает ордера и не ходит в CLOB.

Типы уже лежат в @magnetmlm/common (bets.types.ts) и совпадают поле в поле с существующими entities/position/model/types.ts — адаптер тождественный, можно выкинуть mock-фикстуры и подставить хук.

Неймспейс занят

apps/frontend/src/api/bets/ занят старым продуктом (prediction/*). Заводи новый — например api/vault-bets/.

Адрес и ABI

const cfg = await api.get('bets/config'); // публичный, без JWT
// { chainId: 137, vaultAddress, collateralAddress, collateralDecimals: 6,
// feeBps: 2000, epochLengthSec, minOrderUsd, maxOrderUsd }

ABI: MagnetPolymarketVault__factory.abi из apps/frontend/src/shared/typechain-types.

Все суммы — 6 знаков. parseUnits(x, 6) / formatUnits(x, 6). Никогда не parseEther.

1. Депозит — ончейн, кошельком юзера

await usdc.approve(cfg.vaultAddress, amount); // USDC.e, 6 знаков
await vault.deposit(amount);

Реверт NotRegisteredInDiamond — юзера нет в даймонде. Показать «сначала зарегистрируйтесь», кнопку депозита блокировать по bets/account.

2. Ставка — REST, асинхронно

Сюда вешается onClick в entities/market/ui/BuyPanel.tsx (сейчас его нет вообще).

const clientOrderId = crypto.randomUUID(); // ключ идемпотентности

await api.post('bets/orders', {
slug, // из URL /bets/[slug]
outcome: pick, // 'yes' | 'no' — из BuyPanel
side, // 'buy' | 'sell'
amountUsd: '100', // строка, для buy
shares: '200', // строка, для sell
limitPriceCents: 62, // цена + допуск на проскальзывание
clientOrderId,
});

Ответ приходит сразу со статусом queued — ставка ещё не исполнена. Дальше поллинг раз в 1–2 с:

const r = await api.get(`bets/orders/${clientOrderId}`);
// status: queued → open → filled | killed | rejected
  • filled — есть filledShares, avgPriceCents, txHash
  • killed — FOK не нашёл ликвидности, деньги вернулись, предложить повтор с большим limitPriceCents
  • rejected — смотреть reason, см. таблицу ниже

Частичных исполнений не бывает (только FOK) — либо всё, либо ничего.

Ретрай отправляй с тем же clientOrderId — вернётся исходный результат, вторая ставка не поставится.

Причины отказа (reason при rejected)

reasonЧто произошлоЧто показать пользователю
insufficient_balanceНа счёте в vault не хватает средств под ставку«Недостаточно средств. Пополните счёт» + кнопка депозита
insufficient_sharesПродажа долей, которых у юзера нет«Недостаточно долей для продажи»
not_registeredЮзера нет в даймонде«Сначала завершите регистрацию»
order_already_openПо этой паре (юзер, токен) уже есть незакрытый ордер«Дождитесь исполнения предыдущей ставки»
notional_out_of_rangeСумма вне minOrderUsd / maxOrderUsd«Сумма вне допустимого диапазона»
price_out_of_rangelimitPriceCents выше потолка контракта«Слишком высокая цена исполнения»
intent_expiredСтавку не успели исполнить вовремя (простой оператора)«Ставка не была исполнена вовремя. Проверьте цену и повторите.»
size_out_of_rangeСумма ≤ 0 или выше лимита оператора«Недопустимая сумма ставки»
order_queue_unavailableБэкенд не смог поставить ставку в очередь«Сервис временно недоступен, повторите»
escrow_failed: …Незамапленный сбой при эскроу (сеть, RPC)Общий текст об ошибке, хвост reason — в лог/детали
прочееТекст ошибки CLOB (clob_rejected и т. п.)Общий текст об ошибке, reason — в лог/детали

Первые пять — реверты контракта, замапленные оператором в стабильные коды. Ветвиться можно по ним, а не по тексту execution reverted: …, который может поменяться с версией ethers.

Проверка баланса — на фронте, а не на бэкенде

Бэкенд намеренно не проверяет баланс перед постановкой в очередь: это стоило бы RPC-вызова на каждый ордер, а нода — самый дефицитный ресурс контура. Поэтому insufficient_balance и insufficient_shares приходят только как reason у rejected в поллинге, отдельного 400 на POST bets/orders для них нет.

Чтобы пользователь не ждал этого несколько секунд, BuyPanel считает доступное сам — по уже загруженным bets/account и bets/positions, без единого нового запроса:

const available = side === 'buy' ? (account?.balance ?? 0) : heldShares;
// кнопка disabled при numeric > available, под полем — «Available: $X»

Гарантией это не является и не должно: между кадром UI и эскроу баланс может уйти на другой ордер. Тогда тот же код придёт вторым путём — через rejected. Обрабатывать надо оба, мапа «код → текст» одна и та же.

Два слоя вместо трёх
  1. Фронтdisabled по загруженным данным. Бесплатно, мгновенно, покрывает 99% случаев.
  2. КонтрактInsufficientBalance(), оператор мапит реверт в код и публикует его в reason. Единственный слой, которому можно верить.

Промежуточной проверки на бэкенде нет по соображениям нагрузки на RPC.

intent_expired — отдельный текст

Это не ошибка ввода и не отказ рынка: ставка была принята, но пролежала в очереди дольше POLYMARKET_MAX_INTENT_AGE_SEC (по умолчанию 5 минут) и была отклонена намеренно, чтобы не исполниться по уехавшему стакану. Деньги не списаны, эскроу не открывался — повтор безопасен и делается новым clientOrderId.

Общий текст «произошла ошибка» здесь плохо работает: пользователь видел «в обработке» несколько минут и должен понять, что можно просто повторить. Подробнее — «Исполнение ставок».

3. Просмотр — портфель

ЧтоЭндпоинтТип ответа
Активные позицииGET bets/positionsItemsResponseDto<ActivePositionDto>
ЗакрытыеGET bets/positions/closedItemsResponseDto<ClosedPositionDto>
Лента сделокGET bets/activity?limit=50&offset=0ItemsResponseDto<ActivityItemDto>
Баланс и счётчикиGET bets/accountBetAccountDto
История удержанийGET bets/feesItemsResponseDto<BetFeeDto>
Списки завёрнуты в { items, count }

Четыре списочных эндпоинта отдают ItemsResponseDto, а не голый массив. Слой api/vault-bets типизирован по проводу как есть, распаковка .items живёт в entities/vault-bets/model/queries.ts — виджеты получают уже массивы. Если пишете новый вызов мимо этих хуков, не забудьте про обёртку: .filter по {items, count} падает в рантайме, а не на типах, если тип объявлен массивом.

count — общее число записей без учёта limit/offset, нужно для пагинации ленты; useBetsActivity его возвращает.

Фильтрация, поиск и сортировка на фронте уже есть (entities/position/lib/selectors.ts) и работают как есть — эндпоинты отдают несортированные массивы внутри items.

В widgets/portfolio/ui/PortfolioBoard.tsx меняется ровно одно: импорт MOCK_* → SWR-хук. Убрать <Chip label='Demo data' />.

4. Выигрыш

Отдельной кнопки «клейм выигрыша» нет и не нужно. Когда рынок разрешается, operator-сервис сам гасит позиции и зачисляет выплату в balance пользователя. Позиция автоматически уезжает из bets/positions в bets/positions/closed с result: 'won' | 'lost' и amountWon. От юзера действий не требуется.

«Забрать выигрыш» для пользователя = вывести деньги, а это два шага через границу эпохи:

// Шаг 0 — ОБЯЗАТЕЛЬНО показать удержание до подтверждения
const q = await api.get('bets/withdraw-quote', { params: { amount: '1200' } });
// { amount: 1200, principalPart: 1000, profitPart: 200, fee: 40, net: 1160 }

// Шаг 1 — заявка, ончейн
await vault.requestWithdraw(parseUnits('1200', 6));

// Шаг 2 — забрать, ончейн, в СЛЕДУЮЩЕЙ эпохе
await vault.claimWithdraw();

Когда доступен шаг 2, считается из bets/account:

const canClaim =
acc.pendingWithdraw > 0 && acc.currentEpoch > acc.withdrawEpoch;
// таймер до разблокировки — acc.currentEpochEndsAt (unix ms)

Раньше времени claimWithdraw реверта́ет WithdrawTooEarly.

Про комиссию скажи в UI явно. Берётся 20% от (выведено − внесено), а не с каждой сделки. Первые выводы в пределах внесённого — бесплатные. Полезно показывать «без комиссии осталось: acc.principalRemaining».

5. Партнёрские награды

const r = await api.get('bets/partner-rewards'); // { uid, amount }
await vault.claimPartnerRewards(); // ончейн, если amount > 0

Вот здесь кнопка «Забрать» нужна — это единственный настоящий клейм. Начисляется с комиссий приглашённых, по 3 линиям.

byLine сейчас пустой массив — разбивка по линиям живёт в событиях, если понадобится в UI, скажи, добавим на бэке.

Подводные камни

  1. 6 знаков везде. parseUnits(x, 6).
  2. Ставка асинхронна. Нужны состояния loading/pending/error в BuyPanel — сейчас их нет ни одного.
  3. Кнопка Max в BuyPanel захардкожена в '100' — заменить на acc.balance.
  4. acceptingOrders из MarketDetailDto — натуральный источник для disabled на кнопке ставки, сейчас не используется.
  5. Все bets/* под JWT, кроме bets/config. Заголовок уже вешается автоматически в api/api.ts, ничего делать не надо.
  6. Индексер догоняет цепочку с лагом ~15 с. После filled позиция появится в bets/positions не мгновенно — не считай это ошибкой, обнови по таймеру.
  7. Поллинг должен иметь потолок. queued — не вечное состояние, но и не мгновенное: при простое оператора ставка провисит до intent_expired (5 минут по умолчанию). Крутить опрос бесконечно нельзя — по таймауту показывай «ставка ещё обрабатывается» и продолжай фоновую проверку, а не спиннер на весь экран.
  8. Сеть — Polygon (137). Проверяй chainId из bets/config перед любым ончейн-вызовом.