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:
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ smart:
|
||||
programId: '9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf'
|
||||
tokenMint: '8dpXNUgWN4m8ohhi6dkVqw4XezbDF7oKDSgnbf2VKjQJ'
|
||||
adminKeypair: '/app/secrets/admin-keypair.json'
|
||||
mode: test
|
||||
allowAirdrop: true
|
||||
minAmount: 100000000
|
||||
maxAmount: 10000000000
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user