From 5ab6c9def53230b0945b2c45afbc4ae7ab84e822 Mon Sep 17 00:00:00 2001 From: Caffeine Date: Sun, 13 Sep 2026 13:38:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(relayer):=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=BD=D0=B0=D1=87=D0=B8=D1=81=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B8=D0=B7=20/bet=20=D0=B2=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=D1=83=D1=8E=20=D1=80=D1=83=D1=87=D0=BA=D1=83?= =?UTF-8?q?=20+=20=D0=B3=D0=B5=D0=B9=D1=82=20test/release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /bet больше НЕ наливает SOL и фантики; address обязателен (400 с подсказкой) - пред-проверка балансов перед ставкой (SOL >= 0.01, фантиков >= amount), 400 - новый POST /wallet — создать custodial-кошелёк, ничего не наливает - src/faucetGate.ts: parseMode/faucetDenyReason, fail-closed - SMART_MODE (test|release), дефолт release; /faucet: 403 если не test - genesis-проверка: mainnet/devnet/testnet/genesis недоступен -> 403 - helm: SMART_MODE из .Values.smart.mode - check.ts: 6 проверок гейта (мутационная приёмка пройдена) --- relayer/helm/templates/deployment.yaml | 2 + relayer/helm/values.yaml | 1 + relayer/src/check.ts | 44 ++++++++ relayer/src/config.ts | 9 ++ relayer/src/faucetGate.ts | 46 ++++++++ relayer/src/gateway.ts | 145 +++++++++++++++++-------- 6 files changed, 203 insertions(+), 44 deletions(-) create mode 100644 relayer/src/faucetGate.ts diff --git a/relayer/helm/templates/deployment.yaml b/relayer/helm/templates/deployment.yaml index d7d9aac..f4945de 100644 --- a/relayer/helm/templates/deployment.yaml +++ b/relayer/helm/templates/deployment.yaml @@ -55,6 +55,8 @@ spec: value: {{ .Values.smart.tokenMint | quote }} - name: SMART_ADMIN_KEYPAIR value: {{ .Values.smart.adminKeypair | quote }} + - name: SMART_MODE + value: {{ .Values.smart.mode | quote }} - name: SMART_ALLOW_AIRDROP value: {{ .Values.smart.allowAirdrop | ternary "1" "0" | quote }} - name: SMART_MIN_AMOUNT diff --git a/relayer/helm/values.yaml b/relayer/helm/values.yaml index 4d7981e..2dc5eb5 100644 --- a/relayer/helm/values.yaml +++ b/relayer/helm/values.yaml @@ -19,6 +19,7 @@ smart: programId: '9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf' tokenMint: '8dpXNUgWN4m8ohhi6dkVqw4XezbDF7oKDSgnbf2VKjQJ' adminKeypair: '/app/secrets/admin-keypair.json' + mode: test allowAirdrop: true minAmount: 100000000 maxAmount: 10000000000 diff --git a/relayer/src/check.ts b/relayer/src/check.ts index f1c0548..2dad642 100644 --- a/relayer/src/check.ts +++ b/relayer/src/check.ts @@ -5,6 +5,9 @@ * 1. PDA-деривации (global/bet/vault) совпадают с седами IDL. * 2. Инструкции create_bet/close_bet содержат bettor_token / vault_token / * token_program с нужными адресами и writable-флагами. + * 3. Гейт faucet-ручки: release запрещает на любом кластере, test разрешает + * только на непубличных (genesis-hash не mainnet/devnet/testnet), неизвестный + * hash запрещён (fail-closed). * * Запуск: `npm test`. */ @@ -14,6 +17,7 @@ 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"; const ok = (msg: string) => console.log(`ok: ${msg}`); let failed = 0; @@ -120,6 +124,46 @@ async function main(): Promise { check(kByName(TOKEN_PROGRAM_ID) !== undefined && !kByName(TOKEN_PROGRAM_ID)!.isWritable, "close_bet: token_program присутствует"); check(close.keys.length === 9, "close_bet: 9 аккаунтов (IDL-состав)"); + // 3. Гейт faucet-ручки (тест/release + genesis hash) — чистая логика из faucetGate.ts. + check( + faucetDenyReason({ + mode: "release", + genesisHash: "BDcnjqkaZ1uPQfrEEAYtBzUegVCdvHfGF7n4D2KC5jiG", + }) !== null, + "faucetGate: release запрещает при localnet genesis" + ); + check( + faucetDenyReason({ + mode: "test", + genesisHash: "BDcnjqkaZ1uPQfrEEAYtBzUegVCdvHfGF7n4D2KC5jiG", + }) === null, + "faucetGate: test разрешает при localnet genesis" + ); + check( + faucetDenyReason({ + mode: "test", + genesisHash: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d", + }) !== null, + "faucetGate: test запрещает при mainnet genesis" + ); + check( + faucetDenyReason({ + mode: "test", + genesisHash: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG", + }) !== null, + "faucetGate: test запрещает при devnet genesis" + ); + check( + faucetDenyReason({ mode: "test", genesisHash: null }) !== null, + "faucetGate: test запрещает при неизвестном genesis" + ); + check( + parseMode(undefined) === "release" && + parseMode("prod") === "release" && + parseMode("") === "release", + "parseMode: неизвестное значение = release" + ); + if (failed > 0) { console.error(`\ncheck: ${failed} проверок ПРОВАЛЕНО`); process.exit(1); diff --git a/relayer/src/config.ts b/relayer/src/config.ts index 85ec6be..0e82bca 100644 --- a/relayer/src/config.ts +++ b/relayer/src/config.ts @@ -2,6 +2,7 @@ import { Connection, clusterApiUrl, Keypair, PublicKey } from "@solana/web3.js"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { SmartMode, parseMode } from "./faucetGate"; export const SIDE_UP = 0; export const SIDE_DOWN = 1; @@ -13,6 +14,12 @@ export const SIDE_DOWN = 1; export interface Config { /** RPC endpoint of the cluster (localnet by default). */ rpcUrl: string; + /** + * Режим работы релейера: "test" разрешает faucet-ручку (налив SOL/фантиков + * на произвольный адрес); "release" — запрещает, гейт fail-closed. + * Дефолт — "release" (безопаснее забыть, чем вспомнить). + */ + mode: SmartMode; /** * Localnet allows naive funding: SOL airdrop to bettors (rent/fees) and * minting tokens into the vault via the mint authority. Disable on mainnet. @@ -55,6 +62,7 @@ function envNum(name: string, fallback: number): number { export function loadConfig(): Config { const rpcUrl = env("SMART_RPC_URL", "http://127.0.0.1:8899"); + const mode = parseMode(env("SMART_MODE", "release")); const allowAirdrop = env("SMART_ALLOW_AIRDROP", "1") === "1"; const adminKeypairPath = env("SMART_ADMIN_KEYPAIR", path.join(os.homedir(), ".config/solana/id.json")); @@ -62,6 +70,7 @@ export function loadConfig(): Config { return { rpcUrl, + mode, allowAirdrop, programId: PublicKey.default, // placeholder; real value read from the IDL below // Плейсхолдер: настоящий адрес фантичного mint задаётся через SMART_TOKEN_MINT. diff --git a/relayer/src/faucetGate.ts b/relayer/src/faucetGate.ts new file mode 100644 index 0000000..1944215 --- /dev/null +++ b/relayer/src/faucetGate.ts @@ -0,0 +1,46 @@ +/** + * Гейт доступа к тестовым ручкам шлюза (/faucet, /wallet-финансирование и т.п.). + * + * Чистая логика, без сети — нужна для оффлайн-проверок в `npm test` + * (см. src/check.ts). Все сетевые обращения (getGenesisHash и т.п.) + * живут в gateway.ts и используют эти функции как финальный арбитр. + * + * Политика: fail-closed. Любое отклонение от разрешённого режима — отказ. + */ + +export type SmartMode = "test" | "release"; + +/** Genesis-hash публичных кластеров: на них тестовая ручка запрещена всегда. */ +export const PUBLIC_CLUSTER_GENESIS: Record = { + "mainnet-beta": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d", + devnet: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG", + testnet: "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY", +}; + +/** Разбор режима: всё, что не "test", считается "release" (fail-closed). */ +export function parseMode(raw: string | undefined): SmartMode { + return raw === "test" ? "test" : "release"; +} + +/** + * Причина отказа в доступе к тестовой ручке, либо null если доступ разрешён. + * Отказ (fail-closed) если: режим не test; genesisHash неизвестен (null/пусто); + * genesisHash принадлежит публичному кластеру. + */ +export function faucetDenyReason(opts: { + mode: SmartMode; + genesisHash: string | null | undefined; +}): string | null { + if (opts.mode !== "test") { + return `SMART_MODE=${opts.mode} (only "test" allows the faucet)`; + } + if (!opts.genesisHash) { + return "genesis hash unavailable: cannot prove non-public cluster"; + } + for (const [cluster, hash] of Object.entries(PUBLIC_CLUSTER_GENESIS)) { + if (opts.genesisHash === hash) { + return `cluster ${cluster} is public — faucet disabled`; + } + } + return null; +} diff --git a/relayer/src/gateway.ts b/relayer/src/gateway.ts index e330496..d039c64 100644 --- a/relayer/src/gateway.ts +++ b/relayer/src/gateway.ts @@ -10,15 +10,18 @@ * Эндпоинты (CORS: Access-Control-Allow-Origin: *): * GET /state — Global: counter, min/max, multiplier, expiry, paused, vault * GET /price — текущая сырая цена SOL из NATS (для отладки); 503 если ещё нет - * POST /bet {address?, side, amountUnits} - * — address: custodial-беттор шлюза; если не задан — шлюз - * генерирует новый и регистрирует в keystore. - * Ответ: {tx, id, bettor, entryPrice} + * POST /wallet — создать custodial-кошелёк (только test-mode). Ответ: {address}. + * НЕ наливает ни SOL, ни фантики — деньги отдельно через /faucet/{address}. + * POST /bet {address, side, amountUnits} + * — address: обязателен (получен из /wallet и пополнен через /faucet). + * Ставка НИЧЕГО не наливает — только проверяет, что фантиков + * и SOL хватает, и шлёт create_bet. Ответ: {tx, id, bettor, entryPrice} * POST /close {id} — close_bet по текущей цене (подпись relayer) * GET /balance/{address} — {sol, solLamports, tokenUnits, tokenHuman} - * GET /faucet/{address} — налив SOL + фантиков на произвольный адрес - * (только localnet, allowAirdrop=1; иначе 403). Ответ: - * {address, sol, solLamports, tokenUnits, tokenHuman} + * GET /faucet/{address} — налив SOL + фантиков на ПРОИЗВОЛЬНЫЙ адрес (Phantom и т.п.). + * Доступен ТОЛЬКО в test-режиме: SMART_MODE=test И не публичный + * кластер (проверка по genesis hash: mainnet/devnet/testnet запрещены). + * Иначе 403. Ответ: {address, sol, solLamports, tokenUnits, tokenHuman} * GET /ws (WebSocket) — push цены SOL из NATS; на подключение шлёт * снапшот (если цена уже есть), дальше — каждый тик. * @@ -52,6 +55,7 @@ import { betPda } from "./pda"; import { makeConnection, makeProgram } from "./program"; import { currentPrice, onPrice, connectNats } from "./natsPrice"; import { currentPriceHuman, getPrice } from "./priceSource"; +import { faucetDenyReason } from "./faucetGate"; import type { SmartUpdown } from "../idl/smart_updown"; @@ -62,6 +66,9 @@ const STATUS_HOUSE_WON = 2; const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL); const MAX_BODY_BYTES = 100 * 1024; +/** Минимальный баланс SOL на бетторе: рента PDA + комиссия. */ +const MIN_SOL_LAMPORTS = 10_000_000; + const NATS_URL_DEFAULT = "nats://192.168.88.93:4222"; const NATS_PRICE_SUBJECT_DEFAULT = "market.price.solusdt"; const WS_PATH = "/ws"; @@ -137,6 +144,8 @@ interface Ctx { program: Program; decimals: number; keystore: BettorKeystore; + /** Кэш getGenesisHash() — RPC дёргаем один раз при первом обращении к /faucet. */ + genesisHash?: string; } interface HttpJson { @@ -242,41 +251,50 @@ async function handleBet(ctx: Ctx, body: Record): Promise { } /** - * Налив фантиков/SOL на ПРОИЗВОЛЬНЫЙ адрес (Phantom и т.п.) — только localnet. + * Налив фантиков/SOL на ПРОИЗВОЛЬНЫЙ адрес (Phantom и т.п.). + * Доступ ТОЛЬКО в test-режиме: SMART_MODE=test И не публичный кластер + * (проверка по genesis hash — mainnet/devnet/testnet запрещены). * Переиспользует готовые функции ops.ts без дублирования логики: * - SOL (рента/комиссия) — тот же airdrop, что fundBettor; * - фантики — mintTo в ATA адреса (до startBalance, дефолт = maxAmount), @@ -393,9 +413,32 @@ async function handleBalance(ctx: Ctx, address: string): Promise { * Ответ — адрес + реальные балансы после налива (readBalances, как в /balance). */ async function handleFaucet(ctx: Ctx, address: string): Promise { - if (!ctx.cfg.allowAirdrop) { - return { status: 403, body: { error: "faucet only on localnet" } }; + // Уровень 1: режим. Любой не-test — запрет (даже на localnet). + if (ctx.cfg.mode !== "test") { + console.log(`gateway: faucet DENIED (mode=${ctx.cfg.mode})`); + return { + status: 403, + body: { error: "faucet is disabled: only test mode (SMART_MODE=test)" }, + }; } + // Уровень 2: генезис. Кэшируем первый getGenesisHash() в ctx. + if (ctx.genesisHash === undefined) { + try { + ctx.genesisHash = await ctx.connection.getGenesisHash(); + } catch (e) { + console.log(`gateway: faucet DENIED (genesis hash unavailable: ${e instanceof Error ? e.message : String(e)})`); + return { + status: 403, + body: { error: "faucet is disabled: genesis hash unavailable" }, + }; + } + } + const reason = faucetDenyReason({ mode: ctx.cfg.mode, genesisHash: ctx.genesisHash }); + if (reason !== null) { + console.log(`gateway: faucet DENIED (${reason})`); + return { status: 403, body: { error: `faucet is disabled: ${reason}` } }; + } + const pub = parsePubkey(address); if (!pub) { return { status: 400, body: { error: "address is not a valid Solana pubkey" } }; @@ -417,6 +460,18 @@ async function handleFaucet(ctx: Ctx, address: string): Promise { }; } +/** + * Создаёт новый custodial-кошелёк беттора. НИЧЕГО не наливает — только + * генерирует keypair и регистрирует в keystore. Деньги — через /faucet/{address}. + */ +async function handleWallet(ctx: Ctx): Promise { + const kp = ctx.keystore.generate(); + return { + status: 200, + body: { address: kp.publicKey.toBase58() }, + }; +} + function readJsonBody(req: http.IncomingMessage): Promise> { return new Promise((resolve, reject) => { let size = 0; @@ -549,6 +604,8 @@ async function main(): Promise { result = await handleState(ctx); } else if (m === "GET" && p === "/price") { result = await handlePrice(); + } else if (m === "POST" && p === "/wallet") { + result = await handleWallet(ctx); } else if (m === "POST" && p === "/bet") { const body = await readJsonBody(req); result = await withBetLock(() => handleBet(ctx, body)); @@ -654,7 +711,7 @@ async function main(): Promise { 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, GET /price, POST /bet, POST /close, GET /balance/{address}, GET /faucet/{address}, WS /ws` + `gateway: endpoints: GET /state, GET /price, POST /wallet, POST /bet, POST /close, GET /balance/{address}, GET /faucet/{address}, WS /ws` ); });