docs(sec): SEC-16 — безвозвратная утечка ренты PDA-ставки с кошелька игрока

Найдено при приёмке боевого домена smart-updown.binom.pw (симптом «SOL не обновляется»).

- SEC-16: при create_bet ренту PDA-аккаунта ставки (1 559 040 lamports = (96+32+128)*6960)
  платит БЕТТОР, а не шлюз; баланс шлюза-админа при этом не меняется (замер whose-fee.cjs).
  Аккаунт не закрывается никогда (в контракте 9ALs четыре инструкции, ни одной close = bettor)
  → рента теряется безвозвратно. В цепи 75 аккаунтов = 0.116928 SOL заперто, растёт линейно.
  Вердикт: на тестах терпимо (маскируется краном, 5 SOL = ~3207 раундов), перед mainnet — блокер.
- TASKS.md: фича «если цена не изменилась — подождать ещё 15 с» (НЕ доделана), заметка про
  боевой домен на общем TS-шлюзе и лаг крана 8–13 с.
- tools: доказательные скрипты (whose-fee, bet-pda-rent, faucet-latency, sol-balance-cycle,
  settle-math-test, backend-full-test) + README с питфоллами замера.
- k8s/smart-updown-api-ingress.yaml: /api,/ws вынесены вне helm (релиз игры их сносил).
This commit is contained in:
Hermes Agent
2026-09-15 05:57:34 +03:00
parent b8a1f8dc39
commit 101a4b60a9
10 changed files with 603 additions and 4 deletions
+28
View File
@@ -91,6 +91,34 @@
## Ссылки ## Ссылки
- Решения/экономика: `docs/DECISIONS.md`, `docs/ECONOMY.md` - Решения/экономика: `docs/DECISIONS.md`, `docs/ECONOMY.md`
- Карта репозиториев: `README.md` - Карта репозиториев: `README.md`
## ⭐ Добавлено 2026-09-15 — боевой домен и находки приёмки
**Где мы:** боевой домен `smart-updown.binom.pw` переведён на **тот же TS-шлюз** (`:8895`),
что и `testfront.binom.pw` (некастодиальный Kotlin-бэк выключен, обратимо). Пути `/api`,`/ws`
вынесены в **отдельный ingress вне helm** — релиз игры их больше не сносит.
**Найдено при приёмке живьём:**
- `SEC-16` — безвозвратная утечка ренты PDA-аккаунта ставки: **1 559 040 lamports с кошелька
игрока за каждую ставку**, аккаунт не закрывается никогда. Подробности — `docs/SECURITY.md`.
*Вердикт: на тестах терпимо (маскируется краном), перед mainnet — блокер.*
- Расчёт (выплаты) — **корректен 3/3**: выигрыш `1.3×`, проигрыш `0`, ничья — возврат залога.
- Кран зачисляет на цепь с лагом **8–13 с** (шлюз рапортует мгновенно) — отсюда ложный симптом
«SOL не обновился».
**Фича на будущее — «ждать, если цена не изменилась» (НЕ ДОДЕЛАНА):**
- [ ] **TODO** `[pump.meta]` Если по сделанной ставке цена на момент экспирации **не изменилась**
(`exit == entry`, ничья), нужно **подождать ещё 15 секунд** и попробовать взять цену заново —
чтобы ставка не закрывалась возвратом залога на «плоской» цене, а получала шанс на реальный исход.
**Суть:** ставка не меняется → ждём (не финализируем сразу возвратом).
- [ ] **TODO** Вынести окно ожидания в конфиг: сейчас `PRICE_WAIT_TIMEOUT_MS` (default `15000`)
отвечает лишь за **ожидание первой цены от NATS** (`relayer/src/priceSource.ts:9`) и к ничьей
отношения не имеет — нужен **отдельный** параметр (напр. `TIE_WAIT_TIMEOUT_MS`) и логика
отложенного ретрая в `settle.ts` (`priceAtOrBefore(expireTime*1000)` — сейчас берётся
фиксированный момент, ретрай каждые 2 с вечно получит ту же пару, см. `SEC-9`).
- [ ] **TODO** Решить, что делать по истечении окна ожидания: возврат залога (текущее поведение
`SEC-9`) или иной исход.
## Фаза 7 — Цена из NATS и деплой в куб (2026-09-13) ## Фаза 7 — Цена из NATS и деплой в куб (2026-09-13)
- [x] **DONE** ADR-007: цена SOL из NATS (`market.price.solusdt`), заглушка удалена - [x] **DONE** ADR-007: цена SOL из NATS (`market.price.solusdt`), заглушка удалена
- [x] **DONE** `natsPrice.ts` — подписка NATS, последняя цена, `waitForPrice`, ×1e8 строковым парсером - [x] **DONE** `natsPrice.ts` — подписка NATS, последняя цена, `waitForPrice`, ×1e8 строковым парсером
+20 -2
View File
@@ -2,7 +2,8 @@
**Статус:** открыт, ведётся с 2026-09-13. **Статус:** открыт, ведётся с 2026-09-13.
**Область:** `smart-updown-token/relayer` (шлюз), контракт `smart-updown` (program `9ALs…`), **Область:** `smart-updown-token/relayer` (шлюз), контракт `smart-updown` (program `9ALs…`),
шина NATS, `test-front`. Стенд: `testfront.binom.pw` (k3s ns `game`). шина NATS, `test-front`. Стенды: `testfront.binom.pw` и боевой домен `smart-updown.binom.pw`
(k3s ns `game`; с 2026-09-15 оба домена обслуживает **один** TS-шлюз).
Аудит проведён **на живом стенде**, не по чтению кода: воспроизведение эксплойта, проверка Аудит проведён **на живом стенде**, не по чтению кода: воспроизведение эксплойта, проверка
ручек из внешнего интернета, проверка аутентификации шины. Фактические выводы приведены в строках. ручек из внешнего интернета, проверка аутентификации шины. Фактические выводы приведены в строках.
@@ -10,7 +11,7 @@
Пункты нумеруются сквозным `SEC-N` (не меняется при добавлении/закрытии пунктов) — ссылаться на них Пункты нумеруются сквозным `SEC-N` (не меняется при добавлении/закрытии пунктов) — ссылаться на них
номером. Отмечать закрытое — `[ ]` → `[x]`, в закрытую строку добавлять дату и коммит/релиз. номером. Отмечать закрытое — `[ ]` → `[x]`, в закрытую строку добавлять дату и коммит/релиз.
**Сводка:** открыто 8 — критичных 1 (`SEC-5`, частично закрыт), экономика 1 (`SEC-7`), **Сводка:** открыто 9 — критичных 1 (`SEC-5`, частично закрыт), экономика 2 (`SEC-7`, **`SEC-16`**),
инфраструктура 3 (`SEC-10…SEC-12`), отложено 3 (`SEC-13…SEC-15`). Закрыто: 8 — инфраструктура 3 (`SEC-10…SEC-12`), отложено 3 (`SEC-13…SEC-15`). Закрыто: 8 —
`SEC-6` (2026-09-13, `updown-relayer:2` / `test-front:3`), `SEC-6` (2026-09-13, `updown-relayer:2` / `test-front:3`),
`SEC-1`, `SEC-2`, `SEC-4` (2026-09-13, `updown-relayer:3`, коммит `0b136c6`), `SEC-1`, `SEC-2`, `SEC-4` (2026-09-13, `updown-relayer:3`, коммит `0b136c6`),
@@ -19,6 +20,9 @@
коммит `42dfa58`), коммит `42dfa58`),
плюс `SEC-2` и `SEC-4` усилены в `:6`. плюс `SEC-2` и `SEC-4` усилены в `:6`.
**Добавлено 2026-09-15:** `SEC-16` — безвозвратная утечка ренты PDA-аккаунта ставки с кошелька
игрока (найдено при приёмке боевого домена `smart-updown.binom.pw`).
**Итерация 2026-09-13 (`updown-relayer:6`) — что сделано:** авто-закрытие ставок серверным **Итерация 2026-09-13 (`updown-relayer:6`) — что сделано:** авто-закрытие ставок серверным
планировщиком (`settle.ts`), ручка `POST /close` удалена (`404`), WS-события `bet_open`/`bet_closed` планировщиком (`settle.ts`), ручка `POST /close` удалена (`404`), WS-события `bet_open`/`bet_closed`
в существующем канале (`/ws`), ограничение «одна открытая ставка на адрес» (`409`), в существующем канале (`/ws`), ограничение «одна открытая ставка на адрес» (`409`),
@@ -68,6 +72,20 @@
**Деплой:** программа `9ALs…` апгрейднута (ProgramData расширен 247 848 → 267 848, хэш дампа из цепи совпал с локальным `.so` побайтово); шлюз `updown-relayer:7` (helm rev 15); фронт `test-front:5` (rev 5). **Деплой:** программа `9ALs…` апгрейднута (ProgramData расширен 247 848 → 267 848, хэш дампа из цепи совпал с локальным `.so` побайтово); шлюз `updown-relayer:7` (helm rev 15); фронт `test-front:5` (rev 5).
**Живой эффект:** две залипшие ставки (`#0`, `#1`) закрылись **сами** статусом 3; в обеих транзакциях ваулт −500 и игрок +500 фантиков (проверено `preTokenBalances`/`postTokenBalances`), ваулт 26 850 → 25 850. Канал `bet_closed` → `statusName: "refunded"` подтверждён. **Живой эффект:** две залипшие ставки (`#0`, `#1`) закрылись **сами** статусом 3; в обеих транзакциях ваулт −500 и игрок +500 фантиков (проверено `preTokenBalances`/`postTokenBalances`), ваулт 26 850 → 25 850. Канал `bet_closed` → `statusName: "refunded"` подтверждён.
*Остаток:* политика выбрана как «возврат залога без множителя»; доля ничьих ~33 % означает, что треть ставок не приносит дому ни прибыли, ни убытка (экономика остаётся положительной за счёт множителя 1.3×). *Остаток:* политика выбрана как «возврат залога без множителя»; доля ничьих ~33 % означает, что треть ставок не приносит дому ни прибыли, ни убытка (экономика остаётся положительной за счёт множителя 1.3×).
- [ ] **SEC-16. Рента PDA-аккаунта ставки списывается с кошелька ИГРОКА и НЕ возвращается — безвозвратная утечка `1 559 040` lamports за каждую ставку.**
**Найдено 2026-09-15** при приёмке боевого домена `smart-updown.binom.pw` (симптом владельца: «SOL не обновляется / не растёт»).
**Механика.** При `create_bet` создаётся PDA-аккаунт ставки (`PDA([BET_SEED, id_le_u64])`, размер `96` байт + 8 дискриминатор), и ренту за него платит **беттор**, а не шлюз. Рента = `(96 + 32 + 128) × 6960` lamports = **1 559 040** — это не комиссия транзакции (та 5 000).
**Замер на цепи** (`pump-game-ops/tools/whose-fee.cjs`): SOL игрока `5 000 000 000 → 4 998 440 960` (−1 559 040) при **неизменном** балансе шлюза-админа `E7Hg…` (`4 997 858 09 320`). Подтверждено по каждой ставке (`tools/bet-pda-rent.cjs`, id 60–74): у каждой PDA ровно 1 559 040 lamports.
**Почему безвозвратно.** Аккаунт ставки **никогда не закрывается**. В контракте `9ALs…` ровно четыре инструкции (`lib.rs`): `initialize`, `create_bet`, `close_bet`, `update` — ни одна не делает `close = bettor` / `close_account` / `realloc` (проверено грепом по `program/programs/smart-updown/src`). Рента остаётся приколотой к мёртвому аккаунту навсегда.
**Масштаб.** В цепи уже 75 аккаунтов ставок = **0.116928 SOL** запертой ренты (`getProgramAccounts`, `dataSize:96`), и она растёт линейно с числом ставок. Замер `tools/faucet-latency.cjs`: та же дельта воспроизводится на каждом раунде независимо от исхода (WIN/LOSS/TIE). По цене SOL $150–200 это **$0.25–0.31 реальных денег за каждый раунд** — до mainnet.
**Почему это пока не видно.** Кран (`fundBettor`, `ops.ts:204`) догружает кошелёк до 5 SOL, как только баланс падает ниже 2 SOL — на localnet утечка маскируется, 5 SOL хватает на ~3 207 раундов. **На mainnet airdrop'а нет**, поэтому игрок увидит, что баланс тает сам по себе, без проигрышей.
**Вердикт:** для тестов — **терпимо** (деньги фальшивые, маскируется краном). **Перед выводом на mainnet — блокер**, т.к. это списание реальных средств игрока без его убытка.
**Фикс (правка контракта + редеплой, на выбор):**
1. в `close_bet` добавить `close = bettor` на аккаунт `bet` — рента вернётся беттору при финализации;
2. либо платить ренту с кошелька **шлюза** (`relayer`/`admin`), тогда игрок не теряет SOL вовсе;
3. либо отдельная `close_bet`-инструкция очистки для уже накопленных аккаунтов.
## Средний приоритет — инфраструктура ## Средний приоритет — инфраструктура
- [ ] **SEC-10. NATS без аутентификации** — `auth_required` не выставлен, анонимная публикация в шину проходит (проверено на тестовом subject; боевой не трогали). Пока цена условна — терпимо; как только станет денежной, у шины нет прав на запись и любой сможет напечатать свой курс. Фикс: auth + subject-ACL, шлюзу — read-only на `market.price.*`. - [ ] **SEC-10. NATS без аутентификации** — `auth_required` не выставлен, анонимная публикация в шину проходит (проверено на тестовом subject; боевой не трогали). Пока цена условна — терпимо; как только станет денежной, у шины нет прав на запись и любой сможет напечатать свой курс. Фикс: auth + subject-ACL, шлюзу — read-only на `market.price.*`.
+32
View File
@@ -0,0 +1,32 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: smart-updown-api
namespace: game
labels:
app.kubernetes.io/managed-by: manual
app.kubernetes.io/name: smart-updown-api
annotations:
# Отдельный ingress ВНЕ helm: чарт front2 рендерит только path "/", поэтому
# helm-релиз игры больше не сможет снести /api и /ws (инцидент 15.09.2026).
helm.sh/resource-policy: keep
spec:
ingressClassName: traefik
rules:
- host: smart-updown.binom.pw
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: testfront-gateway-smart-updown-relayer
port:
number: 8895
- path: /ws
pathType: Prefix
backend:
service:
name: testfront-gateway-smart-updown-relayer
port:
number: 8895
+77 -2
View File
@@ -23,8 +23,9 @@ kubectl -n game exec "$P" -c gateway -- sh -c "cd /app && node /app/data/probe/e
- **Копировать только в `/app/data/`**, не в `/app` (`Permission denied`, под не root). - **Копировать только в `/app/data/`**, не в `/app` (`Permission denied`, под не root).
- **Запускать с `cd /app`**, иначе `Cannot find module '@solana/web3.js'` (из `/tmp`/`/app/data` - **Запускать с `cd /app`**, иначе `Cannot find module '@solana/web3.js'` (из `/tmp`/`/app/data`
без cwd `node_modules` не виден). без cwd `node_modules` не виден).
- Ставка при `exit == entry` остаётся `open` — это ожидаемо (равные цены, отдельная задача), - Ставка при `exit == entry`: с 2026-09-14 (`SEC-9`) **возвращает залог** статусом `3` и
а не сбой проверки. финализируется — залипаний больше нет. *Открытый вопрос:* хочется вместо немедленного возврата
**подождать ещё 15 с** и взять цену заново (см. `TASKS.md`, «ждать, если цена не изменилась»).
Читает `bet_time` / `expire_time` прямо из аккаунта Bet (сырые байты) — off-chain данными не Читает `bet_time` / `expire_time` прямо из аккаунта Bet (сырые байты) — off-chain данными не
верим. Раскладка аккаунта (Anchor, после 8-байтового дискриминатора): `amount u64 @8`, верим. Раскладка аккаунта (Anchor, после 8-байтового дискриминатора): `amount u64 @8`,
@@ -43,3 +44,77 @@ node tools/price-history-probe.cjs <programId> <betId>
⚠️ **Символ в БД — в нижнем регистре** (`solusdt`, не `SOLUSDT`). Запрос с верхним регистром ⚠️ **Символ в БД — в нижнем регистре** (`solusdt`, не `SOLUSDT`). Запрос с верхним регистром
вернёт пусто, и это легко принять за «данных нет». вернёт пусто, и это легко принять за «данных нет».
## `whose-fee.cjs` — с ЧЬЕГО кошелька уходит SOL за раунд (доказательство `SEC-16`)
Мерит SOL **игрока** в трёх точках каждого раунда (до ставки → после ставки → после расчёта)
и SOL **шлюза-админа**. Замер: игрок `5 000 000 000 → 4 998 440 960` (−**1 559 040** lamports),
шлюз не меняется — значит ренту PDA-аккаунта ставки платит игрок.
```bash
node tools/whose-fee.cjs
```
## `bet-pda-rent.cjs` — сколько ренты заперто в аккаунтах ставок
Обходит `getProgramAccounts` (фильтр `dataSize:96`) и показывает, что у каждой ставки PDA
держит ровно **1 559 040** lamports. Аккаунт не закрывается никогда (`close_bet` не делает
`close = bettor`) → рента теряется безвозвратно. Второе доказательство `SEC-16`.
```bash
node tools/bet-pda-rent.cjs
```
## `faucet-latency.cjs` / `sol-balance-cycle.cjs` — почему кажется, что «SOL не обновился»
`GET /faucet/{addr}` отвечает **мгновенно**, но `getBalance` **на цепи** показывает `0` ещё
**8–13 с** (замеры: 13309 / 13345 / 13345 мс). Игра читает баланс не через `/api/balance`, а
через свой nginx-прокси `/solana-rpc/getBalance` → прямо с цепи → попадает в окно лага.
Симптом «налил, а SOL не обновился» — это лаг зачисления, а не отставание шлюза.
```bash
node tools/faucet-latency.cjs # лаг крана: цепь против отчёта шлюза
node tools/sol-balance-cycle.cjs # баланс после крана: сразу 0, через ~20 с 5 SOL
```
## `settle-math-test.cjs` — математика расчёта (правильный способ замера)
⚠️ Шлюз **кастодиальный**, поэтому дельту надо считать **от момента после lock** (фантики ушли
в vault при ставке), а НЕ от старта раунда — иначе в дельту попадают и ставка, и выплата, и
«математика не сходится» ложно. Замерено: выигрыш `+130 000 000` (1.3×), проигрыш `0`,
ничья `+100 000 000` (возврат). Считать «от старта» = получить ложный провал (проверено живьём).
```bash
node tools/settle-math-test.cjs
```
## `backend-full-test.cjs` — контракт ручек и коды ошибок шлюза
Приёмка API живьём: `/state`, `/price`, `/wallet`, `/faucet`, `/balance`, `/bet`, идемпотентность
крана, `400` на кривые данные, `409` на вторую открытую ставку, `404` на снятые ручки
(`/close`, `/bet/{id}`), WS-события `bet_open`/`bet_closed`. Полный прогон с расчётом на цепи.
```bash
node tools/backend-full-test.cjs
```
⚠️ `/bet` требует **число** в `amountUnits` (строка → `400`) и `address` в теле.
⚠️ `getTokenAccountsByOwner` принимает **2–3** параметра: commitment кладётся **внутрь третьего**
(`cfg`), а не четвёртым — иначе `-32602 Expected from 2 to 3 parameters`.
## `k8s/smart-updown-api-ingress.yaml` — пути `/api`,`/ws` вне helm
⚠️ Чарт `fun-game-front2` рендерит **только** path `/`, поэтому любой `helm upgrade` игры
**сносит** `/api` и `/ws` с боевого домена (домен начинает отдавать HTML игры на `/api/*`).
Лечение — отдельный ingress `smart-updown-api` **вне helm**; front2-ingress остаётся `paths: [/]`.
```bash
ssh root@192.168.76.120 "export KUBECONFIG=/etc/rancher/k3s/k3s.yaml; \
kubectl apply -f /root/smart-updown-api-ingress.yaml"
# проверка после каждого релиза игры:
kubectl -n game get ingress -o custom-columns='NAME:.metadata.name,HOST:.spec.rules[0].host,PATHS:.spec.rules[0].http.paths[*].path'
```
⚠️ НЕ править paths через `kubectl patch --type=json` с `remove` по индексам: при нескольких
путях индексы съезжают, остаётся висячий `/ws`, а последний path удалить нельзя
(`paths: Required value`). Надёжно — собрать нужный список целиком и `kubectl replace --force`.
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env node
// ПОЛНЫЙ ТЕСТ БЭКА (TS-шлюз) на игровом домене: контракт ручек, коды ошибок, WS, расчёт.
const B = process.env.BASE || "https://smart-updown.binom.pw/api";
const RPC = process.env.RPC || "https://smart-updown.binom.pw/solana-rpc/";
const WS_URL = process.env.WS_URL || "wss://smart-updown.binom.pw/api/ws";
const WebSocket = require(process.env.WS_MODULE || "ws");
const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const BET = 100000000n;
const res = [];
const ok = (name, cond, detail = "") => { res.push({ name, pass: !!cond, detail }); console.log(`${cond ? "OK " : "FAIL"} | ${name}${detail ? " — " + String(detail).slice(0, 150) : ""}`); };
const j = async (p, init) => {
const r = await fetch(B + p, init);
const t = await r.text();
let d = null; try { d = JSON.parse(t); } catch { d = t; }
return { s: r.status, d };
};
const post = (p, body) => j(p, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const rpc = async (m, p, commitment) => {
const body = { jsonrpc: "2.0", id: 1, method: m, params: p };
if (commitment) body.params = [...p, { commitment }];
const r = await fetch(RPC, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const x = await r.json();
if (x.error) throw new Error(JSON.stringify(x.error).slice(0, 120));
return x.result;
};
const sol = (a, c) => rpc("getBalance", [a], c).then((r) => r.value);
const tok = async (a, c) => {
const cfg = { encoding: "jsonParsed" };
if (c) cfg.commitment = c;
const r = await rpc("getTokenAccountsByOwner", [a, { programId: TOKEN_PROGRAM }, cfg]);
return r.value.length ? BigInt(r.value[0].account.data.parsed.info.tokenAmount.amount) : 0n;
};
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
console.log("### БЭК:", B, "\n");
// ---------- A. КОНТРАКТ РУЧЕК ----------
const st = await j("/state");
ok("GET /state → 200", st.s === 200, st.s);
ok(" поля state на месте",
["counter", "nextBetId", "minAmount", "maxAmount", "multiplierBps", "expirySeconds", "paused", "admin", "relayer", "vaultBalance"]
.every((k) => st.d && st.d[k] !== undefined),
Object.keys(st.d || {}).join(","));
const pr = await j("/price");
ok("GET /price → 200 + symbol/price/ts", pr.s === 200 && pr.d.symbol && pr.d.price && pr.d.ts, JSON.stringify(pr.d).slice(0, 90));
const w = await post("/wallet", {});
const addr = w.d && w.d.address;
ok("POST /wallet → 200 + адрес", w.s === 200 && !!addr, addr);
const f1 = await j(`/faucet/${addr}`);
ok("GET /faucet/{addr} → 200", f1.s === 200, JSON.stringify(f1.d).slice(0, 80));
const f2 = await j(`/faucet/${addr}`);
ok(" повторный faucet идемпотентен (не удваивает)", f2.s === 200 && f2.d.sol === f1.d.sol, `${f1.d.sol} → ${f2.d.sol}`);
const bal = await j(`/balance/${addr}`);
ok("GET /balance/{addr} → 200 + sol/tokenUnits", bal.s === 200 && bal.d.solLamports !== undefined && bal.d.tokenUnits !== undefined, JSON.stringify(bal.d).slice(0, 90));
// ---------- B. КОДЫ ОШИБОК ----------
ok("POST /bet без адреса → 400", (await post("/bet", { side: "UP", amountUnits: "100000000" })).s === 400);
ok("POST /bet кривая сторона → 400", (await post("/bet", { address: addr, side: "SIDEWAYS", amountUnits: "100000000" })).s === 400);
ok("POST /bet нулевая сумма → 400", (await post("/bet", { address: addr, side: "UP", amountUnits: "0" })).s === 400);
ok("POST /bet сумма ниже минимума → 400", (await post("/bet", { address: addr, side: "UP", amountUnits: "1" })).s === 400);
ok("POST /bet сумма выше максимума → 400", (await post("/bet", { address: addr, side: "UP", amountUnits: "99999999999999" })).s === 400);
ok("POST /bet чужой (не в keystore) адрес → 400",
(await post("/bet", { address: "A2WeRZfCvhwUx8oQHPPhHbFiQxwn4XTURYBKetUdtHCZ", side: "UP", amountUnits: "100000000" })).s === 400);
ok("GET /balance/{мусор} → 400", (await j("/balance/не-адрес")).s === 400);
ok("POST /close → 404 (ручку убрали)", (await post("/close", {})).s === 404);
ok("GET /bet/{id} → 404 (ручки НЕТ — фронту Игры нужна)", (await j("/bet/61")).s === 404);
ok("POST /bet на несуществующий путь → 404", (await post("/nosuch", {})).s === 404);
// ---------- C. WS ----------
const ws = new WebSocket(WS_URL);
const ev = [];
await new Promise((r) => { ws.on("open", r); ws.on("error", r); setTimeout(r, 8000); });
ok("WS /ws поднимается", ws.readyState === 1);
ws.on("message", (m) => { try { ev.push(JSON.parse(m)); } catch {} });
await wait(5000);
ok(" WS отдаёт price", ev.some((e) => e.type === "price"), ev.find((e) => e.type === "price") && JSON.stringify(ev.find((e) => e.type === "price")).slice(0, 80));
// ---------- D. ЛАТЕНТНОСТЬ КРАНА (цепь vs шлюз) ----------
console.log("\n### ЛАТЕНТНОСТЬ ЗАЧИСЛЕНИЯ (шлюз рапортует сразу, цепь — когда дошло)");
const w2 = await post("/wallet", {});
const a2 = w2.d.address;
const t0 = Date.now();
await j(`/faucet/${a2}`);
const gwAfter = await j(`/balance/${a2}`);
let landed = null;
for (let t = 0; t < 120; t++) { // до 30 с, шаг 250 мс
await wait(250);
if (await sol(a2) > 0) { landed = Date.now() - t0; break; }
}
console.log(` шлюз сразу после крана: sol=${gwAfter.d.sol}`);
console.log(` на ЦЕПИ появилось через: ${landed === null ? ">30 c" : landed + " мс"}`);
ok(" кран: цепь догоняет шлюз (лаг)", landed !== null && landed < 3000, `${landed} мс`);
// ---------- E. ПОЛНЫЙ ЦИКЛ + ТОЧНЫЙ ЗАМЕР РАСЧЁТА ----------
console.log("\n### ПОЛНЫЙ ЦИКЛ (точный замер расчёта на цепи)");
await wait(12000); // ждём, пока кран осядет
let cycle = 0, win = 0, loss = 0, tie = 0, mathOk = 0, mathBad = 0;
for (const side of ["UP", "DOWN", "UP"]) {
cycle++;
const tokBefore = await tok(addr, "finalized"), solBefore = await sol(addr, "finalized");
const bet = await post("/bet", { address: addr, side, amountUnits: BET.toString() });
if (bet.s !== 200) { console.log(` раунд ${cycle}: ставка отклонена ${bet.s} ${JSON.stringify(bet.d)}`); break; }
// ждём события закрытия
let closed = null;
for (let t = 0; t < 40; t++) {
await wait(1000);
closed = ev.filter((e) => e.type === "bet_closed" && String(e.id) === String(bet.d.id)).pop();
if (closed) break;
}
// ловим МОМЕНТ, когда фантики на цепи изменились
const tEv = Date.now();
let delta = 0n, settledAt = null;
for (let t = 0; t < 80; t++) {
const tt = await tok(addr, "finalized");
if (tt !== tokBefore) { delta = tt - tokBefore; settledAt = Date.now() - tEv; break; }
await wait(500);
}
const solAfter = await sol(addr, "finalized");
const nm = { 1: "ВЫИГРЫШ", 2: "ПРОИГРЫШ", 3: "НИЧЬЯ" }[closed && closed.status] || "?";
if (closed && closed.status === 1) win++; else if (closed && closed.status === 2) loss++; else if (closed && closed.status === 3) tie++;
const expect = closed ? (closed.status === 1 ? BET * 3n / 10n : closed.status === 2 ? -BET : 0n) : null;
const math = delta === expect;
if (settledAt !== null) (math ? mathOk++ : mathBad++);
console.log(` раунд ${cycle} (id=${bet.d.id}): ${nm} | фантики ${tokBefore} → ${tokBefore + delta} (Δ${delta}, ожидалось ${expect}) ${math ? "✓" : "✗"} | расчёт на цепи через ${settledAt} мс | SOL Δ=${solAfter - solBefore}`);
ok(` раунд ${cycle}: исход ${nm} + расчёт сошёлся`, math, `Δ=${delta}`);
await wait(2000);
}
const balEnd = await j(`/balance/${addr}`);
console.log(`\n баланс шлюза в конце: ${JSON.stringify(balEnd.d)}`);
console.log(` события WS: ${ev.filter((e) => e.type !== "price").map((e) => e.type).join(", ")}`);
ok(" WS bet_open приходит", ev.some((e) => e.type === "bet_open"));
ok(" WS bet_closed приходит", ev.some((e) => e.type === "bet_closed"));
const bad = res.filter((r) => !r.pass);
console.log(`\n=== ИТОГ: ${res.length - bad.length}/${res.length} пройдено ===`);
if (bad.length) console.log("ПРОВАЛЫ:\n" + bad.map((b) => " - " + b.name + " [" + b.detail + "]").join("\n"));
console.log(`исходы: выигрыш=${win} проигрыш=${loss} ничья=${tie}; математика: сошлось=${mathOk} не сошлось=${mathBad}`);
ws.close();
process.exit(bad.length ? 1 : 0);
})();
+37
View File
@@ -0,0 +1,37 @@
// Сколько lamports лежит в PDA ставок и кому принадлежит рента
const { PublicKey } = require(process.env.SOLANA_WEB3_MODULE || "@solana/web3.js");
const RPC = "https://smart-updown.binom.pw/solana-rpc/";
const PROGRAM = new PublicKey("9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf");
const BET_SEED = Buffer.from("bet");
const GLOBAL_SEED = Buffer.from("global");
const VAULT_SEED = Buffer.from("vault");
const rpc = async (m, p) => {
const r = await fetch(RPC, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: m, params: p }) });
const x = await r.json();
if (x.error) throw new Error(JSON.stringify(x.error).slice(0, 150));
return x.result;
};
const betPda = (id) => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(id)); return PublicKey.findProgramAddressSync([BET_SEED, b], PROGRAM)[0]; };
(async () => {
console.log(`программа: ${PROGRAM.toBase58()}`);
const g = PublicKey.findProgramAddressSync([GLOBAL_SEED], PROGRAM)[0];
const v = PublicKey.findProgramAddressSync([GLOBAL_SEED, VAULT_SEED], PROGRAM)[0];
console.log(`global PDA: ${g.toBase58()}`);
console.log(`vault PDA: ${v.toBase58()}\n`);
let total = 0n, alive = 0;
for (const id of [60, 65, 67, 68, 69, 70, 71, 72, 73, 74]) {
const p = betPda(id);
const r = await rpc("getAccountInfo", [p.toBase58(), { encoding: "base64" }]);
const info = r.value;
if (!info) { console.log(`ставка ${id}: PDA ${p.toBase58().slice(0, 8)}… НЕТ в цепи`); continue; }
alive++; total += BigInt(info.lamports);
console.log(`ставка ${id}: PDA ${p.toBase58().slice(0, 8)}… lamports=${info.lamports} owner=${info.owner.slice(0, 8)}… размер=${info.data[0].length * 3 / 4} байт`);
}
console.log(`\nвсего PDA ставок: ${alive}; суммарно lamports в них: ${total} (${Number(total) / 1e9} SOL)`);
const gr = await rpc("getAccountInfo", [g.toBase58(), { encoding: "base64" }]);
console.log(`global PDA lamports: ${gr.value ? gr.value.lamports : "нет"}`);
process.exit(0);
})();
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
// Замер: сколько времени кран зачисляет SOL на цепь (и почему фронт видит 0).
const B = "https://smart-updown.binom.pw/api";
const RPC = "https://smart-updown.binom.pw/solana-rpc/";
const WebSocket = require(process.env.WS_MODULE || "ws");
const j = async (p, init) => {
const r = await fetch(B + p, init);
return { s: r.status, d: await r.json().catch(() => null) };
};
const rpcBal = async (a) => {
const r = await fetch(RPC, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getBalance", params: [a] }),
});
return (await r.json()).result.value;
};
(async () => {
const w = await j("/wallet", { method: "POST" });
const addr = w.d.address;
console.log("адрес:", addr);
const t0 = Date.now();
const f = await j(`/faucet/${addr}`);
console.log(`кран ответил: ${f.s} sol=${f.d.sol} (t=${Date.now() - t0}ms)`);
console.log("\nвремя_от_крана RPC_баланс шлюз_sol");
for (const wait of [0, 250, 500, 1000, 2000, 4000, 8000, 16000, 30000]) {
while (Date.now() - t0 < wait) await new Promise((r) => setTimeout(r, 50));
const chain = await rpcBal(addr);
const gw = await j(`/balance/${addr}`);
console.log(`${String(Date.now() - t0).padStart(9)}ms ${String(chain).padStart(14)} ${String(gw.d && gw.d.sol).padStart(12)}`);
if (chain > 0 && Date.now() - t0 > 3000) break;
}
process.exit(0);
})();
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
// КОРРЕКТНЫЙ замер расчёта: шлюз КАСТОДИАЛЬНЫЙ, фантики уходят в vault в момент
// ставки (lock), возврат/выплата — при закрытии. Значит дельту надо считать
// ОТ момента после lock, иначе в неё попадёт и ставка, и выплата (двойной счёт).
const B = "https://smart-updown.binom.pw/api";
const RPC = "https://smart-updown.binom.pw/solana-rpc/";
const WebSocket = require(process.env.WS_MODULE || "ws");
const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const BET = 100000000n; // 100 фантиков
const j = async (p, init) => {
const r = await fetch(B + p, init);
return { s: r.status, d: await r.json().catch(() => null) };
};
const post = (p, b) => j(p, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(b) });
const rpc = async (m, p) => {
const r = await fetch(RPC, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: m, params: p }),
});
const x = await r.json();
if (x.error) throw new Error(JSON.stringify(x.error).slice(0, 100));
return x.result;
};
const sol = (a) => rpc("getBalance", [a]).then((r) => r.value);
const tok = async (a) => {
const r = await rpc("getTokenAccountsByOwner", [a, { programId: TOKEN_PROGRAM }, { encoding: "jsonParsed" }]);
return r.value.length ? BigInt(r.value[0].account.data.parsed.info.tokenAmount.amount) : 0n;
};
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const STATUS = { 1: "ВЫИГРЫШ", 2: "ПРОИГРЫШ", 3: "НИЧЬЯ(возврат)" };
(async () => {
const w = await post("/wallet", {});
const addr = w.d.address;
await j(`/faucet/${addr}`);
console.log(`игрок ${addr}\nкран выдан, ждём зачисления на цепь...`);
for (let i = 0; i < 120 && (await sol(addr)) === 0; i++) await wait(500);
console.log(`на цепи: ${await sol(addr)} lamports\n`);
const ws = new WebSocket("wss://smart-updown.binom.pw/api/ws");
const ev = [];
await new Promise((r) => { ws.on("open", r); ws.on("error", r); setTimeout(r, 6000); });
ws.on("message", (m) => { try { const e = JSON.parse(m); if (e.type !== "price") ev.push(e); } catch {} });
let good = 0, bad = 0;
for (let i = 1; i <= 4; i++) {
const start = await tok(addr);
const bet = await post("/bet", { address: addr, side: i % 2 ? "UP" : "DOWN", amountUnits: BET.toString() });
if (bet.s !== 200) { console.log(`раунд ${i}: ставка отклонена ${bet.s} ${JSON.stringify(bet.d)}`); break; }
// ждём LOCK: фантики ушли в vault (-BET)
let lock = null;
for (let t = 0; t < 40; t++) { await wait(400); const x = await tok(addr); if (x === start - BET) { lock = x; break; } }
if (lock === null) { console.log(`раунд ${i}: lock не виден (${start} → ${await tok(addr)})`); continue; }
// ждём закрытие
let closed = null;
for (let t = 0; t < 40; t++) {
await wait(750);
closed = ev.filter((e) => e.type === "bet_closed" && String(e.id) === String(bet.d.id)).pop();
if (closed) break;
}
// ждём PAYOUT: баланс МЕНЯЕТСЯ от зафиксированного lock
let after = lock, payoutAt = null;
const tC = Date.now();
for (let t = 0; t < 60; t++) { await wait(400); const x = await tok(addr); if (x !== lock) { after = x; payoutAt = Date.now() - tC; break; } }
const paid = after - lock; // 0 = проигрыш, +BET = возврат, +1.3*BET = выигрыш
const nm = closed ? STATUS[closed.status] : "НЕ ЗАКРЫЛОСЬ";
const expect = closed ? (closed.status === 1 ? BET * 13000n / 10000n : closed.status === 2 ? 0n : BET) : null;
const goodNow = expect !== null && paid === expect;
goodNow ? good++ : bad++;
console.log(`раунд ${i} (id=${bet.d.id}): ${nm}`);
console.log(` фантики: ${start} → lock ${lock} → после ${after}`);
console.log(` выплата из vault = ${paid} (ожидалось ${expect}) ${goodNow ? "✓ ВЕРНО" : "✗ РАСХОЖДЕНИЕ"} [осело через ${payoutAt} мс]`);
await wait(1500);
}
console.log(`\n=== математика расчёта: верно=${good} неверно=${bad} ===`);
console.log(`баланс шлюза: ${JSON.stringify((await j(`/balance/${addr}`)).d)}`);
ws.close();
process.exit(0);
})();
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env node
// Проверка: обновляется ли SOL после полного цикла ставки (на живом домене).
const B = "https://smart-updown.binom.pw/api";
const RPC = "https://smart-updown.binom.pw/solana-rpc/";
const WS_URL = "wss://smart-updown.binom.pw/api/ws";
const WebSocket = require(process.env.WS_MODULE || "ws");
const j = async (p, init) => {
const r = await fetch(B + p, init);
const t = await r.text();
try { return { s: r.status, d: JSON.parse(t) }; } catch { return { s: r.status, d: t }; }
};
const rpcBal = async (addr) => {
const r = await fetch(RPC, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getBalance", params: [addr] }),
});
const d = await r.json();
return d.result.value;
};
(async () => {
const log = (k, v) => console.log(`${k.padEnd(34)} ${v}`);
// 1. свежий кошелёк
const w = await j("/wallet", { method: "POST" });
const addr = w.d.address;
log("1. /wallet → адрес", addr);
let bal = await rpcBal(addr);
log("2. RPC getBalance (пусто)", bal);
// 2. кран
const f = await j(`/faucet/${addr}`);
log("3. /faucet", f.s + " sol=" + f.d.sol);
bal = await rpcBal(addr);
log("4. RPC после крана", bal + " (" + (bal / 1e9).toFixed(4) + " SOL)");
const gw1 = await j(`/balance/${addr}`);
log("5. шлюз /balance", JSON.stringify(gw1.d));
// 3. WS
const ws = new WebSocket(WS_URL);
const events = [];
await new Promise((res) => { ws.on("open", res); ws.on("error", res); setTimeout(res, 8000); });
ws.on("message", (m) => { try { const e = JSON.parse(m); if (e.type && e.type !== "price") events.push(e); } catch {} });
log("6. WS", ws.readyState === 1 ? "подключён" : "НЕ подключён");
// 4. ставка
const bet = await j("/bet", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address: addr, side: "UP", amountUnits: "100000000" }),
});
log("7. POST /bet", bet.s + " " + JSON.stringify(bet.d).slice(0, 110));
const before = await rpcBal(addr);
log("8. RPC сразу после ставки", before + " (" + (before / 1e9).toFixed(6) + " SOL)");
// 5. ждём авто-закрытие
const id = bet.d.id;
await new Promise((r) => setTimeout(r, 20000));
const after = await rpcBal(addr);
log("9. RPC после закрытия (~20с)", after + " (" + (after / 1e9).toFixed(6) + " SOL)");
const gw2 = await j(`/balance/${addr}`);
log("10. шлюз /balance после", JSON.stringify(gw2.d));
log("11. WS-события", JSON.stringify(events).slice(0, 300));
const delta = after - before;
log("12. ДЕЛЬТА на цепи", delta + " lamports (" + (delta / 1e9).toFixed(6) + " SOL)");
log("13. читается ли /api/bet/" + id, (await j(`/bet/${id}`)).s);
ws.close();
process.exit(0);
})();
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
// ЧЬИ это 1.56e6 lamports за раунд: кошелёк ИГРОКА или кошелёк ШЛЮЗА?
// Мерим SOL игрока на цепи в 3 точках каждого раунда + SOL админа-шлюза.
const B = "https://smart-updown.binom.pw/api";
const RPC = "https://smart-updown.binom.pw/solana-rpc/";
const BET = 100000000n;
const j = async (p, init) => {
const r = await fetch(B + p, init);
return { s: r.status, d: await r.json().catch(() => null) };
};
const post = (p, b) => j(p, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(b) });
const rpc = async (m, p) => {
const r = await fetch(RPC, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: m, params: p }),
});
const x = await r.json();
if (x.error) throw new Error(JSON.stringify(x.error).slice(0, 120));
return x.result;
};
const sol = (a) => rpc("getBalance", [a]).then((r) => r.value);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
const w = await post("/wallet", {});
if (w.s !== 200 || !w.d?.address) { console.log("нет адреса:", w.s, JSON.stringify(w.d)); process.exit(1); }
const p = w.d.address;
console.log(`игрок: ${p}`);
// админ-кошелёк шлюза — ищем в ответах состояния
const st = (await j("/state")).d || {};
const admin = st.admin || st.house || st.wallet || null;
console.log(`админ из /state: ${admin || "не указан"}`);
await j(`/faucet/${p}`);
for (let i = 0; i < 140 && (await sol(p)) === 0; i++) await wait(500);
console.log(`после крана на цепи: ${await sol(p)} lamports\n`);
const A = [];
for (let i = 1; i <= 3; i++) {
const before = BigInt(await sol(p));
const bet = await post("/bet", { address: p, side: i % 2 ? "UP" : "DOWN", amountUnits: BET.toString() });
if (bet.s !== 200) { console.log(`раунд ${i}: ставка ${bet.s} ${JSON.stringify(bet.d)}`); break; }
await wait(1200);
const mid = BigInt(await sol(p));
// ждём закрытия и осадки
let after = mid, t0 = Date.now();
for (let t = 0; t < 90; t++) { await wait(700); const x = BigInt(await sol(p)); if (x !== mid) { after = x; break; } }
console.log(`раунд ${i} (id=${bet.d.id})`);
console.log(` SOL игрока: до ${before} → после ставки ${mid} → в конце ${after}`);
console.log(` Δ за раунд: ${after - before} lamports` + (admin ? ` | админ ${await sol(admin)}` : ""));
A.push(after - before);
await wait(1200);
}
console.log(`\n=== дельты SOL игрока по раундам: ${A.join(", ")} ===`);
console.log(`среднее: ${A.length ? (A.reduce((a, b) => a + b, 0n) / BigInt(A.length)) : 0} lamports/раунд`);
const fin = (await j(`/balance/${p}`)).d;
console.log(`отчёт шлюза по игроку: ${JSON.stringify(fin)}`);
process.exit(0);
})();