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).
This commit is contained in:
subochev
2026-09-17 00:11:31 +03:00
parent 42dfa58582
commit dd4f16a930
8 changed files with 1411 additions and 135 deletions
+378
View File
@@ -0,0 +1,378 @@
# 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-ошибок.