From 30f851fb873c5a138cd1cdfc6a718fae56c0597d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 13 Sep 2026 05:56:56 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20ADR-008=20(=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D1=86=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=B8=D0=B7=20NATS)=20+=20PRICE-NATS.md=20+=20PlantUML=20(?= =?UTF-8?q?=D1=84=D0=B0=D0=B7=D0=B0=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-007 (выбор источника binance-market/NATS) пришёл из другой сессии — оставлен как есть, с апдейт-сноской. Реализация зафиксирована отдельным ADR-008: NATS-консьюмер в шлюзе, цена x1e8 строковым парсером, раздача фронту по WebSocket, деплой в k3s на testfront.binom.pw. --- TASKS.md | 46 ++++++-- docs/DECISIONS.md | 37 +++++- docs/PRICE-NATS.md | 213 ++++++++++++++++++++++++++++++++++ docs/diagrams/price-flow.puml | 43 +++++++ 4 files changed, 331 insertions(+), 8 deletions(-) create mode 100644 docs/PRICE-NATS.md create mode 100644 docs/diagrams/price-flow.puml diff --git a/TASKS.md b/TASKS.md index aaa977c..0c1fdc1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -8,7 +8,23 @@ --- -## ⭐ Актуальное состояние (2026-09-09) +## ⭐ Актуальное состояние (2026-09-13) + +**Где мы:** цена SOL — **реальная, из NATS**, шлюз и тестовый фронт крутятся в k3s на +`testfront.binom.pw`. Первый щуп экономики закрыт: ставка → раунд → результат считается +по живой рыночной цене. + +**Новое (2026-09-13):** +- Цена SOL берётся из NATS (`market.price.solusdt`), заглушка удалена — **ADR-007**, + детали в `docs/PRICE-NATS.md`. +- Шлюз раздаёт цену фронту по WebSocket: `wss://testfront.binom.pw/api/ws`. +- `test-front` показывает живую цену SOL. +- Оба контейнера (статика + шлюз) — в k3s, ns `game`, один домен через ингресс. +- Solana localnet пересобрана с нуля, контракт задеплоен (`9ALs…`). + +--- + +## ⭐ Предыдущее состояние (2026-09-09) **Где мы:** Фаза 1 (контракт переведён на фантики) — **DONE**. Сейчас вход в **Фазу 2 (релейер под токен)**. @@ -28,7 +44,7 @@ ## Фаза 0 — Экономика и спека (кросс) - [ ] **TODO** ADR-002 зафиксирован, документировать `ECONOMY.md`: токен, курс (Способ 1), decimals=6, mint-адреса — `[pump.meta]` - [x] **DONE** Решить «вверх/вниз» — предмет ставки = цена SOL (ADR-006), валюта = фантики -- [ ] **IN_PROGRESS** Цена SOL: реальный источник (ADR-007) — `subochev/binance-market` (NATS `market.price.solusdt` или WS `/ws/price?symbol=solusdt`); остаётся интеграция `PriceSource` в релейере +- [x] **DONE** Цена SOL: реальный источник (ADR-007) — `subochev/binance-market` через NATS `market.price.solusdt`; интеграция `PriceSource` в релейере сделана (ADR-008, 2026-09-13) - [ ] **TODO** Кто держит ликвидность swap и комиссии — `[pump.meta]` - [ ] **TODO** Эмиссия: разовая vs постоянная — `[pump.meta]` @@ -38,12 +54,13 @@ - [x] **DONE** Выплата в `close_bet` фантиками из ATA-ваулта на ATA игрока - [x] **DONE** `state`/`constants`: единицы = token units (decimals=6), + VAULT_SEED - [x] **DONE** Обновить IDL (idl/ + relayer/idl/) + Anchor-тесты (10/10) — коммит 45d64d4 -- [ ] **TODO** Развернуть/поднять localnet стенд для E2E токен-флоу (приёмка на железе) +- [x] **DONE** Localnet стенд поднят заново (ledger пересобран), контракт задеплоен, mint создан, `init` прогнан (2026-09-13) +- [x] **DONE** E2E живьём: ставка → close по реальной цене из NATS (`entryPrice=10195000000` при курсе 101.95) ## Фаза 2 — Релейер и цена (АКТИВНАЯ — следующий шаг) - [ ] **IN_PROGRESS** SolanaClient/ops: подписывать/отправлять токеновую ставку (ATA + ваулт) — перевести релейер с SOL на токен - [ ] **TODO** readGlobal: баланс vault_token вместо SOL; ensureVaultFunded — наливать токен в vault -- [ ] **IN_PROGRESS** PriceSource: реальная цена (вместо `RandomPriceSource`) — интеграция с `binance-market` (ADR-007); реализация NATS-консьюмера `market.price.solusdt` +- [x] **DONE** PriceSource: реальная цена (вместо `RandomPriceSource`) — интеграция с `binance-market` (ADR-007), NATS-консьюмер `market.price.solusdt` в `src/natsPrice.ts`, цена ×1e8 (ADR-008) - [ ] **TODO** Интеграционный тест релейера на токеновом флоу ## Фаза 4 — Бэкенд: задействовать swap @@ -60,8 +77,9 @@ - [x] **DONE** Каркас: Phantom, балансы (фантики+SOL), swap пополнение/вывод через `/api/pump/swap` - [x] **DONE** Ставка вверх/вниз через контракт с поллингом до SETTLED - [ ] **TODO** Перевести подпись под токеновую версию контракта (после Фазы 1) -- [ ] **TODO** Реальный провайдер цены вместо RandomPriceSource (за нами) → `binance-market` (ADR-007) -- [ ] **TODO** Развернуть / дать постоянный URL (nginx/podman) — удобно открывать +- [x] **DONE** Реальный провайдер цены вместо RandomPriceSource (за нами) → `binance-market` через NATS (ADR-007/ADR-008) +- [x] **DONE** Живая цена SOL по WebSocket (`wss://testfront.binom.pw/api/ws`), вывод на странице — релиз 2 +- [x] **DONE** Развернуть / дать постоянный URL — `https://testfront.binom.pw` (k3s, ns `game`, ингресс; релиз 2) ## Фаза 6 — E2E и деплой - [ ] **TODO** Полный круг на devnet: создать токен → swap ввод → ставка токеном → раунд → вывод @@ -72,4 +90,18 @@ ## Ссылки - Решения/экономика: `docs/DECISIONS.md`, `docs/ECONOMY.md` -- Карта репозиториев: `README.md` \ No newline at end of file +- Карта репозиториев: `README.md` +## Фаза 7 — Цена из NATS и деплой в куб (2026-09-13) +- [x] **DONE** ADR-007: цена SOL из NATS (`market.price.solusdt`), заглушка удалена +- [x] **DONE** `natsPrice.ts` — подписка NATS, последняя цена, `waitForPrice`, ×1e8 строковым парсером +- [x] **DONE** `gateway.ts` — WS `/ws`, `GET /price`, нормализация `/api/...` → `/...` +- [x] **DONE** `test-front` — блок «Цена SOL (WebSocket)»: цена, статус, автопереподключение +- [x] **DONE** Dockerfile + helm для шлюза и фронта; деплой в k3s ns `game` +- [x] **DONE** Роут `testfront.binom.pw` переведён с 76.199 на куб 76.120 (Traefik VDS) +- [x] **DONE** Релизы: `smart-updown-token` 7, `test-front` 2 +- [x] **DONE** Документация: `docs/PRICE-NATS.md`, `docs/diagrams/price-flow.puml`, ADR-007 +- [ ] **TODO** Пилот: смена `entry_price` на «реальный источник как оракул» до боя (Pyth/Switchboard) +- [ ] **TODO** Деградация при отсутствии цены: отдельное решение (бэкфилл/остановка приёма ставок) + +## Ссылки (дополнено 2026-09-13) +- Цена: `docs/PRICE-NATS.md`, диаграмма `docs/diagrams/price-flow.puml` diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 7b674ef..2f71e69 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -96,4 +96,39 @@ - **Результат:** задачи «TODO Цена SOL: реальный источник» (Фаза 0) и «PriceSource: реальная цена (вместо RandomPriceSource)» (Фаза 2) переводятся в `IN_PROGRESS` со ссылкой на это решение. Никаких новых подписок/оракулов не делаем — только интеграция в релейере (`smart-updown-token`/ - `relayer` или `smart-updown-server`). Внешний статус сервиса см. README → «Внешние зависимости». \ No newline at end of file + `relayer` или `smart-updown-server`). Внешний статус сервиса см. README → «Внешние зависимости». + **→ Обновление 2026-09-13: реализовано, см. ADR-008** (NATS-консьюмер в шлюзе, цена ×1e8, + раздача фронту по WS). +--- + +## ADR-008 — Реализация: цена SOL из NATS в шлюзе + раздача фронту по WS +- **Дата:** 2026-09-13 +- **Статус:** принято, реализовано +- **Контекст:** ADR-006 зафиксировал предмет ставки (цена SOL), ADR-007 выбрал источник + (`binance-market` через NATS). Оставалось реализовать: сама цена была случайной + заглушкой `RandomPriceSource` / `simulated = 100_000_000 + Math.random()`. Исход раунда был + случаен, а не рыночен. При этом в контуре уже работает `binance-market` — сборщик сделок с + Binance, который публикует каждую цену в NATS (`market.price.`). +- **Решение:** игровой шлюз (`smart-updown-token/relayer`) подписывается на NATS + (`market.price.solusdt`) и берёт цену оттуда. Заглушка удалена. + - **Транспорт:** core NATS `nats://192.168.88.93:4222`, subject `market.price.solusdt`. + Режим — **только чтение**; `binance-market` не трогаем. + - **Шкала:** строка из NATS переводится в целое ×**1e8** строковым парсером (без float): + `"101.89000000"` → `10189000000`. То же юнит-пространство, что у оракульных цен и у + прежней заглушки (`100_000_000`), поэтому контракт сравнивает цены корректно. + - **Раздача фронту:** шлюз транслирует каждый тик по WebSocket (`/ws`, снаружи + `wss://testfront.binom.pw/api/ws`), сообщение — только цена: + `{"type":"price","symbol":"solusdt","price":"102.30...","ts":...}`. + - **Отсутствие цены:** считается аварийным состоянием и обрабатывается мягко — шлюз + стартует без NATS, запрос цены ждёт таймаут, фронт показывает последнее значение и + переподключается. Отдельный сценарий деградации — тема для будущего решения. +- **За:** исход раунда становится рыночным (entry/exit — реальные цены Binance); + переиспользуется уже работающий сборщик, торговый контур не дублируется; шина отделяет + источник цены от её потребителей (игра, аналитика, будущие сервисы). +- **Против:** появляется зависимость от NATS и от `binance-market`; core NATS не хранит + историю (нет JetStream) — при разрыве подписки цена не «догоняется», нужен живой поток. +- **Альтернативы:** (а) прямая подписка шлюза на Binance WS — дублирование сборщика, лишние + внешние коннекты; (б) оракул on-chain (Pyth/Switchboard) — правильнее для боя, но + дороже и требует фидов; (в) заглушка — отклонено как единственный вариант, оставлена + возможность (`setPriceOverride`) для тестов. +- **Детали и диаграммы:** `docs/PRICE-NATS.md`, `docs/diagrams/price-flow.puml`. diff --git a/docs/PRICE-NATS.md b/docs/PRICE-NATS.md new file mode 100644 index 0000000..5d40c2b --- /dev/null +++ b/docs/PRICE-NATS.md @@ -0,0 +1,213 @@ +# 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.`. +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-исходнике выше; чтобы посмотреть глазами — вставить её в + (или отрендерить `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. Где это живёт и как проверить + +**Прод:** + +- фронт: (реальный внешний домен) +- цена по REST: +- цена по WebSocket: `wss://testfront.binom.pw/api/ws` +- состояние игры: + +**Проверка (без графики):** + +```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. diff --git a/docs/diagrams/price-flow.puml b/docs/diagrams/price-flow.puml new file mode 100644 index 0000000..7f2bae5 --- /dev/null +++ b/docs/diagrams/price-flow.puml @@ -0,0 +1,43 @@ +@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 market.price.solusdt +NATS --> GW : subscribe (только чтение) +GW --> SOL : create_bet(entry_price)\nclose_bet(exit_price) +GW --> FE : WS /api/ws {"type":"price"} +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