Files
smart-updown-token/relayer/API.md
T
subochev dd4f16a930 feat(relayer): API v2 (canBet/30% от баланса) + п.8 — ничья играет заново
Тестовый TS-шлюз (relayer/), обслуживающий smart-updown.binom.pw и
testfront.binom.pw. Контракт и prod-Kotlin сервер не тронуты.

1+2. GET /state: canBet (boolean) + canBetReasonCode/canBetReason (русский
     текст) — можно ли ставить с фиксированным кошельком. Учитывает:
     захардкоженный адрес (GW_FIXED_BETTOR), наличие открытой ставки,
     хватает ли фантиков на ставку, лимиты [min,max], paused, наличие цены.
3-6. POST /bet: тело {"side":"up"|"down"} — сумма с фронта НЕ приходит,
     размер = GW_BET_PERCENT (30) % от баланса фантиков, клампится в
     [minAmount, maxAmount]. Ответ: {accepted, reasonCode, reason,
     retryAfterSeconds, betId, txSig, ...}.
7.   GET /state: openBet / lastBet (взаимоисключающие: открытая имеет
     приоритет) + поле status ("open"/"closed"), result, retryAfterSeconds.
8.   Ничья (refunded, exit==entry) игроку НЕ показывается: планировщик молча
     пересоздаёт ставку тем же размером и направлением (хук replayBet) и
     шлёт WS-событие bet_replay вместо bet_closed. Лимит GW_MAX_REPLAYS
     (дефолт 10) обрывает цепочку при «замороженной» цене.
9.   relayer/API.md — документация всех endpoint-ов и WS-событий.

Новые файлы: src/betRules.ts (правила ставки: canBetDecision,
computeBetAmountUnits), API.md.
Новые env: GW_FIXED_BETTOR, GW_BET_PERCENT (30), GW_MAX_REPLAYS (10).

Проверено: npm run typecheck + npm run test — 127 проверок, 0 провалов;
живая приёмка tools/gateway-api-v2-check.cjs — 26/26 на
https://smart-updown.binom.pw/api (включая реальную ставку → payout_done).
2026-09-17 00:11:31 +03:00

379 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-ошибок.