30f851fb87
ADR-007 (выбор источника binance-market/NATS) пришёл из другой сессии — оставлен как есть, с апдейт-сноской. Реализация зафиксирована отдельным ADR-008: NATS-консьюмер в шлюзе, цена x1e8 строковым парсером, раздача фронту по WebSocket, деплой в k3s на testfront.binom.pw.
214 lines
8.6 KiB
Markdown
214 lines
8.6 KiB
Markdown
# PRICE-NATS.md — откуда берётся цена SOL и как она доходит до фронта
|
||
|
||
**Репо:** `pump-game-ops` (мета-репо, документация). **Статус:** живой, отражает фактическое
|
||
состояние на 2026-09-13.
|
||
|
||
Документ отвечает на один вопрос: **откуда игра берёт цену SOL** и каким путём она доезжает
|
||
до экрана игрока. Раньше на этом месте стояла заглушка со случайным блужданием — теперь цена
|
||
**настоящая**, с биржи Binance, через шину NATS.
|
||
|
||
---
|
||
|
||
## 1. Суть в трёх строках
|
||
|
||
1. Сервис `binance-market` (живёт в k3s, ns `invest`) собирает сделки с Binance и **публикует**
|
||
каждую цену в NATS — в subject `market.price.<symbol>`.
|
||
2. Игровой шлюз (`smart-updown-token/relayer`) **подписывается** на `market.price.solusdt`,
|
||
берёт оттуда цену и:
|
||
- отдаёт её **контракту** как `entry_price` / `exit_price` (целое, цена × 1e8);
|
||
- **транслирует** её фронту по WebSocket.
|
||
3. Тестовый фронт (`test-front`) подключается к этому WebSocket и **просто выводит цену** —
|
||
игрок видит, что рынок живёт.
|
||
|
||
Направление ставки (UP/DOWN) больше НЕ случайное: контракт сравнивает `exit_price` с
|
||
`entry_price`, а обе величины теперь настоящие.
|
||
|
||
---
|
||
|
||
## 2. Диаграмма компонентов (PlantUML)
|
||
|
||
```plantuml
|
||
@startuml price-flow
|
||
title Цена SOL: Binance -> NATS -> шлюз -> контракт -> фронт
|
||
|
||
skinparam componentStyle rectangle
|
||
skinparam shadowing false
|
||
|
||
actor "Игрок" as User
|
||
|
||
rectangle "Внешняя сеть" {
|
||
[Binance WS API] as Binance
|
||
}
|
||
|
||
rectangle "k3s (192.168.76.120)" {
|
||
|
||
rectangle "ns invest" {
|
||
component "binance-market\n(сборщик цен)" as BM
|
||
}
|
||
|
||
rectangle "ns game" {
|
||
component "testfront-gateway\n(шлюз релейера, :8895)" as GW
|
||
component "testfront\n(статика test-front)" as FE
|
||
}
|
||
}
|
||
|
||
cloud "NATS\n192.168.88.93:4222" as NATS
|
||
database "Solana localnet\n192.168.76.181:8899" as SOL
|
||
|
||
Binance --> BM : WS-поток сделок
|
||
BM --> NATS : publish\nmarket.price.solusdt\n{"price":"102.30..."}
|
||
NATS --> GW : subscribe\n(только чтение)
|
||
|
||
GW --> SOL : create_bet(entry_price)\nclose_bet(exit_price)
|
||
GW --> FE : WS /api/ws\n{"type":"price","price":"102.30"}
|
||
FE --> User : «SOL: 102.30»
|
||
|
||
note right of GW
|
||
Цена из NATS -> целое x1e8
|
||
("102.30000000" -> 10230000000)
|
||
Без float: строковый парсер
|
||
end note
|
||
|
||
note bottom of NATS
|
||
Core NATS, без JetStream:
|
||
истории нет, только живая подписка
|
||
end note
|
||
@enduml
|
||
```
|
||
|
||
Диаграмма в PlantUML-исходнике выше; чтобы посмотреть глазами — вставить её в
|
||
<https://www.plantuml.com/plantuml/uml/> (или отрендерить `plantuml` локально). Исходник
|
||
лежит и отдельным файлом: `docs/diagrams/price-flow.puml`.
|
||
|
||
---
|
||
|
||
## 3. Последовательность (кто что делает по шагам)
|
||
|
||
```plantuml
|
||
@startuml price-sequence
|
||
title Одна ставка: от тика Binance до результата
|
||
|
||
participant "binance-market" as BM
|
||
participant "NATS" as N
|
||
participant "Шлюз (gateway.ts)" as GW
|
||
participant "Контракт smart_updown" as C
|
||
participant "Фронт (test-front)" as FE
|
||
|
||
== фон: цена течёт всегда ==
|
||
BM -> N : market.price.solusdt {"price":"101.95","ts":...}
|
||
N -> GW : подписка: новый тик
|
||
GW -> GW : priceStringToInt("101.95...") = 10195000000
|
||
GW -> FE : WS {"type":"price","price":"101.95000000"}
|
||
FE -> FE : вывести «SOL: 101.95»
|
||
|
||
== игрок ставит ==
|
||
FE -> GW : POST /api/bet {side:"UP", amountUnits}
|
||
GW -> N : (ждёт цену, если ещё не пришла)
|
||
GW -> C : create_bet(entry_price = 10195000000)
|
||
C --> GW : tx ok, id = N
|
||
GW --> FE : {id, bettor, entryPrice}
|
||
|
||
== игрок закрывает (после expiry ~15с) ==
|
||
FE -> GW : POST /api/close {id}
|
||
GW -> C : close_bet(exit_price = текущая цена)
|
||
C -> C : exit > entry ? игрок : дом
|
||
C --> GW : status: payout_done | house_won
|
||
GW --> FE : {statusName, exitPrice}
|
||
@enduml
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Контракт с NATS (что именно приходит)
|
||
|
||
- **URL:** `nats://192.168.88.93:4222` (доступен из k3s-подов — проверено).
|
||
- **Subject:** `market.price.solusdt` (игра интересуется **только** Solana).
|
||
- **Payload** (живой пример):
|
||
|
||
```json
|
||
{
|
||
"symbol": "solusdt",
|
||
"type": "price",
|
||
"price": "101.89000000",
|
||
"qty": "0.07900000",
|
||
"tradeId": 672033931,
|
||
"eventTime": 1789266205660,
|
||
"ts": 1789266205814,
|
||
"initial": false
|
||
}
|
||
```
|
||
|
||
`price` — **строка**, доллары США, ровно 8 знаков после запятой. Это важно: перевод в целое
|
||
делается **строковым парсером**, а не `Number(price) * 1e8` — float на 8 знаках теряет точность,
|
||
а контракт сравнивает цены строго.
|
||
|
||
| Строка из NATS | Целое для контракта (×1e8) |
|
||
|---|---|
|
||
| `"101.89000000"` | `10189000000` |
|
||
| `"0.00000001"` | `1` |
|
||
| `"1234567.12345678"` | `123456712345678` |
|
||
| `"100"` | `10000000000` |
|
||
|
||
---
|
||
|
||
## 5. Что изменилось в коде
|
||
|
||
| Файл | Было | Стало |
|
||
|---|---|---|
|
||
| `relayer/src/priceSource.ts` | `simulated = 100_000_000`, случайный дрейф `Math.random()` | `getPrice()` → цена из NATS × 1e8 |
|
||
| `relayer/src/natsPrice.ts` | не было | NATS-подписка, последняя цена, слушатели, `waitForPrice` |
|
||
| `relayer/src/gateway.ts` | только REST | + WS `/ws` (трансляция цены), + `GET /price`, нормализация `/api/...` → `/...` |
|
||
| `test-front/index.html` | цены не показывал | блок «Цена SOL (WebSocket)» + автопереподключение |
|
||
|
||
Совместимость: `setPriceOverride()` сохранён — им можно подставить цену вручную (тесты,
|
||
сценарий «цена не меняется»).
|
||
|
||
---
|
||
|
||
## 6. Что происходит, если цены нет
|
||
|
||
Сейчас это трактуется как **авария** (обсуждается отдельно). Поведение временное и простое:
|
||
|
||
- шлюз **не падает** — поднимается без NATS, логирует ошибку подключения;
|
||
- запрос цены **ждёт** появления до таймаута `PRICE_WAIT_TIMEOUT_MS` (по умолчанию 15 секунд),
|
||
потом отдаёт ошибку;
|
||
- фронт продолжает показывать последнее известное значение и переподключается к WS
|
||
с нарастающей паузой (3 → 15 секунд).
|
||
|
||
То есть отсутствие цены — не «падение сервиса», а заметная деградация, которую видно и в
|
||
логах шлюза, и на экране.
|
||
|
||
---
|
||
|
||
## 7. Где это живёт и как проверить
|
||
|
||
**Прод:**
|
||
|
||
- фронт: <https://testfront.binom.pw/> (реальный внешний домен)
|
||
- цена по REST: <https://testfront.binom.pw/api/price>
|
||
- цена по WebSocket: `wss://testfront.binom.pw/api/ws`
|
||
- состояние игры: <https://testfront.binom.pw/api/state>
|
||
|
||
**Проверка (без графики):**
|
||
|
||
```bash
|
||
curl -s https://testfront.binom.pw/api/price
|
||
# {"symbol":"solusdt","price":"102.09...","ts":...}
|
||
|
||
# WS (нужен любой ws-клиент), ожидаем поток:
|
||
# {"type":"price","symbol":"solusdt","price":"102.09...","ts":...}
|
||
```
|
||
|
||
**В k3s:**
|
||
|
||
```bash
|
||
kubectl get pods -n game | grep -E 'testfront|relayer'
|
||
kubectl logs -n game -l app.kubernetes.io/name=smart-updown-relayer | grep natsPrice
|
||
# nats: connected nats://192.168.88.93:4222
|
||
# natsPrice: first price 102.04000000 (ts=...)
|
||
```
|
||
|
||
**Роутинг:** `testfront.binom.pw` → Traefik на VDS `176.109.100.6` → `http://192.168.76.120`
|
||
(один хост, два контейнера: `/` — статика, `/api` и `/ws` — шлюз). Конфиг роутера:
|
||
`/opt/traefik/static/testfront.yaml` на VDS.
|