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
+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).
- **Запускать с `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 <programId> <betId>
⚠️ **Символ в БД — в нижнем регистре** (`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`.