fix: распаковать источник из обёртки smart-updown в корень ветки

This commit is contained in:
2026-09-02 15:36:21 +03:00
parent a9d54feab6
commit 5cf3a7a8d1
35 changed files with 0 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
import { Connection, clusterApiUrl, Keypair, PublicKey } from "@solana/web3.js";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
export const SIDE_UP = 0;
export const SIDE_DOWN = 1;
/**
* Runtime configuration. Everything can be overridden via environment
* variables; defaults target the local validator.
*/
export interface Config {
/** RPC endpoint of the cluster (localnet by default). */
rpcUrl: string;
/** Localnet allows naive airdrops (to bettors and the house vault). */
allowAirdrop: boolean;
/** Program id. Defaults to the value embedded in the IDL. */
programId: PublicKey;
/** House admin/relayer keypair file path. */
adminKeypairPath: string;
/** Initial parameters used by `init` (must match on-chain constants). */
minAmountLamports: number;
maxAmountLamports: number;
multiplierBps: number;
expirySeconds: number;
}
function env(name: string, fallback: string): string {
return process.env[name] ?? fallback;
}
function envNum(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const n = Number(raw);
if (!Number.isFinite(n)) throw new Error(`Invalid number for env ${name}: ${raw}`);
return n;
}
export function loadConfig(): Config {
const rpcUrl = env("SMART_RPC_URL", "http://127.0.0.1:8899");
const allowAirdrop = env("SMART_ALLOW_AIRDROP", "1") === "1";
const adminKeypairPath =
env("SMART_ADMIN_KEYPAIR", path.join(os.homedir(), ".config/solana/id.json"));
return {
rpcUrl,
allowAirdrop,
programId: PublicKey.default, // placeholder; real value read from the IDL below
adminKeypairPath,
minAmountLamports: envNum("SMART_MIN_AMOUNT", 100_000_000), // 0.1 SOL
maxAmountLamports: envNum("SMART_MAX_AMOUNT", 10_000_000_000), // 10 SOL
multiplierBps: envNum("SMART_MULTIPLIER_BPS", 13_000), // 1.3x
expirySeconds: envNum("SMART_EXPIRY_SECONDS", 15),
};
}
export function loadKeypair(pathOrJson: string): Keypair {
const resolved = pathOrJson.startsWith("~")
? path.join(os.homedir(), pathOrJson.slice(1))
: path.resolve(pathOrJson);
const raw = fs.readFileSync(resolved, "utf8");
return Keypair.fromSecretKey(Uint8Array.from(JSON.parse(raw)));
}
+24
View File
@@ -0,0 +1,24 @@
import { Keypair } from "@solana/web3.js";
import { SIDE_DOWN, SIDE_UP } from "./config";
/**
* STUB — «фронт»: от пользователя приходят параметры ставки.
*
* В реальной системе это сообщение присылает фронтенд (или твоя БД) после
* того, как пользователь выбрал направление и сумму. Здесь мы эмулируем
* входящую очередь: случайный UP/DOWN и случайную сумму в заданном диапазоне.
*/
export interface BetRequest {
bettor: Keypair;
side: number; // SIDE_UP | SIDE_DOWN
amountLamports: number;
}
/** Очередь входящих ставок. В заглушке наполняем случайными значениями. */
export function nextBetRequest(minAmount: number, maxAmount: number): BetRequest {
const bettor = Keypair.generate();
const side = Math.random() < 0.5 ? SIDE_UP : SIDE_DOWN;
const amount =
minAmount + Math.floor(Math.random() * (maxAmount - minAmount + 1));
return { bettor, side, amountLamports: amount };
}
+136
View File
@@ -0,0 +1,136 @@
import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import { Program } from "@anchor-lang/core";
import { loadConfig, loadKeypair, SIDE_UP } from "./config";
import { getPrice } from "./priceSource";
import { nextBetRequest } from "./frontStub";
import {
initialize,
createBet,
closeBet,
readGlobal,
ensureVaultFunded,
} 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> {
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)`);
}
}
/** Закрывает ставку, повторяя при «equal price» (exit==entry) пока не зафиналится. */
async function settleBet(
program: Program<SmartUpdown>,
id: any,
entryPrice: number,
relayer: any,
bettor: PublicKey
): Promise<void> {
for (let attempt = 1; ; attempt++) {
const exitPrice = await getPrice();
if (exitPrice === entryPrice) {
console.log(`attempt ${attempt}: exit==entry (${exitPrice}), жду движение...`);
await sleep(2000);
continue;
}
const sig = await closeBet(program, id, exitPrice, relayer, bettor);
console.log(`close_bet #${id} exit=${exitPrice} (attempt ${attempt}) tx=${sig}`);
const status = await getStatus(program, id);
if (status === STATUS_OPEN) {
console.log(`ставка ещё open (equal price), повторяю close...`);
await sleep(2000);
continue;
}
return;
}
}
async function getStatus(program: Program<SmartUpdown>, id: any): Promise<number> {
const bet = await program.account.bet.fetchNullable(betPda(program.programId, id));
return bet ? bet.status.toNumber() : -1;
}
async function main(): Promise<void> {
const cfg = loadConfig();
const admin = loadKeypair(cfg.adminKeypairPath);
const connection = makeConnection(cfg.rpcUrl);
const program = makeProgram(connection, admin);
console.log(`RPC: ${cfg.rpcUrl}`);
console.log(`Admin/relayer: ${admin.publicKey.toBase58()}`);
const global = await readGlobal(program, program.programId);
if (!global) {
if (!process.argv.includes("--init")) {
throw new Error(
"Контракт не инициализирован. Запусти с флагом --init (или init+run)."
);
}
await initialize(program, {
minAmount: cfg.minAmountLamports,
maxAmount: cfg.maxAmountLamports,
multiplierBps: cfg.multiplierBps,
expirySeconds: cfg.expirySeconds,
}, admin);
console.log("Контракт инициализирован.");
} else {
console.log(
`Global: counter=${global.counter} vault=${(global.lamports / LAMPORTS_PER_SOL_NUM).toFixed(2)} SOL ` +
`expiry=${global.expirySeconds}s paused=${global.paused}`
);
}
if (cfg.allowAirdrop) {
await ensureVaultFunded(connection, program, 10 * LAMPORTS_PER_SOL_NUM);
}
const loops = process.argv.includes("--once") ? 1 : Infinity;
let n = 0;
while (n < loops) {
n++;
const req = nextBetRequest(cfg.minAmountLamports, cfg.maxAmountLamports);
await fundBettor(connection, req.bettor.publicKey);
const entryPrice = await getPrice();
await createBet(program, req.bettor, {
side: req.side,
amount: req.amountLamports,
entryPrice,
});
console.log(
`ставка ${req.side === SIDE_UP ? "UP" : "DOWN"} на ${(req.amountLamports / LAMPORTS_PER_SOL_NUM).toFixed(3)} SOL, жду ${cfg.expirySeconds}s...`
);
await sleep((cfg.expirySeconds + 1) * 1000);
const g0 = await readGlobal(program, program.programId);
const id = g0!.counter.subn(1);
await settleBet(program, id, entryPrice, admin, req.bettor.publicKey);
const outcome = await program.account.bet.fetchNullable(betPda(program.programId, id));
console.log(
outcome!.status.toNumber() === 1
? `>> результат #${id}: ПОЛЬЗОВАТЕЛЬ ВЫИГРАЛ (payout 1.3x)`
: `>> результат #${id}: ВЫИГРАЛ ДОМ (user lost)`
);
}
console.log("Готово.");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+131
View File
@@ -0,0 +1,131 @@
import { Connection, Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import BN from "bn.js";
import { Program } from "@anchor-lang/core";
import { SIDE_UP } from "./config";
import { globalPda } from "./pda";
import type { SmartUpdown } from "../idl/smart_updown";
const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL);
/** Считывает account Global (ваулт). Возвращает null, если ещё не инициализирован. */
export async function readGlobal(
program: Program<SmartUpdown>,
programId: PublicKey
): Promise<{
counter: BN;
minAmount: BN;
maxAmount: BN;
multiplierBps: BN;
expirySeconds: BN;
paused: BN;
admin: PublicKey;
relayer: PublicKey;
lamports: number;
} | null> {
const global = globalPda(programId);
const raw = await program.account.global.fetchNullable(global);
if (!raw) return null;
const lamports = await program.provider.connection.getBalance(global);
return {
counter: raw.counter,
minAmount: raw.minAmount,
maxAmount: raw.maxAmount,
multiplierBps: raw.multiplierBps,
expirySeconds: raw.expirySeconds,
paused: raw.paused,
admin: raw.admin,
relayer: raw.relayer,
lamports,
};
}
/** Одноразовая инициализация контракта (вызывается admin'ом до приёма ставок). */
export async function initialize(
program: Program<SmartUpdown>,
args: {
minAmount: number;
maxAmount: number;
multiplierBps: number;
expirySeconds: number;
},
admin: Keypair
): Promise<string> {
const sig = await program.methods
.initialize(
new BN(args.minAmount),
new BN(args.maxAmount),
new BN(args.multiplierBps),
new BN(args.expirySeconds)
)
.accounts({
admin: admin.publicKey,
})
.signers([admin])
.rpc();
return sig;
}
/** Создаёт ставку от имени пользователя. Возвращает фактический id (== counter). */
export async function createBet(
program: Program<SmartUpdown>,
bettor: Keypair,
args: { side: number; amount: number; entryPrice: number }
): Promise<BN> {
const global = await readGlobal(program, program.programId);
if (!global) throw new Error("Contract not initialized");
const id = global.counter;
const sig = await program.methods
.createBet(new BN(id), new BN(args.side), new BN(args.amount), new BN(args.entryPrice))
.accounts({
bettor: bettor.publicKey,
})
.signers([bettor])
.rpc();
console.log(`create_bet #${id} side=${args.side === SIDE_UP ? "UP" : "DOWN"} ` +
`amount=${(args.amount / LAMPORTS_PER_SOL_NUM).toFixed(3)} SOL entry=${args.entryPrice} tx=${sig}`);
return id;
}
/**
* Закрывает ставку после экспирации.
* Если exit_price == entry_price, контракт не финализирует ставку — тогда
* ждём первое движение цены и повторяем close (см. обработку в main loop).
*/
export async function closeBet(
program: Program<SmartUpdown>,
id: BN,
exitPrice: number,
relayer: Keypair,
bettor: PublicKey
): Promise<string> {
return program.methods
.closeBet(new BN(id), new BN(exitPrice))
.accounts({
relayer: relayer.publicKey,
bettor,
})
.signers([relayer])
.rpc();
}
/**
* Догружает ваулт (house bankroll), чтобы хватило на выплаты 1.3x.
* На localnet используем airDrop на PDA ваулта; на mainnet воздух не накапает —
* там нужно переводить SOL с админа (off-chain, вне этого CLI).
*/
export async function ensureVaultFunded(
connection: Connection,
program: Program<SmartUpdown>,
amountLamports: number
): Promise<void> {
const global = globalPda(program.programId);
const current = await connection.getBalance(global);
const needed = amountLamports; // будущая выплата (до 1.3x), запасаем с запасом
if (current >= needed) return;
const topUp = needed - current + 5 * LAMPORTS_PER_SOL_NUM; // +5 SOL запаса
await connection.requestAirdrop(global, topUp);
console.log(`vault funded +${(topUp / LAMPORTS_PER_SOL_NUM).toFixed(2)} SOL`);
}
+16
View File
@@ -0,0 +1,16 @@
import { PublicKey } from "@solana/web3.js";
import BN from "bn.js";
/** Seeds, согласованные с контрактом (см. constants.rs). */
export const GLOBAL_SEED = Buffer.from("global");
export const BET_SEED = Buffer.from("bet");
export function globalPda(programId: PublicKey): PublicKey {
return PublicKey.findProgramAddressSync([GLOBAL_SEED], programId)[0];
}
export function betPda(programId: PublicKey, id: BN): PublicKey {
const idLE = Buffer.alloc(8);
idLE.writeBigUInt64LE(BigInt(id.toString(10)));
return PublicKey.findProgramAddressSync([BET_SEED, idLE], programId)[0];
}
+22
View File
@@ -0,0 +1,22 @@
/**
* STUB — SOL price source.
*
* TODO(пользователь): замени `getPrice` на реальный запрос к своей БД.
* Договоримся, что функция возвращает цену SOL как целое число в «центах»
* (то же юнит-пространство, что у веса entry/exit на контракте). Контракт
* сравнивает тол ько «больше/меньше/равно», поэтому само юнит-пространство
* не критично — главное, чтобы оно было согласовано между entry и exit.
*/
let simulated = 100_000_000;
/** Цена SOL сейчас (заглушка — детерминированно «гуляет»). */
export async function getPrice(): Promise<number> {
const drift = Math.round(Math.random() * 200_000); // ±0.2% движка
simulated += Math.random() < 0.5 ? drift : -drift;
return simulated;
}
/** Управляемая заглушка: вернуть ровно `value` (полезна для теста «equal price»). */
export function setPriceOverride(value: number | undefined): void {
simulated = value ?? 100_000_000;
}
+27
View File
@@ -0,0 +1,27 @@
import { Connection } from "@solana/web3.js";
import { AnchorProvider, Program, Wallet } from "@anchor-lang/core";
import { Keypair } from "@solana/web3.js";
import idl from "../idl/smart_updown.json";
import type { SmartUpdown } from "../idl/smart_updown";
/**
* Создаёт готовый клиент программы: Connection + AnchorProvider (подпись админа-
* релеера) + типизированный Program, собранный из IDL.
*/
export function makeProgram(
connection: Connection,
admin: Keypair
): Program<SmartUpdown> {
const provider = new AnchorProvider(
connection,
new Wallet(admin),
{ commitment: "confirmed", preflightCommitment: "confirmed" }
);
// IDL.address задаёт programId в рантайме.
return new Program<SmartUpdown>(idl as unknown as SmartUpdown, provider);
}
export function makeConnection(rpcUrl: string): Connection {
return new Connection(rpcUrl, "confirmed");
}