diff --git a/TASKS.md b/TASKS.md index d5bb718..d0e3d22 100644 --- a/TASKS.md +++ b/TASKS.md @@ -91,6 +91,34 @@ ## Ссылки - Решения/экономика: `docs/DECISIONS.md`, `docs/ECONOMY.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) - [x] **DONE** ADR-007: цена SOL из NATS (`market.price.solusdt`), заглушка удалена - [x] **DONE** `natsPrice.ts` — подписка NATS, последняя цена, `waitForPrice`, ×1e8 строковым парсером diff --git a/docs/SECURITY.md b/docs/SECURITY.md index cee4915..e23a14d 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2,7 +2,8 @@ **Статус:** открыт, ведётся с 2026-09-13. **Область:** `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` (не меняется при добавлении/закрытии пунктов) — ссылаться на них номером. Отмечать закрытое — `[ ]` → `[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 — `SEC-6` (2026-09-13, `updown-relayer:2` / `test-front:3`), `SEC-1`, `SEC-2`, `SEC-4` (2026-09-13, `updown-relayer:3`, коммит `0b136c6`), @@ -19,6 +20,9 @@ коммит `42dfa58`), плюс `SEC-2` и `SEC-4` усилены в `:6`. +**Добавлено 2026-09-15:** `SEC-16` — безвозвратная утечка ренты PDA-аккаунта ставки с кошелька +игрока (найдено при приёмке боевого домена `smart-updown.binom.pw`). + **Итерация 2026-09-13 (`updown-relayer:6`) — что сделано:** авто-закрытие ставок серверным планировщиком (`settle.ts`), ручка `POST /close` удалена (`404`), WS-события `bet_open`/`bet_closed` в существующем канале (`/ws`), ограничение «одна открытая ставка на адрес» (`409`), @@ -68,6 +72,20 @@ **Деплой:** программа `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"` подтверждён. *Остаток:* политика выбрана как «возврат залога без множителя»; доля ничьих ~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.*`. diff --git a/k8s/smart-updown-api-ingress.yaml b/k8s/smart-updown-api-ingress.yaml new file mode 100644 index 0000000..e9a8f71 --- /dev/null +++ b/k8s/smart-updown-api-ingress.yaml @@ -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 diff --git a/tools/README.md b/tools/README.md index 0ace988..53d9b9e 100644 --- a/tools/README.md +++ b/tools/README.md @@ -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). - **Запускать с `cd /app`**, иначе `Cannot find module '@solana/web3.js'` (из `/tmp`/`/app/data` без cwd `node_modules` не виден). -- Ставка при `exit == entry` остаётся `open` — это ожидаемо (равные цены, отдельная задача), - а не сбой проверки. +- Ставка при `exit == entry`: с 2026-09-14 (`SEC-9`) **возвращает залог** статусом `3` и + финализируется — залипаний больше нет. *Открытый вопрос:* хочется вместо немедленного возврата + **подождать ещё 15 с** и взять цену заново (см. `TASKS.md`, «ждать, если цена не изменилась»). Читает `bet_time` / `expire_time` прямо из аккаунта Bet (сырые байты) — off-chain данными не верим. Раскладка аккаунта (Anchor, после 8-байтового дискриминатора): `amount u64 @8`, @@ -43,3 +44,77 @@ node tools/price-history-probe.cjs ⚠️ **Символ в БД — в нижнем регистре** (`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`. diff --git a/tools/backend-full-test.cjs b/tools/backend-full-test.cjs new file mode 100644 index 0000000..c95aacf --- /dev/null +++ b/tools/backend-full-test.cjs @@ -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); +})(); diff --git a/tools/bet-pda-rent.cjs b/tools/bet-pda-rent.cjs new file mode 100644 index 0000000..2b29ef0 --- /dev/null +++ b/tools/bet-pda-rent.cjs @@ -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); +})(); diff --git a/tools/faucet-latency.cjs b/tools/faucet-latency.cjs new file mode 100644 index 0000000..8750ce0 --- /dev/null +++ b/tools/faucet-latency.cjs @@ -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); +})(); diff --git a/tools/settle-math-test.cjs b/tools/settle-math-test.cjs new file mode 100644 index 0000000..6858243 --- /dev/null +++ b/tools/settle-math-test.cjs @@ -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); +})(); diff --git a/tools/sol-balance-cycle.cjs b/tools/sol-balance-cycle.cjs new file mode 100644 index 0000000..ec9311c --- /dev/null +++ b/tools/sol-balance-cycle.cjs @@ -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); +})(); diff --git a/tools/whose-fee.cjs b/tools/whose-fee.cjs new file mode 100644 index 0000000..6df9203 --- /dev/null +++ b/tools/whose-fee.cjs @@ -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); +})();