feat(relayer): вынести начисление из /bet в отдельную тестовую ручку + гейт test/release

- /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 проверок гейта (мутационная приёмка пройдена)
This commit is contained in:
Caffeine
2026-09-13 13:38:38 +03:00
parent a3178cfa6e
commit 5ab6c9def5
6 changed files with 203 additions and 44 deletions
+2
View File
@@ -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
+1
View File
@@ -19,6 +19,7 @@ smart:
programId: '9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf'
tokenMint: '8dpXNUgWN4m8ohhi6dkVqw4XezbDF7oKDSgnbf2VKjQJ'
adminKeypair: '/app/secrets/admin-keypair.json'
mode: test
allowAirdrop: true
minAmount: 100000000
maxAmount: 10000000000
+44
View File
@@ -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<void> {
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);
+9
View File
@@ -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.
+46
View File
@@ -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<string, string> = {
"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;
}
+101 -44
View File
@@ -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<SmartUpdown>;
decimals: number;
keystore: BettorKeystore;
/** Кэш getGenesisHash() — RPC дёргаем один раз при первом обращении к /faucet. */
genesisHash?: string;
}
interface HttpJson {
@@ -242,41 +251,50 @@ async function handleBet(ctx: Ctx, body: Record<string, unknown>): Promise<HttpJ
};
}
// Кому принадлежат фантики: либо зарегистрированный 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;
// address: обязателен. Без него — 400 с подсказкой, как создать кошелёк.
const addressRaw = body.address;
if (addressRaw === undefined || addressRaw === null || addressRaw === "") {
return {
status: 400,
body: {
error:
"address is required: create a wallet first (POST /wallet) and top it up (GET /faucet/{address})",
},
};
}
const pub = parsePubkey(addressRaw);
if (!pub) {
return { status: 400, body: { error: "address is not a valid Solana pubkey" } };
}
const kp = ctx.keystore.get(pub.toBase58());
if (!kp) {
return {
status: 400,
body: {
error: `bettor ${pub.toBase58()} is not registered in the gateway keystore`,
},
};
}
const bettor = pub;
// Фандинг как в релейере: 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)
);
// Предварительная проверка балансов — лучше явный 400, чем невнятная ошибка контракта.
// Сначала SOL (рента PDA + комиссия), потом фантики (размер ставки).
const balances = await readBalances(ctx, bettor);
if (balances.solLamports < MIN_SOL_LAMPORTS) {
return {
status: 400,
body: {
error: `insufficient funds: need at least ${MIN_SOL_LAMPORTS / LAMPORTS_PER_SOL_NUM} SOL for rent+fees; top up via GET /faucet/${bettor.toBase58()} (test mode only)`,
},
};
}
if (BigInt(balances.tokenUnits) < BigInt(amountUnits)) {
return {
status: 400,
body: {
error: `insufficient funds: have ${balances.tokenUnits} token base units, need ${amountUnits}; top up via GET /faucet/${bettor.toBase58()} (test mode only)`,
},
};
}
const entryPrice = await getPrice();
@@ -385,7 +403,9 @@ async function handleBalance(ctx: Ctx, address: string): Promise<HttpJson> {
}
/**
* Налив фантиков/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<HttpJson> {
* Ответ — адрес + реальные балансы после налива (readBalances, как в /balance).
*/
async function handleFaucet(ctx: Ctx, address: string): Promise<HttpJson> {
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<HttpJson> {
};
}
/**
* Создаёт новый custodial-кошелёк беттора. НИЧЕГО не наливает — только
* генерирует keypair и регистрирует в keystore. Деньги — через /faucet/{address}.
*/
async function handleWallet(ctx: Ctx): Promise<HttpJson> {
const kp = ctx.keystore.generate();
return {
status: 200,
body: { address: kp.publicKey.toBase58() },
};
}
function readJsonBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let size = 0;
@@ -549,6 +604,8 @@ async function main(): Promise<void> {
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<void> {
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`
);
});