Files
pump-game-ops/tools/backend-full-test.cjs
Hermes Agent 101a4b60a9 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 (релиз игры их сносил).
2026-09-15 05:57:34 +03:00

150 lines
8.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
})();