diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 94cae10..66382de 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -10,17 +10,22 @@ Пункты нумеруются сквозным `SEC-N` (не меняется при добавлении/закрытии пунктов) — ссылаться на них номером. Отмечать закрытое — `[ ]` → `[x]`, в закрытую строку добавлять дату и коммит/релиз. -**Сводка:** открыто 14 — критичных 5 (`SEC-1…SEC-5`), экономика 3 (`SEC-7…SEC-9`), -инфраструктура 3 (`SEC-10…SEC-12`), отложено 3 (`SEC-13…SEC-15`). Закрыто: 1 (`SEC-6`, 2026-09-13, релизы `updown-relayer:2` / `test-front:3`). +**Сводка:** открыто 12 — критичных 3 (`SEC-3`, `SEC-5`), экономика 3 (`SEC-7…SEC-9`), +инфраструктура 3 (`SEC-10…SEC-12`), отложено 3 (`SEC-13…SEC-15`). Закрыто: 4 — +`SEC-6` (2026-09-13, `updown-relayer:2` / `test-front:3`), +`SEC-1`, `SEC-2`, `SEC-4` (2026-09-13, `updown-relayer:3`, коммит `0b136c6`). --- ## Критично — эксплуатируется -- [ ] **SEC-1. Нет верхней границы окна закрытия ставки** — `close_bet` проверяет только `now >= expire_time`, но не `now <= expire_time + WINDOW`. Ставка остаётся `open` бесконечно, и момент расчёта выбирает тот, кто позвал `/close`. Место: `program/programs/smart-updown/src/instructions/close_bet.rs:60`. Фикс: `require!(now <= bet.expire_time + CLOSE_WINDOW_SECONDS, Error::CloseWindowExpired)`. -- [ ] **SEC-2. Нет авто-закрытия ставок на сервере** — в шлюзе отсутствует таймер закрытия; единственный `setInterval` в коде — WS-heartbeat. Экспирация ничего не значит, раунд завершает клиент. Место: `relayer/src/gateway.ts:621`. Фикс: серверный планировщик, закрывающий ставку в момент `expire_time`. +- [x] **SEC-1. Нет верхней границы окна закрытия ставки** — **ЗАКРЫТ 2026-09-13 по сути** (релиз `updown-relayer:3`, коммит `0b136c6`). Контракт по-прежнему не ограничивает время закрытия, **но выбирать момент расчёта больше нельзя**: шлюз берёт цену закрытия из локальной истории на момент `expire_time` ставки (`history.priceAtOrBefore(expireTime*1000)`), а не текущую. Поздний клик теперь даёт ту же цену, что и своевременный. Место правки: `relayer/src/gateway.ts` (`handleClose`), `relayer/src/priceHistory.ts`. + *Проверено на живом стенде (ставка №10):* цена на момент экспирации `9977000000`, текущая цена в момент клика `9976000000` → `exitPrice=9977000000` (взята история), ставка финализирована `payout_done`. Уязвимый код взял бы `9976000000`. + *Остаток (не критично):* в контракте верхней границы так и нет — открытый вопрос «как долго позволять закрывать» остаётся косметикой, поскольку цена уже зафиксирована. Строгий фикс на цепочке (тот, что был предложен изначально) — по-прежнему желателен как второй слой. +- [x] **SEC-2. Нет авто-закрытия ставок на сервере** — **ЗАКРЫТ 2026-09-13 по сути** (релиз `updown-relayer:3`). Серверное авто-закрытие по-прежнему не сделано (следующая итерация, по решению владельца), **но риск снят**: исход ставки больше не зависит от того, кто и когда позвал `/close`, потому что цена закрытия определяется историей. Опоздание сервера перестало быть риском — дозакрытие даст ту же цену. Место: `relayer/src/gateway.ts` (`handleClose`). + *Остаток:* без авто-закрытия незакрытые ставки продолжают запирать средства в ваулте (см. SEC-8) — вопрос экономики, не честности расчёта. - [ ] **SEC-3. `POST /close` доступен из интернета без авторизации** — любой может закрыть чужую ставку в выбранный им момент. Проверено: `curl -X POST https://testfront.binom.pw/api/close -d '{"id":"1"}'` → обработчик выполняется. Фикс: авторизация + серверное закрытие. -- [ ] **SEC-4. Безрисковый арбитраж на паре ставок UP+DOWN** — при `entry ≈ X` обе ставки закрываются в свой выгодный момент, обе получают `payout_done` (пара как схема — **вывод** из дыры окна; прямого прогона пары не делали). Прямо воспроизведено: ставка №5 (`DOWN`, `entry=9998000000`) висела `open` >2 мин после экспирации 15 с и закрыта при `exitPrice=9997000000` → `{"status":1,"statusName":"payout_done"}` — момент расчёта выбран вручную. Фикс: `SEC-1` + `SEC-2`. +- [x] **SEC-4. Безрисковый арбитраж на паре ставок UP+DOWN** — **ЗАКРЫТ 2026-09-13 по сути** (релиз `updown-relayer:3`, коммит `0b136c6`). Схема опиралась на возможность выбрать выгодный момент закрытия; теперь цена закрытия фиксируется историей на момент `expire_time`, и обе половины пары закрываются по одной и той же цене независимо от времени клика. Арбитраж исчезает вместе с SEC-1. *Прямого прогона пары UP+DOWN не делали — закрытие выводится из закрытия SEC-1 (тот же механизм).* - [ ] **SEC-5. `POST /bet` без авторизации** — любой может создавать ставки на произвольные адреса и накручивать счётчик раундов `global.counter`. Проверено: `curl -X POST .../api/bet` → 200. Фикс: токен/подпись запроса. - [x] **SEC-6. `GET /faucet/{address}` открыт наружу и выдаёт реальные средства** — **ЗАКРЫТ 2026-09-13** (релизы `updown-relayer:2`, `test-front:3`). Что было: из двух механизмов ручки (`handleFaucet`) — **(а) SOL-налив** (`requestAirdrop`) технически только localnet (mainnet-RPC отдаёт `410 Gone`); **(б) налив фантиков** (`mintTo`) — **НЕ был привязан к сети**: работал везде, где у шлюза есть mint authority минта. Гейтом был только конфиг-флаг `SMART_ALLOW_AIRDROP` с дефолтом **`"1"` (включено)**, проверки кластера в коде не было; SOL-барьер обходился (airdrop звался лишь при балансе <2 SOL, `ops.ts:204`) — адрес с ≥2 SOL доходил прямо до `mintTo`. Было воспроизведено: `curl …/api/faucet/6Q1ko…` → `{"sol":5,"tokenUnits":"10000000000"}` на произвольный адрес без авторизации. **Что сделано:** (1) новый модуль `relayer/src/faucetGate.ts` — `parseMode` + `faucetDenyReason` с политикой **fail-closed**: отказ, если режим не `test`, если genesis-hash недоступен, или если он принадлежит mainnet-beta/devnet/testnet (`5eykt4…`/`EtWTRA…`/`4uhcVJ…`). (2) `SMART_MODE` (`test|release`), **дефолт `release`** — безопасно забыть, а не вспомнить. (3) `handleFaucet`: 403 на уровне режима и 403 на уровне сети, в лог `faucet DENIED (<причина>)`. (4) Начисление **вынесено из `/bet`** — ставка больше не печатает денег, а `address` стал обязательным; перед ставкой проверяются балансы (SOL ≥ 0.01, фантиков ≥ суммы). (5) Новая ручка `POST /wallet` — создать custodial-кошелёк, **ничего не наливает**. (6) На пульте `test-front` — отдельный блок «Кошелёк и пополнение» с кнопками «Создать кошелёк» / «Пополнить кошелёк». diff --git a/tools/check-close-window.sh b/tools/check-close-window.sh new file mode 100644 index 0000000..4d265cf --- /dev/null +++ b/tools/check-close-window.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Живая проверка того, что окно выбора времени ЗАКРЫТО. +# +# Схема проверки (повторяет поведение вора): +# 1. создаём кошелёк, пополняем +# 2. ставим ставку +# 3. ЖДЁМ существенно дольше экспирации (по умолчанию 60 с; экспирация 15 с) +# 4. закрываем и смотрим, какую цену вернул шлюз +# +# Ожидание ПОСЛЕ фикса: цена закрытия == цена на момент экспирации (из истории), +# а НЕ текущая цена на момент клика. Плюс шлюз должен отказать, если цена закрытия +# совпала с ценой входа (ставка останется open — это отдельная задача). +# +# Использование: bash check-exploit.sh [секунды_ожидания] +set -uo pipefail + +G="${GW_URL:-https://testfront.binom.pw/api}" +WAIT_SEC="${1:-60}" + +jqget() { python3 -c "import sys,json;print(json.load(sys.stdin)$1)" 2>/dev/null; } + +echo "=== 1. кошелёк ===" +W=$(curl -s --max-time 30 -X POST "$G/wallet" -H 'Content-Type: application/json' -d '{}') +A=$(echo "$W" | jqget "['address']") +echo "address=$A" +[ -z "$A" ] && { echo "ОШИБКА: адрес не получен ($W)"; exit 1; } + +echo "=== 2. пополнение ===" +curl -s --max-time 60 "$G/faucet/$A" | head -c 200; echo + +echo "=== 3. ставка ===" +BET=$(curl -s --max-time 45 -X POST "$G/bet" -H 'Content-Type: application/json' \ + -d "{\"side\":\"UP\",\"amountUnits\":\"100000000\",\"address\":\"$A\"}") +echo "$BET" +ID=$(echo "$BET" | jqget "['id']") +ENTRY=$(echo "$BET" | jqget "['entryPrice']") +[ -z "$ID" ] && { echo "ОШИБКА: ставка не создана"; exit 1; } +echo "id=$ID entryPrice=$ENTRY" + +echo +echo "=== 4. цена шлюза СЕЙЧАС (до экспирации) ===" +curl -s --max-time 20 "$G/price"; echo + +echo +echo "=== 5. ждём ${WAIT_SEC}с (экспирация 15с — окно прошло) ===" +sleep "$WAIT_SEC" + +echo "=== 6. цена шлюза ПОСЛЕ ожидания (текущая, которую вор хотел бы получить) ===" +P_NOW=$(curl -s --max-time 20 "$G/price") +echo "$P_NOW" + +echo +echo "=== 7. ЗАКРЫТИЕ — какую цену возьмёт? ===" +BEFORE=$(date -u +%s) +CLOSE=$(curl -s --max-time 45 -X POST "$G/close" -H 'Content-Type: application/json' -d "{\"id\":\"$ID\"}") +AFTER=$(date -u +%s) +echo "$CLOSE" + +EXIT=$(echo "$CLOSE" | jqget "['exitPrice']") +echo +echo "================ ИТОГ ================" +echo "entryPrice (вход) : $ENTRY" +echo "exitPrice (шлюз вернул) : $EXIT" +echo "цена в момент клика : $P_NOW" +echo +if [ -z "$EXIT" ]; then + echo "ВЕРДИКТ: закрытие НЕ выполнилось (см. ответ выше)." +elif [ "$EXIT" = "$ENTRY" ]; then + echo "ВЕРДИКТ: exit == entry -> ставка осталась open (равные цены, отдельная задача)." + echo " Главное: цена НЕ взята произвольно с момента клика." +else + # Сравниваем exit с ценой, которая была бы при старом поведении + CUR=$(echo "$P_NOW" | jqget "['price']") + if [ -n "$CUR" ]; then + echo "Текущая цена в момент клика: $CUR (в целом виде $(python3 -c "print(int(float('$CUR')*10**8))" 2>/dev/null))" + fi + echo "Смотрим глазами: exitPrice должен соответствовать цене НА МОМЕНТ ЭКСПИРАЦИИ," + echo "а не текущей. Если exitPrice == текущей цене клика — ОКНО ОТКРЫТО, фикс не работает." +fi +echo "закрытие заняло: $((AFTER-BEFORE))с" diff --git a/tools/exploit-check.cjs b/tools/exploit-check.cjs new file mode 100644 index 0000000..9b9bbcc --- /dev/null +++ b/tools/exploit-check.cjs @@ -0,0 +1,144 @@ +/** + * ЖИВАЯ проверка: закрыто ли окно выбора времени закрытия. + * Запускается ВНУТРИ пода шлюза (обращается к 127.0.0.1:8895 и читает ставку из RPC). + * + * Логика доказательства: + * - ставим ставку, запоминаем betTime/expireTime (сырые байты аккаунта Bet) + * - ЖДЁМ существенно дольше экспирации + * - считаем: price_at_expiry (история, ts <= expireTime) и price_now (текущая) + * - закрываем ставку и смотрим, какая цена ушла в транзакцию + * + * ОКНО ЗАКРЫТО, если exitPrice == price_at_expiry и при этом price_now != price_at_expiry + * (т.е. цена взята из прошлого, а не с момента клика). + */ +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 WAIT_MS = Number(process.argv[2] || 75) * 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 priceAtExpiry(expireMs) { + const db = new DatabaseSync(DB); + const row = db.prepare("SELECT price FROM price_history WHERE ts <= ? ORDER BY ts DESC LIMIT 1").get(expireMs); + const cnt = db.prepare("SELECT COUNT(*) c FROM price_history").get().c; + const first = db.prepare("SELECT ts FROM price_history ORDER BY ts ASC LIMIT 1").get(); + db.close(); + return { price: row ? row.price : null, count: cnt, firstTs: first ? first.ts : null }; +} + +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 { + pda: pda.toBase58(), + betTime: Number(d.readBigInt64LE(16)), + expireTime: Number(d.readBigInt64LE(24)), + entryPrice: d.readBigUInt64LE(32).toString(), + exitPrice: d.readBigUInt64LE(40).toString(), + status: d.readBigUInt64LE(56).toString(), + }; +} + +(async () => { + console.log("=== 1. кошелёк ==="); + const w = await api("/wallet", {}); + if (!w.json || !w.json.address) throw new Error("wallet failed: " + JSON.stringify(w)); + const addr = w.json.address; + console.log("address:", addr); + + console.log("=== 2. пополнение ==="); + const f = await api("/faucet/" + addr); + console.log("faucet:", f.status, JSON.stringify(f.json).slice(0, 140)); + + console.log("=== 3. ставка ==="); + const bet = await api("/bet", { side: "UP", amountUnits: "100000000", address: addr }); + console.log("bet:", bet.status, JSON.stringify(bet.json).slice(0, 200)); + if (!bet.json || !bet.json.id) throw new Error("bet failed"); + const id = bet.json.id; + const entryResp = String(bet.json.entryPrice); + + await sleep(1500); + const b0 = await readBet(id); + console.log("\n--- ставка on-chain ---"); + console.log("betTime :", b0.betTime, new Date(b0.betTime * 1000).toISOString()); + console.log("expireTime:", b0.expireTime, new Date(b0.expireTime * 1000).toISOString(), + "(+" + (b0.expireTime - b0.betTime) + "с)"); + console.log("entry(on-chain):", b0.entryPrice, "| entry(/bet ответ):", entryResp); + + console.log("\n=== 4. ПРОБА: закрыть ДО экспирации (должен быть 400) ==="); + const early = await api("/close", { id }); + const stillOpen = await readBet(id); + if (stillOpen.status === "0") { + console.log("ответ:", early.status, JSON.stringify(early.json)); + console.log(early.status === 400 ? " -> ок, отказ до экспирации" : " -> ВНИМАНИЕ: ожидался 400"); + } else { + console.log(" (ставка уже закрылась сама/успела — пропускаем пробу)"); + } + + console.log("\n=== 5. ЖДЁМ " + (WAIT_MS / 1000) + "с (экспирация давно прошла) ==="); + await sleep(WAIT_MS); + + const b1 = await readBet(id); + const expireMs = b1.expireTime * 1000; + const hist = priceAtExpiry(expireMs); + + const nowResp = await api("/price"); + const priceNowStr = nowResp.json ? String(nowResp.json.price) : null; + const priceNowInt = priceNowStr ? Number(priceNowStr.replace(".", "").padEnd(0) === "" ? "0" : priceNowStr.replace(".", "")) : null; + const toInt = (s) => { const [i, fr = ""] = String(s).split("."); return Number(i + fr.padEnd(8, "0").slice(0, 8)); }; + const nowInt = priceNowStr ? toInt(priceNowStr) : null; + + console.log("цена на момент экспирации (из истории):", hist.price, "(строк в истории:", hist.count + ")"); + console.log("текущая цена (момент клика) :", nowInt, "(" + priceNowStr + ")"); + console.log("entry :", b1.entryPrice); + + console.log("\n=== 6. ЗАКРЫТИЕ ==="); + const cl = await api("/close", { id }); + console.log("ответ:", cl.status, JSON.stringify(cl.json)); + const exitPrice = cl.json && cl.json.exitPrice != null ? String(cl.json.exitPrice) : null; + + const b2 = await readBet(id); + console.log("после закрытия: status=" + b2.status, "exitPrice=" + b2.exitPrice); + + console.log("\n================ ВЕРДИКТ ================"); + console.log("price_at_expiry (история) :", hist.price); + console.log("exitPrice (ушло в tx) :", exitPrice); + console.log("price_now (момент клика) :", nowInt); + console.log(); + + if (exitPrice === null) { + console.log("ЗАКРЫТИЕ НЕ ВЫПОЛНИЛОСЬ — см. ответ выше."); + } else if (hist.price === null) { + console.log("НЕТ ДАННЫХ В ИСТОРИИ — проверить запись."); + } else if (String(hist.price) === String(exitPrice)) { + if (String(nowInt) !== String(hist.price)) { + console.log("*** ОКНО ЗАКРЫТО ***"); + console.log("exitPrice взят ИЗ ИСТОРИИ на момент экспирации и НЕ равен цене момента клика."); + console.log("Игрок физически не может выбрать выгодный момент: цена уже зафиксирована прошлым."); + } else { + console.log("СОВПАЛО (но цена не двигалась) — тест слабый, цена сейчас та же. Повторить позже."); + } + } else { + console.log("!!! ОКНО ОТКРЫТО !!! exitPrice (" + exitPrice + ") != цена на момент экспирации (" + hist.price + ")"); + } + if (b2.status === "0") console.log("\n(ставка осталась open — exit==entry: равные цены, отдельная задача)"); +})().catch((e) => { console.error("ERR:", e.message); process.exit(1); }); diff --git a/tools/exploit-decisive.cjs b/tools/exploit-decisive.cjs new file mode 100644 index 0000000..e553808 --- /dev/null +++ b/tools/exploit-decisive.cjs @@ -0,0 +1,128 @@ +/** + * РЕШАЮЩАЯ проверка: цена закрытия берётся из истории на момент экспирации, + * а НЕ из текущей цены в момент клика. + * + * Почему прошлые прогоны были слабыми: сравнивали 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); }); diff --git a/tools/price-history-probe.cjs b/tools/price-history-probe.cjs new file mode 100644 index 0000000..4c2dca8 --- /dev/null +++ b/tools/price-history-probe.cjs @@ -0,0 +1,104 @@ +/** + * Проверка схемы «цена закрытия из истории» на РЕАЛЬНОЙ ставке. + * Читает аккаунт Bet из localnet СЫРЫМИ БАЙТАМИ (без Anchor-обёрток), + * берёт bet_time/expire_time/entry_price и спрашивает Postgres binance-market. + * + * Раскладка Bet (Anchor, после 8-байтового дискриминатора): + * offset 8 : amount u64 + * offset 16 : bet_time i64 + * offset 24 : expire_time i64 + * offset 32 : entry_price u64 + * offset 40 : exit_price u64 + * offset 48 : side u64 + * offset 56 : status u64 + * offset 64 : bettor Pubkey(32) + */ +const { Connection, PublicKey } = require("@solana/web3.js"); +const { Client } = require("pg"); + +const PROGRAM_ID = new PublicKey(process.argv[2] || "9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf"); +const BET_ID = Number(process.argv[3] || 6); +const RPC = "http://192.168.76.181:8899"; + +const u64 = (b, o) => b.readBigUInt64LE(o); +const i64 = (b, o) => b.readBigInt64LE(o); + +(async () => { + const conn = new Connection(RPC, "confirmed"); + + const seeds = Buffer.concat([Buffer.from("bet"), (() => { + const a = Buffer.alloc(8); a.writeBigUInt64LE(BigInt(BET_ID)); return a; + })()]); + const [betPda] = PublicKey.findProgramAddressSync([seeds], PROGRAM_ID); + + const info = await conn.getAccountInfo(betPda, "confirmed"); + if (!info) throw new Error("bet account not found: " + betPda.toBase58()); + const d = info.data; + + const amount = u64(d, 8); + const betTime = Number(i64(d, 16)); + const exp = Number(i64(d, 24)); + const entry = u64(d, 32); + const exitP = u64(d, 40); + const side = u64(d, 48); + const status = u64(d, 56); + const bettor = new PublicKey(d.subarray(64, 96)).toBase58(); + + console.log("=== СТАВКА №" + BET_ID + " (сырые байты аккаунта) ==="); + console.log("PDA :", betPda.toBase58()); + console.log("amount :", amount.toString()); + console.log("bet_time :", betTime, new Date(betTime * 1000).toISOString()); + console.log("expire_time:", exp, new Date(exp * 1000).toISOString(), "(=bet_time+" + (exp - betTime) + "с)"); + console.log("entry_price:", entry.toString(), " exit_price:", exitP.toString()); + console.log("side:", side.toString(), " status:", status.toString(), " bettor:", bettor); + console.log("СЕЙЧАС :", Math.floor(Date.now() / 1000), new Date().toISOString()); + console.log("просрочена на:", Math.floor(Date.now() / 1000) - exp, "с"); + + const c = new Client({ host: "192.168.76.182", port: 5434, user: "postgres", password: "postgres", database: "binance_market" }); + await c.connect(); + + const q = async (label, sql, params) => { + const r = await c.query(sql, params); + console.log("\n--- " + label + " ---"); + if (!r.rows.length) console.log("(ПУСТО — цены нет вовсе)"); + for (const row of r.rows) console.log(JSON.stringify(row)); + return r.rows; + }; + + const bMs = betTime * 1000, eMs = exp * 1000; + + await q("1) сделки в окне ставки [bet_time, expire]", ` + SELECT price::text AS price, event_time, to_timestamp(event_time/1000) AS t + FROM trade_event WHERE symbol='solusdt' AND event_time BETWEEN $1 AND $2 + ORDER BY event_time`, [bMs, eMs]); + + const e1 = await q("2) ENTRY = последняя цена НЕ ПОЗЖЕ bet_time", ` + SELECT price::text AS price, event_time, to_timestamp(event_time/1000) AS t + FROM trade_event WHERE symbol='solusdt' AND event_time <= $1 + ORDER BY event_time DESC LIMIT 1`, [bMs]); + + const e2 = await q("3) EXIT = последняя цена НЕ ПОЗЖЕ expire_time", ` + SELECT price::text AS price, event_time, to_timestamp(event_time/1000) AS t + FROM trade_event WHERE symbol='solusdt' AND event_time <= $1 + ORDER BY event_time DESC LIMIT 1`, [eMs]); + + if (e1[0] && e2[0]) { + const p1 = BigInt(e1[0].price.replace(".", "").padEnd(0)); + const a = e1[0].price, b = e2[0].price; + console.log("\n=== ИТОГ ПО СХЕМЕ ==="); + console.log("entry из истории:", a, "| entry on-chain:", entry.toString(), "(on-chain = price*1e8)"); + console.log("exit из истории:", b); + const toI = (s) => { const [i, f = ""] = s.split("."); return BigInt(i + f.padEnd(8, "0").slice(0, 8)); }; + const ai = toI(a), bi = toI(b); + console.log("entry(int):", ai.toString(), " exit(int):", bi.toString()); + console.log("вердикт по контракту:", bi > ai ? "UP выиграл" : bi < ai ? "DOWN выиграл" : "РАВНО — ставка зависает (текущий контракт не финализирует)"); + } + + await q("4) ЧТО БЫЛО БЫ при 'ждать выгодной цены' (после экспирации, +2 мин)", ` + SELECT min(price)::text AS min_p, max(price)::text AS max_p, count(*) AS n, + min(to_timestamp(event_time/1000)) AS с, max(to_timestamp(event_time/1000)) AS по + FROM trade_event WHERE symbol='solusdt' AND event_time > $1 AND event_time <= $2`, + [eMs, (exp + 120) * 1000]); + + await c.end(); +})().catch((e) => { console.error("ERR:", e.message); process.exit(1); });