From f426f58778ac66e6ef22af82762f97c2521ad141 Mon Sep 17 00:00:00 2001 From: Caffeine Date: Thu, 10 Sep 2026 04:33:34 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20HTTP-=D1=88=D0=BB=D1=8E=D0=B7=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D1=85=20=D1=80=D0=B5=D0=BB=D0=B5=D0=B9?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=20(POST=20/bet,/close;=20GET=20/balance,/sta?= =?UTF-8?q?te)=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D1=83=D0=BB=D1=8C=D1=82=D0=B0?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20Phantom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relayer/.gitignore | 5 +- relayer/gateway-TASK.md | 43 ++++ relayer/package.json | 3 +- relayer/src/gateway.ts | 471 ++++++++++++++++++++++++++++++++++++++++ relayer/src/index.ts | 15 +- relayer/src/ops.ts | 33 ++- 6 files changed, 551 insertions(+), 19 deletions(-) create mode 100644 relayer/gateway-TASK.md create mode 100644 relayer/src/gateway.ts diff --git a/relayer/.gitignore b/relayer/.gitignore index 04c01ba..f0c9e84 100644 --- a/relayer/.gitignore +++ b/relayer/.gitignore @@ -1,2 +1,5 @@ node_modules/ -dist/ \ No newline at end of file +dist/ + +# custodial-ключи бетторов HTTP-шлюза (gateway.ts) — НЕ коммитить +.gateway-bettors.json diff --git a/relayer/gateway-TASK.md b/relayer/gateway-TASK.md new file mode 100644 index 0000000..74b0c00 --- /dev/null +++ b/relayer/gateway-TASK.md @@ -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-теста. \ No newline at end of file diff --git a/relayer/package.json b/relayer/package.json index 91b289e..03f0963 100644 --- a/relayer/package.json +++ b/relayer/package.json @@ -9,7 +9,8 @@ "test": "ts-node src/check.ts", "init": "ts-node src/index.ts --init --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": { "@anchor-lang/core": "^1.0.0", diff --git a/relayer/src/gateway.ts b/relayer/src/gateway.ts new file mode 100644 index 0000000..d1dc1c7 --- /dev/null +++ b/relayer/src/gateway.ts @@ -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 { "": [секретные байты...] }, права 0600. + */ +class BettorKeystore { + private pairs = new Map(); + 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; + 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 = {}; + 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; + admin: Keypair; + connection: ReturnType; + program: Program; + decimals: number; + keystore: BettorKeystore; +} + +interface HttpJson { + status: number; + body: Record; +} + +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 { + 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): Promise { + 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 = Promise.resolve(); +function withBetLock(fn: () => Promise): Promise { + const run = betChain.then(fn); + betChain = run.then( + () => undefined, + () => undefined + ); + return run; +} + +async function handleClose(ctx: Ctx, body: Record): Promise { + 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 = { + 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 { + 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 => { + 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> { + 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); + } 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 { + 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); +}); diff --git a/relayer/src/index.ts b/relayer/src/index.ts index e717ba6..ba14eb1 100644 --- a/relayer/src/index.ts +++ b/relayer/src/index.ts @@ -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 { Program } from "@anchor-lang/core"; import { loadConfig, loadKeypair, SIDE_UP } from "./config"; @@ -11,27 +11,16 @@ import { readGlobal, ensureVaultFunded, ensureBettorTokenAccount, + fundBettor, } from "./ops"; import { betPda } from "./pda"; import { makeConnection, makeProgram } from "./program"; 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 STATUS_OPEN = 0; -async function fundBettor(connection: any, bettorPubkey: PublicKey): Promise { - // 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. */ function humanize(units: string | number | bigint, decimals: number): string { diff --git a/relayer/src/ops.ts b/relayer/src/ops.ts index 7d57319..dda53a3 100644 --- a/relayer/src/ops.ts +++ b/relayer/src/ops.ts @@ -1,4 +1,9 @@ -import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import { + Connection, + Keypair, + LAMPORTS_PER_SOL, + PublicKey, +} from "@solana/web3.js"; import { TOKEN_PROGRAM_ID, createAssociatedTokenAccount, @@ -87,13 +92,15 @@ export async function initialize( return sig; } -/** Создаёт ставку от имени пользователя. Возвращает фактический id (== counter). */ +/** + * Создаёт ставку от имени пользователя. Возвращает {id (== counter), tx}. + */ export async function createBet( program: Program, mint: PublicKey, bettor: Keypair, args: { side: number; amount: number; entryPrice: number } -): Promise { +): Promise<{ id: BN; tx: string }> { const global = await readGlobal(program, program.programId, mint); if (!global) throw new Error("Contract not initialized"); const id = global.counter; @@ -118,7 +125,7 @@ export async function createBet( `createBet #${id} side=${args.side === SIDE_UP ? "UP" : "DOWN"} ` + `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 { + 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. * Теперь это ТОКЕН, не SOL: на localnet mint-авторитетом выступает admin