# API шлюза `smart-updown-relayer` Шлюз — это HTTP/WebSocket-прослойка поверх Solana-контракта. Клиент фронта **не держит свой keypair**: все ставки подписывает шлюз от имени **захардкоженного беттора** (`GW_FIXED_BETTOR`, дефолт `6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6`). Сумму ставки шлюз тоже считает сам = `GW_BET_PERCENT%` (дефолт `30%`) от баланса фантиков беттора с клампом в `[minAmount, maxAmount]` из `Global`. ## База клиента и форматы - **База**: `https://smart-updown.binom.pw/api` — все POST-ручки требуют префикс `/api`. - **WebSocket**: `wss://smart-updown.binom.pw/api/ws`. - GET-ручки работают и без `/api` (`/state`, `/price`, `/balance/...`, `/faucet/...`). - **CORS**: `Access-Control-Allow-Origin: *` — кросс-доменные запросы разрешены. - **Цены SOL** — целое `price × 1e8` (например `101.89000000` = 101.89 USD), строка. - **Фантики** — SPL-токен `decimals = 6`. Все суммы в API — в **базовых юнитах** (целое число, `1 фантик = 1_000_000` юнитов). `humanize` форматирует их в строку с 6 знаками (`"9800000000"` → `"9800.000000"`). --- ## `GET /state` Снимок состояния контракта + фиксированного беттора. Клиент фронта опрашивает эту ручку перед каждой ставкой. ### Параметры Нет. ### Ответ (HTTP 200) ```jsonc { // Прежние поля (НЕ убирать — тестовый фронт их читает). "counter": "81", "nextBetId": "81", "minAmount": "100000000", "maxAmount": "10000000000", "minAmountHuman": "100.000000", "maxAmountHuman": "10000.000000", "multiplierBps": "13000", "expirySeconds": "15", "paused": 0, "admin": "...", "relayer": "...", "vaultBalance": "30020000000", "vaultBalanceHuman": "30020.000000", // Новые поля (v2). "bettor": "6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6", "betPercent": 30, "betAmountUnits": "2940000000", "betAmountHuman": "2940.000000", "canBet": true, "canBetReasonCode": null, "canBetReason": null, "balance": { "sol": 4.99064576, "solLamports": 4990645760, "tokenUnits": "9800000000", "tokenHuman": "9800.000000" }, "openBet": null, "lastBet": { "id": "80", "status": "closed", "statusCode": 1, "result": "payout_done", "side": "UP", "amountUnits": "2940000000", "amountHuman": "2940.000000", "entryPrice": 10077000000, "exitPrice": 10120000000, "betTime": 1789589360, "expireTime": 1789589375, "retryAfterSeconds": null } } ``` ### Коды ошибок - **409** — контракт не инициализирован (`error: "Contract not initialized (relayer: npm run init)"`). Все остальные поля в этом случае НЕ возвращаются. ### Форма объекта ставки (`openBet` / `lastBet`) | поле | тип | описание | |---|---|---| | `id` | string | ID аккаунта (десятичная строка) | | `status` | `"open"` \| `"closed"` | ключевое поле для UI | | `statusCode` | number (0..3) | сырое значение из контракта | | `result` | `null` \| `"payout_done"` \| `"house_won"` \| `"refunded"` | результат закрытой ставки; `null` для открытой | | `side` | `"UP"` \| `"DOWN"` | | | `amountUnits` | string | ставка в базовых юнитах | | `amountHuman` | string | форматированная сумма | | `entryPrice` | number | цена SOL при создании (`×1e8`) | | `exitPrice` | number | цена при закрытии (`×1e8`, 0 для открытой) | | `betTime` | number | unix-time создания (секунды) | | `expireTime` | number | unix-time экспирации (секунды) | | `retryAfterSeconds` | number \| null | для открытой: `ceil((expireTime*1000 - now)/1000)`, мин. 0; для закрытой: `null` | ### Логика выбора `openBet` / `lastBet` - Если у фиксированного беттора есть ставка со `status === 0` → `openBet = эта ставка`, `lastBet = null`. - Иначе, если есть любые ставки этого беттора → `openBet = null`, `lastBet = ставка с максимальным id`. - Иначе (ставок нет) → `openBet = null`, `lastBet = null`. --- ## `GET /price` Текущая сырая цена SOL из NATS (`market.price.solusdt`). Для отладки и прикидки UI. ### Ответ (HTTP 200) ```jsonc { "symbol": "solusdt", "price": "101.89000000", "ts": 1789589385123 } ``` ### Коды ошибок - **503** — NATS ещё не прислал ни одного тика (`error: "price not available yet"`). --- ## `POST /wallet` Создаёт новый custodial-кошелёк беттора. **НИЧЕГО не наливает** — деньги через `/faucet/{address}`. ### Тело запроса Пустое. ### Ответ (HTTP 200) ```jsonc { "address": "7xK9...AbCd" } ``` Адрес автоматически регистрируется в keystore шлюза. --- ## `POST /bet` Ставка **за захардкоженного беттора** (`GW_FIXED_BETTOR`). Сумма НЕ передаётся — шлюз считает её сам. Адрес беттора тоже НЕ передаётся. ### Тело запроса ```jsonc { "side": "up" } ``` Допустимые значения `side`: `"UP"`, `"up"`, `"Up"`, `"0"`, `0` (UP); `"DOWN"`, `"down"`, `"Down"`, `"1"`, `1` (DOWN). Регистр не важен. `address` и `amountUnits` в теле **молча игнорируются** (обратная совместимость со старым фронтом) — ставка всегда идёт за `GW_FIXED_BETTOR` и на `GW_BET_PERCENT%`. ### Ответ (HTTP 200, всегда кроме плохого `side` / не-JSON) **Принята** (`accepted: true`): ```jsonc { "accepted": true, "reasonCode": null, "reason": null, "retryAfterSeconds": 15, "betId": "81", "txSig": "5xY...", "amountUnits": "2940000000", "amountHuman": "2940.000000", "side": "UP", "entryPrice": 9856000000, "expireTime": 1789589400, // Обратная совместимость со старым фронтом: j.id / j.tx / j.bettor. "id": "81", "tx": "5xY...", "bettor": "6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6" } ``` **Отклонена** (`accepted: false`): ```jsonc { "accepted": false, "reasonCode": "OPEN_BET", "reason": "У вас уже есть открытая ставка — дождитесь её расчёта", "retryAfterSeconds": 12, "betId": null, "txSig": null } ``` ### Коды ошибок - **400** — `side` отсутствует или не распознан (`error: 'side must be 0/"UP" or 1/"DOWN"'`). - **400** — тело не JSON (`error: "invalid JSON body"`). Все остальные причины отказа приходят с **HTTP 200** и `accepted: false`. ### Правила `retryAfterSeconds` | ситуация | значение | |---|---| | принятая ставка | `expirySeconds` из `Global` (15) | | `OPEN_BET` | `ceil((expireTime*1000 - Date.now())/1000)`, минимум `0` | | прочие отказы (`NO_PRICE`, `INSUFFICIENT_SOL`, `INSUFFICIENT_FUNDS`, `PAUSED`, `WALLET_UNKNOWN`, `NOT_INITIALIZED`) | `null` | --- ## `GET /balance/{address}` Реальные балансы адреса: SOL + фантики в ATA по нашему mint. ### Параметры пути - `address` — Solana pubkey в base58. ### Ответ (HTTP 200) ```jsonc { "sol": 4.99064576, "solLamports": 4990645760, "tokenUnits": "9800000000", "tokenHuman": "9800.000000" } ``` ### Коды ошибок - **400** — `address` не валидный Solana pubkey. --- ## `GET /faucet/{address}` Налив SOL + фантиков на ПРОИЗВОЛЬНЫЙ адрес (Phantom и т.п.). **Только в test-режиме**: `SMART_MODE=test` И кластер не mainnet/devnet/testnet (проверка по genesis hash). Иначе `403`. ### Параметры пути - `address` — Solana pubkey в base58. ### Ответ (HTTP 200) ```jsonc { "address": "7xK9...AbCd", "sol": 5.0, "solLamports": 5000000000, "tokenUnits": "10000000000", "tokenHuman": "10000.000000" } ``` ### Коды ошибок - **403** — не test-режим / публичный кластер / genesis hash неизвестен. - **400** — `address` не валидный Solana pubkey. --- ## `GET /ws` (WebSocket) Push-канал цены SOL + жизненного цикла ставки. На подключение сервер шлёт **снапшот текущей цены** (если NATS уже прислал хоть один тик), дальше — каждый тик/событие. ### Протокол Каждое сообщение — JSON-объект одного из трёх типов (см. ниже). Клиент различает их по полю `type`. ### Событие `price` — каждый NATS-тик цены ```jsonc { "type": "price", "symbol": "solusdt", "price": "101.89000000", "ts": 1789589385123 } ``` ### Событие `bet_open` — после успешного `POST /bet` ```jsonc { "type": "bet_open", "id": "81", "bettor": "6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6", "side": "UP", "amountUnits": "2940000000", "entryPrice": 9856000000, "expireTime": 1789589400 } ``` ### Событие `bet_closed` — после авто-закрытия планировщиком (`settle.ts`) Приходит, когда сработал `close_bet` (по экспирации + наличию цены в истории). ```jsonc { "type": "bet_closed", "id": "81", "bettor": "6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6", "exitPrice": 10120000000, "status": 1, "statusName": "payout_done" } ``` | `statusName` | значение `status` | |---|---| | `payout_done` | `1` (игрок выиграл — выплата 1.3× от ставки) | | `house_won` | `2` (казино выиграло — ставка остаётся в ваулте) | | `refunded` | `3` (ставка возвращена — особый случай) | ### Событие `bet_replay` — п.8 ТЗ: ничья не показывается игроку, раунд перезапускается Если `close_bet` зафиксировал ничью (`exit_price == entry_price`, статус `3`/`refunded`), планировщик НЕ шлёт `bet_closed` — вместо этого он сразу пересоздаёт ставку тем же направлением и размером (цена берётся на момент пересоздания через `getPrice()`) и отправляет это событие. Игрок видит новый раунд с новым `id` и `expireTime` и не узнаёт, что предыдущая ставка была «ничья». Если цена «заморожена» (фид стоит) и ничьи идут подряд, цепочка обрывается после `GW_MAX_REPLAYS` (дефолт `10`) — тогда уходит обычный `bet_closed` со статусом `refunded`. > Совместимость: клиент, который знает только `bet_open`/`bet_closed` (например > текущий `test-front`), на `bet_replay` не отреагирует — сообщение просто > игнорируется. Для «тихой» переигровки этого достаточно; чтобы клиент > перерисовал таймер, ему нужно добавить обработку `bet_replay`. ```jsonc { "type": "bet_replay", "id": "82", // новая ставка в цепочке "prevId": "81", // id ставки, по которой была ничья "bettor": "6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6", "side": "UP", "amountUnits": "2940000000", "entryPrice": 10077000000, // цена уже новая (на момент пересоздания) "expireTime": 1789589390, // свежий expireTime (+15 с от создания) "replayCount": 1 // 1 = первая переигровка, 2 = вторая, ... } ``` Поле `replayCount` показывает позицию в цепочке: `1` — первая ничья→переигровка, `2` — вторая подряд, и т. д. После `GW_MAX_REPLAYS` переигровок в той же цепочке — отправляется `bet_closed` со статусом `refunded`, цепочка обрывается. --- ## Коды отказов `canBetReasonCode` (для `/state.canBetReasonCode` и `/bet.reasonCode`) Таблица — в **порядке приоритета** проверки: первая сработавшая даёт результат. | Код | Условие | Текст по-русски | Когда возникает | |---|---|---|---| | `NOT_INITIALIZED` | контракт не инициализирован (`readGlobal` вернул null) | Контракт не инициализирован | первый запуск без `npm run init` | | `PAUSED` | `paused !== 0` в Global | Ставки приостановлены администратором | админ нажал pause | | `NO_PRICE` | NATS не прислал цену | Нет актуальной цены SOL | NATS недоступен / первый тик не пришёл | | `OPEN_BET` | у фиксированного беттора уже есть открытая ставка | У вас уже есть открытая ставка — дождитесь её расчёта | есть Bet со `status === 0` | | `WALLET_UNKNOWN` | `GW_FIXED_BETTOR` НЕ в keystore шлюза | Кошелёк не зарегистрирован в шлюзе | keystore повреждён / адрес не тот | | `INSUFFICIENT_SOL` | `solLamports < 10_000_000` (0.01 SOL) | Недостаточно SOL на комиссию и ренту | нужен `/faucet/{address}` для пополнения | | `INSUFFICIENT_FUNDS` | `BigInt(tokenUnits) < BigInt(minAmount)` | Недостаточно фантиков для минимальной ставки | нужен `/faucet/{address}` или меньший процент | --- ## Размер ставки `betAmountUnits = floor(tokenBalance × GW_BET_PERCENT / 100)` с клампом в `[minAmount, maxAmount]` из Global. Если баланс ≤ 0 или не парсится → `minAmount` (но решение всё равно отвергнет по `INSUFFICIENT_FUNDS`). **Пример.** Баланс = `9_800_000_000` юнитов (9800 фантиков), `GW_BET_PERCENT = 30`, `minAmount = 100_000_000`, `maxAmount = 10_000_000_000`: ``` floor(9_800_000_000 × 30 / 100) = floor(2_940_000_000.0) = 2_940_000_000 ← попадает в диапазон betAmountUnits = "2940000000" (2940.000000 фантиков) ``` Считается в `BigInt` (см. `betRules.computeBetAmountUnits`), без float-ошибок.