6e7132e935
Механизм: шлюз берёт цену закрытия из локальной истории на момент expire_time ставки (priceHistory, node:sqlite, ретеншен 24ч), а не текущую. Момент клика перестаёт влиять на исход — окно выбора времени закрыто. Живая проверка (ставка №10, релиз updown-relayer:3): цена на момент экспирации 9977000000 текущая цена в момент клика 9976000000 exitPrice в транзакции 9977000000 -> взята история, не 'сейчас' статус payout_done Также: tools/ — скрипты проверки (price-history-probe, exploit-decisive, exploit-check, check-close-window.sh). Остаток: в контракте верхней границы окна по-прежнему нет (второй слой, не критично); серверное авто-закрытие — следующая итерация.
129 lines
6.4 KiB
JavaScript
129 lines
6.4 KiB
JavaScript
/**
|
||
* РЕШАЮЩАЯ проверка: цена закрытия берётся из истории на момент экспирации,
|
||
* а НЕ из текущей цены в момент клика.
|
||
*
|
||
* Почему прошлые прогоны были слабыми: сравнивали exitPrice с ценой момента клика,
|
||
* а цена случайно стояла на месте — сравнение вырождалось.
|
||
*
|
||
* Здесь ждём момента, когда цена на экспирации и текущая цена РАЗЛИЧАЮТСЯ,
|
||
* и только тогда закрываем. Тогда исход однозначен:
|
||
* - уязвимое поведение -> exitPrice == текущая цена (момент клика)
|
||
* - исправленное -> exitPrice == цена на момент экспирации
|
||
*/
|
||
const { Connection, PublicKey } = require("@solana/web3.js");
|
||
const { DatabaseSync } = require("node:sqlite");
|
||
|
||
const GW = "http://127.0.0.1:8895";
|
||
const RPC = "http://192.168.76.181:8899";
|
||
const PROGRAM_ID = new PublicKey("9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf");
|
||
const DB = "/app/data/price-history.db";
|
||
|
||
const MAX_WAIT_MS = Number(process.argv[2] || 300) * 1000;
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
async function api(path, body) {
|
||
const opts = body === undefined
|
||
? { method: "GET" }
|
||
: { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
|
||
const r = await fetch(GW + path, opts);
|
||
let j = null;
|
||
try { j = await r.json(); } catch { /* ignore */ }
|
||
return { status: r.status, json: j };
|
||
}
|
||
|
||
function histAt(ms) {
|
||
const db = new DatabaseSync(DB, { readOnly: true });
|
||
const row = db.prepare("SELECT price FROM price_history WHERE ts <= ? ORDER BY ts DESC LIMIT 1").get(ms);
|
||
const cnt = db.prepare("SELECT COUNT(*) c FROM price_history").get().c;
|
||
db.close();
|
||
return { price: row ? Number(row.price) : null, count: cnt };
|
||
}
|
||
|
||
async function readBet(id) {
|
||
const conn = new Connection(RPC, "confirmed");
|
||
const seed = Buffer.alloc(8); seed.writeBigUInt64LE(BigInt(id));
|
||
const [pda] = PublicKey.findProgramAddressSync([Buffer.from("bet"), seed], PROGRAM_ID);
|
||
const info = await conn.getAccountInfo(pda, "confirmed");
|
||
if (!info) return null;
|
||
const d = info.data;
|
||
return {
|
||
betTime: Number(d.readBigInt64LE(16)),
|
||
expireTime: Number(d.readBigInt64LE(24)),
|
||
entryPrice: d.readBigUInt64LE(32).toString(),
|
||
status: d.readBigUInt64LE(56).toString(),
|
||
};
|
||
}
|
||
|
||
async function currentPriceInt() {
|
||
const r = await api("/price");
|
||
if (!r.json || r.json.price == null) return null;
|
||
const [i, fr = ""] = String(r.json.price).split(".");
|
||
return Number(i + fr.padEnd(8, "0").slice(0, 8));
|
||
}
|
||
|
||
(async () => {
|
||
console.log("=== 1. кошелёк / пополнение / ставка ===");
|
||
const w = await api("/wallet", {});
|
||
const addr = w.json.address;
|
||
await api("/faucet/" + addr);
|
||
const bet = await api("/bet", { side: "UP", amountUnits: "100000000", address: addr });
|
||
if (!bet.json || !bet.json.id) throw new Error("bet failed: " + JSON.stringify(bet));
|
||
const id = bet.json.id;
|
||
console.log("address:", addr, "| betId:", id, "| entry:", bet.json.entryPrice);
|
||
|
||
await sleep(1500);
|
||
const b0 = await readBet(id);
|
||
const expireMs = b0.expireTime * 1000;
|
||
console.log("betTime:", new Date(b0.betTime * 1000).toISOString(),
|
||
"| expireTime:", new Date(expireMs).toISOString());
|
||
|
||
const blocked = await api("/close", { id });
|
||
console.log("проба закрытия до экспирации:", blocked.status, JSON.stringify(blocked.json));
|
||
|
||
console.log("\n=== 2. ждём, пока текущая цена ОТОЙДЁТ от цены на экспирации ===");
|
||
const t0 = Date.now();
|
||
let atExpiry = null, now = null, tries = 0;
|
||
while (Date.now() - t0 < MAX_WAIT_MS) {
|
||
if (Date.now() < expireMs) { await sleep(1000); continue; }
|
||
tries++;
|
||
atExpiry = histAt(expireMs).price;
|
||
now = await currentPriceInt();
|
||
process.stdout.write(` #${tries} цена@экспирации=${atExpiry} текущая=${now}\n`);
|
||
if (atExpiry !== null && now !== null && atExpiry !== now) break;
|
||
await sleep(2000);
|
||
}
|
||
|
||
if (atExpiry === null || now === null || atExpiry === now) {
|
||
console.log("\nНЕ ДОЖДАЛИСЬ расхождения цен — решающий тест не выполнен (повторить позже).");
|
||
return;
|
||
}
|
||
|
||
console.log("\n*** РАСХОЖДЕНИЕ ЕСТЬ: цена@экспирации=" + atExpiry + ", текущая=" + now + " ***");
|
||
console.log(" уязвимый код взял бы: " + now);
|
||
console.log(" исправленный должен взять: " + atExpiry);
|
||
|
||
console.log("\n=== 3. ЗАКРЫТИЕ ===");
|
||
const cl = await api("/close", { id });
|
||
console.log("ответ:", cl.status, JSON.stringify(cl.json));
|
||
const exitPrice = cl.json && cl.json.exitPrice != null ? Number(cl.json.exitPrice) : null;
|
||
const after = await readBet(id);
|
||
|
||
console.log("\n================ ВЕРДИКТ ================");
|
||
console.log("цена на момент экспирации (история):", atExpiry);
|
||
console.log("цена в момент клика (текущая) :", now);
|
||
console.log("exitPrice, ушедший в транзакцию :", exitPrice);
|
||
console.log();
|
||
if (exitPrice === null) {
|
||
console.log("ЗАКРЫТИЕ НЕ ВЫПОЛНИЛОСЬ.");
|
||
} else if (exitPrice === atExpiry && exitPrice !== now) {
|
||
console.log(">>> ОКНО ЗАКРЫТО <<<");
|
||
console.log("exitPrice = цена НА МОМЕНТ ЭКСПИРАЦИИ (" + atExpiry + "), а не текущая (" + now + ").");
|
||
console.log("Момент клика больше ни на что не влияет — эксплойт закрытия по выгодной цене невозможен.");
|
||
} else if (exitPrice === now && exitPrice !== atExpiry) {
|
||
console.log("!!! ОКНО ОТКРЫТО !!! exitPrice = ТЕКУЩАЯ цена момента клика (" + now + "). Фикс не работает.");
|
||
} else {
|
||
console.log("НЕОЖИДАННЫЙ ИСХОД: exitPrice=" + exitPrice);
|
||
}
|
||
console.log("статус ставки после: " + after.status + (after.status === "0" ? " (open — равные цены, отдельная задача)" : ""));
|
||
})().catch((e) => { console.error("ERR:", e.message); process.exit(1); });
|