feat(relayer): цена закрытия из истории на момент экспирации — окно выбора времени закрыто

Проблема: handleClose брал getPrice() (текущую цену), поэтому игрок мог
подождать выгодного момента и закрыть ставку тогда, когда ему надо
(эксплуатировалось вживую: ставка висела open >2 мин после экспирации 15с).

Решение (только шлюз, контракт не тронут):
- src/priceHistory.ts (новый) — локальная история цены на встроенном node:sqlite
  (Node 22, без новых зависимостей). Запись только при изменении цены
  (~300 строк/сутки вместо 92k тиков), ретеншен 24ч, prune одним DELETE.
- natsPrice: в PriceTick добавлено eventTime (время сделки у Binance) — источник
  истины по времени; ts не тронут.
- config: SMART_PRICE_HISTORY_PATH (/app/data/price-history.db),
  SMART_PRICE_RETENTION_HOURS (24).
- gateway: каждый тик пишется в историю; handleClose берёт
  history.priceAtOrBefore(expireTime*1000) — последняя цена НЕ ПОЗЖЕ экспирации.
  Отказ 400 'bet not expired yet' до экспирации, 400 на уже закрытую,
  503 если истории нет (не выдумываем цену).
- helm: env SMART_PRICE_HISTORY_PATH / SMART_PRICE_RETENTION_HOURS.
- check.ts: 10 проверок истории, включая ключевую «НЕ отдаёт цену позже момента».

