docs(security): SEC-1, SEC-2, SEC-4 закрыты по сути — цена закрытия из истории
Механизм: шлюз берёт цену закрытия из локальной истории на момент 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). Остаток: в контракте верхней границы окна по-прежнему нет (второй слой, не критично); серверное авто-закрытие — следующая итерация.
This commit is contained in:
+10
-5
@@ -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` — отдельный блок «Кошелёк и пополнение» с кнопками «Создать кошелёк» / «Пополнить кошелёк».
|
||||
|
||||
@@ -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))с"
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user