feat: HTTP-шлюз поверх релейера (POST /bet,/close; GET /balance,/state) для пульта без Phantom

This commit is contained in:
Caffeine
2026-09-10 04:33:34 +03:00
parent f937c8993d
commit f426f58778
6 changed files with 551 additions and 19 deletions
+4 -1
View File
@@ -1,2 +1,5 @@
node_modules/ node_modules/
dist/ dist/
# custodial-ключи бетторов HTTP-шлюза (gateway.ts) — НЕ коммитить
.gateway-bettors.json
+43
View File
@@ -0,0 +1,43 @@
# TASK — HTTP-шлюз поверх релейера (чтобы пульт ставил ставки без Phantom)
## Контекст
`smart-updown-token` — контракт на SPL-токене (фантики) + релейер (`relayer/`), который
**уже умеет** на localnet (RPC 8890): `initialize`, `createBet`, `closeBet`, фандинг
ваулта и беттора. Релейер — CLI скрипт (`npm run run`/`--once`), автономный (сам генерирует
ставки через frontStub), **без HTTP-входа**.
Хочется: статичный веб-пульт (`test-front/index.html`, лежит на `game2.binom.pw`) мог
присылать команду «поставить фантиками от адреса X» на маленький HTTP-шлюз, который
вызывает функции релейера и исполняет ставку на localnet — БЕЗ Phantom.
## РЕАЛИЗАЦИЯ (выполни)
Создай в `relayer/` HTTP-шлюз `gateway.ts` (node http, БЕЗ express, только stdlib),
который:
- `POST /bet {address, side, amountUnits}`:
- derives ATA беттора от `address` и mint
- вызывает существующую функцию `createBet` (не переписывай уже готовую логику,
переиспользуй из `ops.ts`/`index.ts`, если нужно — слегка вынеси)
- фандит беттора если надо (как в `fundBettor`)
- возвращает `{tx, id}` (id нового bet) — id вычислить из Global.counter
- `POST /close {id}`: вызывает `closeBet`
- `GET /balance/{address}`: возвращает `{sol, tokenUnits, tokenHuman}` (SOL-баланс +
токен-баланс по ATA беттора). Токен-баланс — через `getTokenAccountBalance` по ATA.
- `GET /state`: вернуть Global (min/max/expiry, counter) — реюз там же.
- CORS: `Access-Control-Allow-Origin: *` (фронт на другом домене/порту может).
- Порт — из env `GW_PORT` (default 8895). Слушать на 0.0.0.0.
- Логирование запросов в stdout.
Запуск: `npm run gateway` → `ts-node src/gateway.ts`. Проверь `npm run typecheck` и
`npm test` — не ломать существующий релейер.
## НЕ делать
- Не менять контракт `program/`.
- Не переписывать саму логику ставки — только дёргать готовые функции.
- Не коммитить.
## Гейт
- `npm run typecheck` 0 ошибок, `npm test` зелёный.
- Шлюз стартует и отвечает на `GET /state` и `POST /bet` (проверь на localnet 8890:
init уже выполнен).
Отчитайся: какие файлы создал/изменил, как запускать, результат smoke-теста.
+2 -1
View File
@@ -9,7 +9,8 @@
"test": "ts-node src/check.ts", "test": "ts-node src/check.ts",
"init": "ts-node src/index.ts --init --once", "init": "ts-node src/index.ts --init --once",
"run": "ts-node src/index.ts --once", "run": "ts-node src/index.ts --once",
"loop": "ts-node src/index.ts" "loop": "ts-node src/index.ts",
"gateway": "ts-node src/gateway.ts"
}, },
"dependencies": { "dependencies": {
"@anchor-lang/core": "^1.0.0", "@anchor-lang/core": "^1.0.0",
+471
View File
@@ -0,0 +1,471 @@
/**
* HTTP-шлюз поверх релейера: пульт ставит фантики БЕЗ Phantom.
*
* Шлюз — custodial: keypair'ы бетторов хранит у себя (keystore-файл
* `.gateway-bettors.json` рядом с relayer/, в .gitignore) и подписывает
* create_bet от их имени. Для /close ключи не нужны — подписывает
* relayer (admin). Все сетевые операции — переиспользованные функции
* из ops.ts (createBet / closeBet / readGlobal / fundBettor / ...).
*
* Эндпоинты (CORS: Access-Control-Allow-Origin: *):
* GET /state — Global: counter, min/max, multiplier, expiry, paused, vault
* POST /bet {address?, side, amountUnits}
* — address: custodial-беттор шлюза; если не задан — шлюз
* генерирует новый и регистрирует в keystore.
* Ответ: {tx, id, bettor, entryPrice}
* POST /close {id} — close_bet по текущей цене (подпись relayer)
* GET /balance/{address} — {sol, solLamports, tokenUnits, tokenHuman}
*
* Запуск: npm run gateway (ts-node src/gateway.ts)
* Env: GW_PORT (default 8895), GW_BETTOR_KEYSTORE, SMART_* (как у релейера).
*/
import http from "http";
import * as fs from "fs";
import * as path from "path";
import { Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import { getAssociatedTokenAddress, getMint } from "@solana/spl-token";
import BN from "bn.js";
import type { Program } from "@anchor-lang/core";
import { loadConfig, loadKeypair, SIDE_DOWN, SIDE_UP } from "./config";
import {
closeBet,
createBet,
ensureBettorTokenAccount,
ensureVaultFunded,
fundBettor,
readGlobal,
} from "./ops";
import { betPda } from "./pda";
import { makeConnection, makeProgram } from "./program";
import { getPrice } from "./priceSource";
import type { SmartUpdown } from "../idl/smart_updown";
const STATUS_OPEN = 0;
const STATUS_PAYOUT_DONE = 1;
const STATUS_HOUSE_WON = 2;
const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL);
const MAX_BODY_BYTES = 100 * 1024;
/** Токен-юниты -> "N.NNNNNN" по decimals (дефолт 6, как в контракте). */
function humanize(units: string | number | bigint, decimals: number): string {
const n = BigInt(units);
const sign = n < 0n ? "-" : "";
const abs = n < 0n ? -n : n;
const pow = 10n ** BigInt(decimals);
return `${sign}${abs / pow}.${(abs % pow).toString().padStart(decimals, "0")}`;
}
/**
* Custodial keystore бетторов шлюза: адрес -> keypair.
* Файл — JSON { "<pubkey>": [секретные байты...] }, права 0600.
*/
class BettorKeystore {
private pairs = new Map<string, Keypair>();
private readonly file: string;
constructor(file: string) {
this.file = file;
if (fs.existsSync(file)) {
try {
const raw = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, number[]>;
for (const [addr, secret] of Object.entries(raw)) {
this.pairs.set(addr, Keypair.fromSecretKey(Uint8Array.from(secret)));
}
console.log(`gateway: loaded ${this.pairs.size} bettor key(s) from ${file}`);
} catch (e) {
console.error(`gateway: keystore ${file} unreadable, starting empty: ${e}`);
}
}
}
get(address: string): Keypair | undefined {
return this.pairs.get(address);
}
/** Новый custodial-кошелёк: генерирует keypair, регистрирует под СВОИМ pubkey. */
generate(): Keypair {
const kp = Keypair.generate();
this.pairs.set(kp.publicKey.toBase58(), kp);
this.persist();
console.log(`gateway: generated custodial bettor keypair ${kp.publicKey.toBase58()}`);
return kp;
}
private persist(): void {
const data: Record<string, number[]> = {};
for (const [addr, kp] of this.pairs) data[addr] = Array.from(kp.secretKey);
fs.writeFileSync(this.file, JSON.stringify(data, null, 2), { mode: 0o600 });
}
}
interface Ctx {
cfg: ReturnType<typeof loadConfig>;
admin: Keypair;
connection: ReturnType<typeof makeConnection>;
program: Program<SmartUpdown>;
decimals: number;
keystore: BettorKeystore;
}
interface HttpJson {
status: number;
body: Record<string, unknown>;
}
function parsePubkey(value: unknown): PublicKey | null {
if (typeof value !== "string") return null;
try {
return new PublicKey(value.trim());
} catch {
return null;
}
}
function normalizeSide(value: unknown): number | null {
if (value === 0 || value === "0" || value === "UP" || value === "up" || value === "Up") {
return SIDE_UP;
}
if (value === 1 || value === "1" || value === "DOWN" || value === "down" || value === "Down") {
return SIDE_DOWN;
}
return null;
}
function parseAmountUnits(value: unknown): number | null {
const n = typeof value === "string" ? Number(value) : value;
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) return null;
return n;
}
async function handleState(ctx: Ctx): Promise<HttpJson> {
const g = await readGlobal(ctx.program, ctx.program.programId, ctx.cfg.tokenMint);
if (!g) {
return {
status: 409,
body: { error: "Contract not initialized (relayer: npm run init)" },
};
}
return {
status: 200,
body: {
counter: g.counter.toString(),
nextBetId: g.counter.toString(),
minAmount: g.minAmount.toString(),
maxAmount: g.maxAmount.toString(),
minAmountHuman: humanize(g.minAmount.toString(), ctx.decimals),
maxAmountHuman: humanize(g.maxAmount.toString(), ctx.decimals),
multiplierBps: g.multiplierBps.toString(),
expirySeconds: g.expirySeconds.toString(),
paused: g.paused.toNumber(),
admin: g.admin.toBase58(),
relayer: g.relayer.toBase58(),
vaultBalance: g.vaultBalance,
vaultBalanceHuman: humanize(g.vaultBalance, ctx.decimals),
},
};
}
async function handleBet(ctx: Ctx, body: Record<string, unknown>): Promise<HttpJson> {
const side = normalizeSide(body.side);
if (side === null) {
return { status: 400, body: { error: 'side must be 0/"UP" or 1/"DOWN"' } };
}
const amountUnits = parseAmountUnits(body.amountUnits);
if (amountUnits === null) {
return {
status: 400,
body: { error: "amountUnits must be a positive integer (token base units)" },
};
}
const g = await readGlobal(ctx.program, ctx.program.programId, ctx.cfg.tokenMint);
if (!g) {
return {
status: 409,
body: { error: "Contract not initialized (relayer: npm run init)" },
};
}
if (amountUnits < g.minAmount.toNumber() || amountUnits > g.maxAmount.toNumber()) {
return {
status: 400,
body: {
error: `amountUnits ${amountUnits} out of bounds [${g.minAmount.toString()}, ${g.maxAmount.toString()}]`,
},
};
}
// Кому принадлежат фантики: либо зарегистрированный custodial-адрес,
// либо новый кошелёк, который шлюз только что сгенерировал.
let bettor: PublicKey;
let kp: Keypair | undefined;
if (body.address !== undefined && body.address !== null && body.address !== "") {
const pub = parsePubkey(body.address);
if (!pub) {
return { status: 400, body: { error: "address is not a valid Solana pubkey" } };
}
kp = ctx.keystore.get(pub.toBase58());
if (!kp) {
return {
status: 400,
body: {
error: `bettor ${pub.toBase58()} is not registered in the gateway keystore ` +
"(send a bet without address to let the gateway create the wallet)",
},
};
}
bettor = pub;
} else {
kp = ctx.keystore.generate();
bettor = kp.publicKey;
}
// Фандинг как в релейере: SOL под ренту/комиссию + фантики в ATA.
if (ctx.cfg.allowAirdrop) {
await fundBettor(ctx.connection, bettor);
await ensureBettorTokenAccount(
ctx.connection,
ctx.admin,
ctx.cfg.tokenMint,
bettor,
BigInt(ctx.cfg.startBalance)
);
}
const entryPrice = await getPrice();
const { id, tx } = await createBet(ctx.program, ctx.cfg.tokenMint, kp, {
side,
amount: amountUnits,
entryPrice,
});
return {
status: 200,
body: {
tx,
id: id.toString(),
bettor: bettor.toBase58(),
entryPrice,
},
};
}
// Глобальный mutex: две параллельные ставки на один counter — BetIdMismatch.
let betChain: Promise<unknown> = Promise.resolve();
function withBetLock<T>(fn: () => Promise<T>): Promise<T> {
const run = betChain.then(fn);
betChain = run.then(
() => undefined,
() => undefined
);
return run;
}
async function handleClose(ctx: Ctx, body: Record<string, unknown>): Promise<HttpJson> {
let id: BN;
try {
id = new BN(String(body.id));
} catch {
return { status: 400, body: { error: "id must be an integer" } };
}
const programId = ctx.program.programId;
const bet = await ctx.program.account.bet.fetchNullable(betPda(programId, id));
if (!bet) {
return { status: 404, body: { error: `bet #${id.toString()} not found` } };
}
const exitPrice = await getPrice();
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));
const status = after ? after.status.toNumber() : STATUS_OPEN;
const bodyOut: Record<string, unknown> = {
tx,
id: id.toString(),
exitPrice,
status,
statusName:
status === STATUS_PAYOUT_DONE
? "payout_done"
: status === STATUS_HOUSE_WON
? "house_won"
: "open",
bettor: bet.bettor.toBase58(),
};
if (status === STATUS_OPEN) {
bodyOut.note =
"exit==entry: ставка осталась open (контракт не финализирует при равной цене) — повторите /close позже";
}
return { status: 200, body: bodyOut };
}
async function handleBalance(ctx: Ctx, address: string): Promise<HttpJson> {
const pub = parsePubkey(address);
if (!pub) {
return { status: 400, body: { error: "address is not a valid Solana pubkey" } };
}
const [lamports, tokenUnits] = await Promise.all([
ctx.connection.getBalance(pub),
(async (): Promise<string> => {
try {
const ata = await getAssociatedTokenAddress(ctx.cfg.tokenMint, pub);
const bal = await ctx.connection.getTokenAccountBalance(ata);
return bal.value.amount || "0";
} catch {
return "0";
}
})(),
]);
return {
status: 200,
body: {
sol: lamports / LAMPORTS_PER_SOL_NUM,
solLamports: lamports,
tokenUnits,
tokenHuman: humanize(tokenUnits, ctx.decimals),
},
};
}
function readJsonBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => {
size += c.length;
if (size > MAX_BODY_BYTES) {
reject(new Error("body too large"));
req.destroy();
return;
}
chunks.push(c);
});
req.on("end", () => {
if (chunks.length === 0) {
resolve({});
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>);
} catch {
const err = new Error("invalid JSON body") as Error & { clientError?: boolean };
err.clientError = true;
reject(err);
}
});
req.on("error", reject);
});
}
async function main(): Promise<void> {
const cfg = loadConfig();
const admin = loadKeypair(cfg.adminKeypairPath);
const connection = makeConnection(cfg.rpcUrl);
const program = makeProgram(connection, admin);
let decimals = 6;
try {
decimals = (await getMint(connection, cfg.tokenMint)).decimals;
} catch {
// mint ещё не создан — дефолт из ТЗ контракта
}
const keystore = new BettorKeystore(
process.env.GW_BETTOR_KEYSTORE ?? path.join(__dirname, "..", ".gateway-bettors.json")
);
const ctx: Ctx = { cfg, admin, connection, program, decimals, keystore };
// Прелоад как в index.ts: ваулт-фантики на localnet (если включён airdrop).
// Не блокируем старт сервера: подтверждение tx может вешаться на 30с,
// а /bet сам добудет, чего не хватает.
if (cfg.allowAirdrop) {
ensureVaultFunded(connection, program, admin, cfg)
.then(() => console.log("gateway: vault pre-funded at startup"))
.catch((e) => console.error(`gateway: vault pre-funding skipped: ${e}`));
}
const g0 = await readGlobal(program, program.programId, cfg.tokenMint).catch(() => null);
if (!g0) {
console.log("gateway: contract NOT initialized — /state будет 409 до `npm run init`");
}
const port = Number(process.env.GW_PORT ?? "8895");
const server = http.createServer((req, res) => {
const started = Date.now();
const cors = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
for (const [k, v] of Object.entries(cors)) res.setHeader(k, v);
const finish = (status: number, body?: unknown) => {
if (body !== undefined) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
} else {
res.writeHead(status);
res.end();
}
const url = req.url ?? "/";
console.log(`gateway: ${req.method} ${url} -> ${status} (${Date.now() - started}ms)`);
};
(async () => {
const reqUrl = new URL(req.url ?? "/", "http://gateway.local");
const p = reqUrl.pathname;
const m = req.method ?? "GET";
if (m === "OPTIONS") {
finish(204);
return;
}
let result: HttpJson;
try {
if (m === "GET" && p === "/state") {
result = await handleState(ctx);
} else if (m === "POST" && p === "/bet") {
const body = await readJsonBody(req);
result = await withBetLock(() => handleBet(ctx, body));
} else if (m === "POST" && p === "/close") {
const body = await readJsonBody(req);
result = await handleClose(ctx, body);
} else if (m === "GET" && p.startsWith("/balance/")) {
const address = decodeURIComponent(p.slice("/balance/".length));
result = await handleBalance(ctx, address);
} else {
result = { status: 404, body: { error: "not found" } };
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const isClient = e instanceof Error && !!(e as unknown as { clientError?: boolean }).clientError;
result = { status: isClient ? 400 : 500, body: { error: msg } };
}
if (result.status === 204) {
finish(204);
return;
}
finish(result.status, result.body);
})().catch((e) => {
console.error("gateway: request failed:", e);
try {
finish(500, { error: "internal error" });
} catch {
// соединение уже разорвано
}
});
});
server.listen(port, "0.0.0.0", () => {
console.log(`gateway: listening on 0.0.0.0:${port} (RPC ${cfg.rpcUrl}, mint ${cfg.tokenMint.toBase58()})`);
console.log(`gateway: endpoints: GET /state, POST /bet, POST /close, GET /balance/{address}`);
});
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+2 -13
View File
@@ -1,4 +1,4 @@
import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"; import { PublicKey } from "@solana/web3.js";
import { getMint } from "@solana/spl-token"; import { getMint } from "@solana/spl-token";
import { Program } from "@anchor-lang/core"; import { Program } from "@anchor-lang/core";
import { loadConfig, loadKeypair, SIDE_UP } from "./config"; import { loadConfig, loadKeypair, SIDE_UP } from "./config";
@@ -11,27 +11,16 @@ import {
readGlobal, readGlobal,
ensureVaultFunded, ensureVaultFunded,
ensureBettorTokenAccount, ensureBettorTokenAccount,
fundBettor,
} from "./ops"; } from "./ops";
import { betPda } from "./pda"; import { betPda } from "./pda";
import { makeConnection, makeProgram } from "./program"; import { makeConnection, makeProgram } from "./program";
import type { SmartUpdown } from "../idl/smart_updown"; import type { SmartUpdown } from "../idl/smart_updown";
const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const STATUS_OPEN = 0; const STATUS_OPEN = 0;
async function fundBettor(connection: any, bettorPubkey: PublicKey): Promise<void> {
// SOL всё ещё нужен беттору: рент PDA-аккаунта ставки + комиссия за tx.
// Саму ставку (фантики) он закрывает из своего токен-аккаунта (ATA).
const bal = await connection.getBalance(bettorPubkey);
if (bal < 2 * LAMPORTS_PER_SOL_NUM) {
await connection.requestAirdrop(bettorPubkey, 5 * LAMPORTS_PER_SOL_NUM);
await sleep(800);
console.log(`bettor funded ${bettorPubkey.toBase58()} (+5 SOL)`);
}
}
/** Человекочитаемо-формат: токен-юниты -> "N.NNNNNN" по decimals. */ /** Человекочитаемо-формат: токен-юниты -> "N.NNNNNN" по decimals. */
function humanize(units: string | number | bigint, decimals: number): string { function humanize(units: string | number | bigint, decimals: number): string {
+29 -4
View File
@@ -1,4 +1,9 @@
import { Connection, Keypair, PublicKey } from "@solana/web3.js"; import {
Connection,
Keypair,
LAMPORTS_PER_SOL,
PublicKey,
} from "@solana/web3.js";
import { import {
TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID,
createAssociatedTokenAccount, createAssociatedTokenAccount,
@@ -87,13 +92,15 @@ export async function initialize(
return sig; return sig;
} }
/** Создаёт ставку от имени пользователя. Возвращает фактический id (== counter). */ /**
* Создаёт ставку от имени пользователя. Возвращает {id (== counter), tx}.
*/
export async function createBet( export async function createBet(
program: Program<SmartUpdown>, program: Program<SmartUpdown>,
mint: PublicKey, mint: PublicKey,
bettor: Keypair, bettor: Keypair,
args: { side: number; amount: number; entryPrice: number } args: { side: number; amount: number; entryPrice: number }
): Promise<BN> { ): Promise<{ id: BN; tx: string }> {
const global = await readGlobal(program, program.programId, mint); const global = await readGlobal(program, program.programId, mint);
if (!global) throw new Error("Contract not initialized"); if (!global) throw new Error("Contract not initialized");
const id = global.counter; const id = global.counter;
@@ -118,7 +125,7 @@ export async function createBet(
`createBet #${id} side=${args.side === SIDE_UP ? "UP" : "DOWN"} ` + `createBet #${id} side=${args.side === SIDE_UP ? "UP" : "DOWN"} ` +
`amount=${args.amount} (token base units) entry=${args.entryPrice} tx=${sig}` `amount=${args.amount} (token base units) entry=${args.entryPrice} tx=${sig}`
); );
return id; return { id, tx: sig };
} }
/** /**
@@ -183,6 +190,24 @@ export async function ensureBettorTokenAccount(
} }
} }
/**
* SOL всё ещё нужен беттору: рент PDA-аккаунта ставки + комиссия за tx.
* Саму ставку (фантики) он закрывает из своего токен-аккаунта (ATA).
* Догружает до 5 SOL airdrop'ом, если лампортов меньше 2 SOL.
*/
export async function fundBettor(
connection: Connection,
bettorPubkey: PublicKey
): Promise<void> {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const bal = await connection.getBalance(bettorPubkey);
if (bal < 2 * LAMPORTS_PER_SOL) {
await connection.requestAirdrop(bettorPubkey, 5 * LAMPORTS_PER_SOL);
await sleep(800);
console.log(`bettor funded ${bettorPubkey.toBase58()} (+5 SOL)`);
}
}
/** /**
* Догружает ваулт (house bankroll), чтобы хватило на выплаты 1.3x. * Догружает ваулт (house bankroll), чтобы хватило на выплаты 1.3x.
* Теперь это ТОКЕН, не SOL: на localnet mint-авторитетом выступает admin * Теперь это ТОКЕН, не SOL: на localnet mint-авторитетом выступает admin