101a4b60a9
Найдено при приёмке боевого домена 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 (релиз игры их сносил).
150 lines
8.9 KiB
JavaScript
150 lines
8.9 KiB
JavaScript
#!/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);
|
||
})();
|