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
+20 -2
View File
@@ -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.*`.