Верификация: typecheck 0 ошибок; npm test 34/34; мутационная приёмка — подмена
<= на >=/ASC роняет 4 проверки, снятие дедупа и смена ретеншена роняют свои.
Живой стенд: exitPrice = цена на момент экспирации (9977000000), тогда как
текущая цена в момент клика была 9976000000 — взята история, не 'сейчас'.
This commit is contained in:
Caffeine
2026-09-13 14:46:47 +03:00
parent 5ab6c9def5
commit 0b136c6502
7 changed files with 302 additions and 4 deletions
+4
View File
@@ -69,6 +69,10 @@ spec:
value: {{ .Values.smart.expirySeconds | quote }}
- name: SMART_START_BALANCE
value: {{ .Values.smart.startBalance | quote }}
- name: SMART_PRICE_HISTORY_PATH
value: {{ .Values.smart.priceHistory | quote }}
- name: SMART_PRICE_RETENTION_HOURS
value: {{ .Values.smart.priceRetentionHours | quote }}
- name: GW_PORT
value: {{ .Values.port | quote }}
- name: GW_BETTOR_KEYSTORE
+2
View File
@@ -26,6 +26,8 @@ smart:
multiplierBps: 13000
expirySeconds: 15
startBalance: 10000000000
priceHistory: '/app/data/price-history.db'
priceRetentionHours: 24
nats:
url: 'nats://192.168.88.93:4222'
+85
View File
@@ -13,11 +13,15 @@
*/
import { Keypair, PublicKey } from "@solana/web3.js";
import BN from "bn.js";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { getAssociatedTokenAddress, TOKEN_PROGRAM_ID } from "@solana/spl-token";
import idl from "../idl/smart_updown.json";
import { globalPda, betPda, vaultPda } from "./pda";
import { makeConnection, makeProgram } from "./program";
import { faucetDenyReason, parseMode } from "./faucetGate";
import { DEFAULT_RETENTION_MS, PriceHistory } from "./priceHistory";
const ok = (msg: string) => console.log(`ok: ${msg}`);
let failed = 0;
@@ -164,6 +168,87 @@ async function main(): Promise<void> {
"parseMode: неизвестное значение = release"
);
// 4. PriceHistory (SQLite): пишется каждый NATS-тик цены, читается в
// /close для фиксированной цены на момент expire_time. Тесты гоняются
// на РЕАЛЬНОЙ БД в /tmp (файл удаляется до и после).
{
const dbPath = path.join(
os.tmpdir(),
`price-history-check-${process.pid}-${Date.now()}.db`
);
fs.rmSync(dbPath, { force: true });
const h = new PriceHistory(dbPath);
try {
// Тест 1: пишем точку, читаем её же.
h.record(1000, 111);
check(
h.priceAtOrBefore(1000) === 111,
"priceHistory: пишет точку и читает её"
);
// Тест 4: до самой первой точки — пусто.
check(
h.priceAtOrBefore(999) === null,
"priceHistory: до первой точки = null"
);
// Тесты 2/3/5: две точки 1000→111 и 5000→222 — разные режимы выборки.
h.record(5000, 222);
check(
h.priceAtOrBefore(3000) === 111,
"priceHistory: отдаёт предыдущую точку между записями"
);
check(
h.priceAtOrBefore(4999) === 111,
"priceHistory: НЕ отдаёт цену позже момента"
);
check(
h.priceAtOrBefore(5000) === 222,
"priceHistory: ровно на точке отдаёт эту точку"
);
// Тест 6: повтор той же цены (последняя записанная = 222) — отказ.
const before6 = h.count();
const r6 = h.record(6000, 222);
check(
r6 === false && h.count() === before6,
"priceHistory: повтор той же цены не пишет строку"
);
// Тест 7: изменившаяся цена — пишется.
const before7 = h.count();
const r7 = h.record(7000, 333);
check(
r7 === true && h.count() === before7 + 1,
"priceHistory: изменившаяся цена пишет строку"
);
// Тест 8: 3 точки (1000/5000/7000), prune(now=7000, retention=1000).
// cutoff = 7000 - 1000 = 6000 → удаляются 1000 и 5000, остаётся 7000
// (ровно 2 удалено, ровно сколько старше).
const removed = h.prune(7000, 1000);
check(
removed === 2 && h.priceAtOrBefore(3000) === null,
"priceHistory: prune удаляет старше ретеншена"
);
// Тест 9: после prune свежая точка (7000 → 333) всё ещё читается.
check(
h.priceAtOrBefore(7000) === 333,
"priceHistory: prune не трогает свежие"
);
} finally {
h.close();
fs.rmSync(dbPath, { force: true });
}
}
// Тест 10: дефолтный ретеншен = 24 часа.
check(
DEFAULT_RETENTION_MS === 24 * 60 * 60 * 1000,
"priceHistory: ретеншен по умолчанию = 24 часа"
);
if (failed > 0) {
console.error(`\ncheck: ${failed} проверок ПРОВАЛЕНО`);
process.exit(1);
+9
View File
@@ -46,6 +46,10 @@ export interface Config {
startBalance: number;
multiplierBps: number;
expirySeconds: number;
/** Путь к файлу локальной истории цены (SQLite). */
priceHistoryPath: string;
/** Сколько миллисекунд хранить историю цены (ретеншен). Дефолт — 24 часа. */
priceHistoryRetentionMs: number;
}
function env(name: string, fallback: string): string {
@@ -80,6 +84,11 @@ export function loadConfig(): Config {
maxAmount: envNum("SMART_MAX_AMOUNT", 10_000_000_000), // 10 000 фантиков
multiplierBps: envNum("SMART_MULTIPLIER_BPS", 13_000), // 1.3x
expirySeconds: envNum("SMART_EXPIRY_SECONDS", 15),
// Локальная история цены (SQLite): цена пишется на каждом тике при
// изменении, чтобы при /close отдавать цену на момент expire_time ставки,
// а не текущую (иначе игрок ждёт выгодного момента и кликает тогда).
priceHistoryPath: env("SMART_PRICE_HISTORY_PATH", "/app/data/price-history.db"),
priceHistoryRetentionMs: envNum("SMART_PRICE_RETENTION_HOURS", 24) * 60 * 60 * 1000,
// Стартовый баланс фантиков, который локальный релейер mintTo'ит беттору
// (аналог SOL-airdrop). По умолчанию равен maxAmount — хватает на максимум ставки.
startBalance: envNum("SMART_START_BALANCE", 10_000_000_000),
+73 -3
View File
@@ -52,8 +52,14 @@ import {
readGlobal,
} from "./ops";
import { betPda } from "./pda";
import { PriceHistory } from "./priceHistory";
import { makeConnection, makeProgram } from "./program";
import { currentPrice, onPrice, connectNats } from "./natsPrice";
import {
currentPrice,
onPrice,
connectNats,
priceStringToInt,
} from "./natsPrice";
import { currentPriceHuman, getPrice } from "./priceSource";
import { faucetDenyReason } from "./faucetGate";
@@ -144,6 +150,9 @@ interface Ctx {
program: Program<SmartUpdown>;
decimals: number;
keystore: BettorKeystore;
/** Локальная история цены (SQLite). Используется в /close для честной
* цены на момент `expire_time` ставки, а не текущей. */
history: PriceHistory;
/** Кэш getGenesisHash() — RPC дёргаем один раз при первом обращении к /faucet. */
genesisHash?: string;
}
@@ -338,7 +347,32 @@ async function handleClose(ctx: Ctx, body: Record<string, unknown>): Promise<Htt
return { status: 404, body: { error: `bet #${id.toString()} not found` } };
}
const exitPrice = await getPrice();
// Цена закрытия = последняя цена в локальной истории НЕ ПОЗЖЕ
// expire_time ставки. Не «текущая», не «ближайшая» — иначе игрок
// ждёт выгодного момента и кликает тогда. Момент клика больше
// ничего не значит: цена была зафиксирована историей раньше.
const currentStatus = bet.status.toNumber();
if (currentStatus !== STATUS_OPEN) {
return { status: 400, body: { error: "bet is already closed" } };
}
const expireTsMs = Number(bet.expireTime) * 1000;
if (Date.now() < expireTsMs) {
const waitSec = Math.ceil((expireTsMs - Date.now()) / 1000);
return {
status: 400,
body: { error: `bet not expired yet: wait ${waitSec}s` },
};
}
const exitPrice = ctx.history.priceAtOrBefore(expireTsMs);
if (exitPrice === null) {
return {
status: 503,
body: {
error:
"price history has no data for the expiry moment; cannot settle safely",
},
};
}
const tx = await closeBet(ctx.program, id, exitPrice, ctx.admin, bet.bettor, ctx.cfg.tokenMint);
const after = await ctx.program.account.bet.fetchNullable(betPda(programId, id));
@@ -541,7 +575,29 @@ async function main(): Promise<void> {
process.env.GW_BETTOR_KEYSTORE ?? path.join(__dirname, "..", ".gateway-bettors.json")
);
const ctx: Ctx = { cfg, admin, connection, program, decimals, keystore };
// Локальная история цены (SQLite): пишется каждый тик из NATS (только при
// изменении цены), читается в handleClose для фиксированной цены на момент
// expire_time ставки. Ретеншен чистится периодически ОДНИМ DELETE.
const history = new PriceHistory(cfg.priceHistoryPath);
try {
const removed = history.prune(Date.now(), cfg.priceHistoryRetentionMs);
if (removed > 0) {
console.log(`gateway: price history pruned ${removed} stale row(s) at startup`);
}
} catch (e) {
console.error(
`gateway: initial price-history prune failed: ${e instanceof Error ? e.message : String(e)}`
);
}
setInterval(() => {
try {
history.prune(Date.now(), cfg.priceHistoryRetentionMs);
} catch {
// тихий фоновый prune — ронять процесс нельзя
}
}, 10 * 60 * 1000);
const ctx: Ctx = { cfg, admin, connection, program, decimals, keystore, history };
// Прелоад как в index.ts: ваулт-фантики на localnet (если включён airdrop).
// Не блокируем старт сервера: подтверждение tx может вешаться на 30с,
@@ -674,6 +730,20 @@ async function main(): Promise<void> {
// Рассылка каждого нового NATS-тика по всем подключённым WS.
onPrice(broadcastPrice(wsClients));
// Параллельно пишем тик в локальную историю цены (только при изменении
// цены — `record` сам это разруливает и вернёт false на дубликат).
// Ошибку записи НЕ роняем в процесс — это фоновая телеметрия,
// шлюз без неё работает.
onPrice((p) => {
try {
history.record(p.eventTime, priceStringToInt(p.price));
} catch (e) {
console.error(
`gateway: price-history record failed: ${e instanceof Error ? e.message : String(e)}`
);
}
});
// Heartbeat: пинг раз в 30с, нет pong 2 цикла — terminate.
const heartbeat = setInterval(() => {
for (const c of wsClients) {
+13 -1
View File
@@ -17,6 +17,14 @@ export interface PriceTick {
price: string;
/** Unix ms: берётся из поля `ts` payload'а, иначе Date.now(). */
ts: number;
/**
* Unix ms: время СДЕЛКИ у Binance (поле `eventTime` payload'а).
* Источник истины по времени: ставка в шлюзе закрывается по цене
* на момент `expire_time`, а не по текущей — поэтому нужно время
* сделки, а не локальная метка приёма. Fallback на `ts`, если
* `eventTime` отсутствует или не число.
*/
eventTime: number;
}
let nc: NatsConnection | null = null;
@@ -87,7 +95,11 @@ export async function connectNats(url: string, subject: string): Promise<void> {
typeof obj.ts === "number" && Number.isFinite(obj.ts)
? (obj.ts as number)
: Date.now();
const tick: PriceTick = { price: priceRaw, ts: tsNum };
const eventTime =
typeof obj.eventTime === "number" && Number.isFinite(obj.eventTime)
? (obj.eventTime as number)
: tsNum;
const tick: PriceTick = { price: priceRaw, ts: tsNum, eventTime };
if (!firstLogged) {
console.log(`natsPrice: first price ${priceRaw} (ts=${tsNum})`);
firstLogged = true;
+116
View File
@@ -0,0 +1,116 @@
/**
* Локальная история цены SOL (SQLite, node:sqlite).
*
* Используется шлюзом, чтобы при `close_bet` подставлять в транзакцию
* **зафиксированную заранее** цену на момент `expire_time` ставки, а не
* текущую с биржи. Это закрывает окно «подождать выгодного момента и
* кликнуть позже»: цена фиксируется историей, момент клика больше
* ничего не значит.
*
* Схема (одна таблица, PK = `ts` = unix-миллисекунды сделки):
*
* ts INTEGER PRIMARY KEY -- время СДЕЛКИ (eventTime Binance), unix ms
* price INTEGER NOT NULL -- цена × 1e8, целое (без float)
*
* Запись идёт **только при изменении цены** (≈300 строк/сутки вместо
* 92k тиков), ретеншен по умолчанию — 24 часа, чистится периодически
* `DELETE FROM price_history WHERE ts < ?` одним запросом.
*/
import { DatabaseSync } from "node:sqlite";
/** Ретеншен по умолчанию: 24 часа (в миллисекундах). */
export const DEFAULT_RETENTION_MS: number = 24 * 60 * 60 * 1000;
export class PriceHistory {
private readonly db: DatabaseSync;
private readonly stmtLast: ReturnType<DatabaseSync["prepare"]>;
private readonly stmtLastPrice: ReturnType<DatabaseSync["prepare"]>;
private readonly stmtAtOrBefore: ReturnType<DatabaseSync["prepare"]>;
private readonly stmtInsert: ReturnType<DatabaseSync["prepare"]>;
private readonly stmtPrune: ReturnType<DatabaseSync["prepare"]>;
private readonly stmtCount: ReturnType<DatabaseSync["prepare"]>;
constructor(filePath: string) {
this.db = new DatabaseSync(filePath);
// WAL: параллельные читатели не блокируют пишущего тик-цикл.
this.db.exec("PRAGMA journal_mode=WAL");
// PK(ts) уже индекс для `WHERE ts <= ?` и `ORDER BY ts DESC LIMIT 1`,
// отдельный индекс не нужен.
this.db.exec(`
CREATE TABLE IF NOT EXISTS price_history (
ts INTEGER PRIMARY KEY,
price INTEGER NOT NULL
)
`);
this.stmtLast = this.db.prepare("SELECT ts FROM price_history ORDER BY ts DESC LIMIT 1");
this.stmtLastPrice = this.db.prepare(
"SELECT price FROM price_history ORDER BY ts DESC LIMIT 1"
);
this.stmtAtOrBefore = this.db.prepare(
"SELECT price FROM price_history WHERE ts <= ? ORDER BY ts DESC LIMIT 1"
);
this.stmtInsert = this.db.prepare(
"INSERT OR REPLACE INTO price_history (ts, price) VALUES (?, ?)"
);
this.stmtPrune = this.db.prepare("DELETE FROM price_history WHERE ts < ?");
this.stmtCount = this.db.prepare("SELECT COUNT(*) AS c FROM price_history");
}
/**
* Записать точку. Пишем ТОЛЬКО если цена отличается от последней записанной
* — иначе возвращаем false и в БД ничего не добавляем (≈300 строк/сутки
* вместо 92k тиков). Первая запись всегда пишется. `tsMs` пишется как
* есть (PK; INSERT OR REPLACE на случай коллизии `ts`).
*
* Возвращает true, если строка реально добавлена.
*/
record(tsMs: number, priceInt: number): boolean {
if (!Number.isFinite(tsMs) || !Number.isFinite(priceInt)) return false;
const prev = this.stmtLastPrice.get() as { price: number } | undefined;
if (prev !== undefined && prev.price === priceInt) return false;
this.stmtInsert.run(tsMs, priceInt);
return true;
}
/**
* Последняя цена, записанная НЕ ПОЗЖЕ `tsMs` (ts <= tsMs).
* Или null, если такой нет.
*
* Именно этот метод определяет честность расчёта: цена ПОЗЖЕ момента
* не должна возвращаться никогда. Поэтому `ORDER BY ts DESC` + `LIMIT 1`
* под условием `ts <= ?` — никаких «ближайших».
*/
priceAtOrBefore(tsMs: number): number | null {
if (!Number.isFinite(tsMs)) return null;
const row = this.stmtAtOrBefore.get(tsMs) as { price: number } | undefined;
return row !== undefined ? row.price : null;
}
/** Unix-ms последней записи, или null если истории нет. */
lastTs(): number | null {
const row = this.stmtLast.get() as { ts: number } | undefined;
return row !== undefined ? row.ts : null;
}
/**
* Удалить записи старше `(nowMs - retentionMs)`. Одним запросом
* `DELETE FROM price_history WHERE ts < ?`. Возвращает число удалённых строк.
*/
prune(nowMs: number, retentionMs: number = DEFAULT_RETENTION_MS): number {
if (!Number.isFinite(nowMs) || !Number.isFinite(retentionMs)) return 0;
const cutoff = nowMs - retentionMs;
const res = this.stmtPrune.run(cutoff);
return Number(res.changes);
}
/** Сколько строк в таблице (для диагностики/тестов). */
count(): number {
const row = this.stmtCount.get() as { c: number } | undefined;
return row !== undefined ? Number(row.c) : 0;
}
/** Закрыть БД. */
close(): void {
this.db.close();
}
}