dd4f16a930
Тестовый 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).
379 lines
16 KiB
Markdown
379 lines
16 KiB
Markdown
# 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-ошибок.
|