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:
@@ -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 строковым парсером
|
||||
|
||||
+20
-2
@@ -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.*`.
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user