feat: HTTP-шлюз поверх релейера (POST /bet,/close; GET /balance,/state) для пульта без Phantom
This commit is contained in:
@@ -1,2 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# custodial-ключи бетторов HTTP-шлюза (gateway.ts) — НЕ коммитить
|
||||
.gateway-bettors.json
|
||||
|
||||
@@ -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-теста.
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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<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. */
|
||||
function humanize(units: string | number | bigint, decimals: number): string {
|
||||
|
||||
+29
-4
@@ -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<SmartUpdown>,
|
||||
mint: PublicKey,
|
||||
bettor: Keypair,
|
||||
args: { side: number; amount: number; entryPrice: number }
|
||||
): Promise<BN> {
|
||||
): 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<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.
|
||||
* Теперь это ТОКЕН, не SOL: на localnet mint-авторитетом выступает admin
|
||||
|
||||
Reference in New Issue
Block a user