feat(relayer): migrate to SPL-token (фантики)
- ops: readGlobal читает vaultBalance (getTokenAccountBalance), createBet/closeBet передают bettorToken/vaultToken/tokenProgram, ensureVaultFunded наливает токен (mintTo, allowOwnerOffCurve) - pda: vaultPda([global,vault]), config:.env.new SMART_TOKEN_MINT, суммы в токен-единицах - check.ts: 14 проверок сигнатур создания create/close под новым токеновым IDL - @solana/spl-token dependency; .gitignore + node_modules/dist
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Оффлайн-проверка релейера (без сети): компиляция и сигнатуры под новый
|
||||
* токеновый IDL.
|
||||
*
|
||||
* 1. PDA-деривации (global/bet/vault) совпадают с седами IDL.
|
||||
* 2. Инструкции create_bet/close_bet содержат bettor_token / vault_token /
|
||||
* token_program с нужными адресами и writable-флагами.
|
||||
*
|
||||
* Запуск: `npm test`.
|
||||
*/
|
||||
import { Keypair, PublicKey } from "@solana/web3.js";
|
||||
import BN from "bn.js";
|
||||
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";
|
||||
|
||||
const ok = (msg: string) => console.log(`ok: ${msg}`);
|
||||
let failed = 0;
|
||||
function check(cond: boolean, msg: string): void {
|
||||
if (cond) {
|
||||
ok(msg);
|
||||
} else {
|
||||
failed++;
|
||||
console.error(`FAIL: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** PDA из собственных (JSON) севов IDL — независимо от pda.ts. */
|
||||
function pdaFromIdlSeeds(programId: PublicKey, seeds: ReadonlyArray<{ kind: string; value?: unknown; path?: string }>): PublicKey {
|
||||
const buffers = seeds.map((s) => {
|
||||
if (s.kind === "const") return Buffer.from(s.value as number[]);
|
||||
if (s.kind === "arg" && s.path === "id") return Buffer.alloc(8); // id = 0
|
||||
throw new Error(`unsupported seed: ${JSON.stringify(s)}`);
|
||||
});
|
||||
return PublicKey.findProgramAddressSync(buffers as Buffer[], programId)[0];
|
||||
}
|
||||
|
||||
function idlIx(name: string) {
|
||||
const ix = (idl as any).instructions.find((i: any) => i.name === name);
|
||||
if (!ix) throw new Error(`instruction ${name} not in IDL`);
|
||||
return ix;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const programId = new PublicKey((idl as any).address as string);
|
||||
const createIx = idlIx("create_bet");
|
||||
const closeIx = idlIx("close_bet");
|
||||
|
||||
// 1. PDA-деривации против севов IDL
|
||||
const acct = (ix: any, name: string) => ix.accounts.find((a: any) => a.name === name);
|
||||
check(
|
||||
globalPda(programId).equals(pdaFromIdlSeeds(programId, (acct(createIx, "global") as any).pda.seeds)),
|
||||
"globalPda совпадает с седами global из IDL"
|
||||
);
|
||||
check(
|
||||
betPda(programId, new BN(0)).equals(pdaFromIdlSeeds(programId, (acct(createIx, "bet") as any).pda.seeds)),
|
||||
"betPda(0) совпадает с седами bet из IDL"
|
||||
);
|
||||
check(
|
||||
vaultPda(programId).equals(pdaFromIdlSeeds(programId, (acct(createIx, "vault_token") as any).pda.seeds)),
|
||||
"vaultPda совпадает с седами vault_token из IDL"
|
||||
);
|
||||
check(
|
||||
vaultPda(programId).equals(pdaFromIdlSeeds(programId, (acct(closeIx, "vault_token") as any).pda.seeds)),
|
||||
"vaultPda совпадает с седами vault_token в close_bet"
|
||||
);
|
||||
|
||||
// 2. Сборка инструкций оффлайн и проверка ключей
|
||||
const connection = makeConnection("http://127.0.0.1:8899"); // не используется сетью
|
||||
const admin = Keypair.generate();
|
||||
const bettor = Keypair.generate();
|
||||
const mint = new PublicKey("11111111111111111111111111111111"); // плейсхолдер
|
||||
const program = makeProgram(connection, admin);
|
||||
|
||||
const vaultToken = vaultPda(program.programId);
|
||||
const bettorToken = await getAssociatedTokenAddress(mint, bettor.publicKey);
|
||||
|
||||
const create = await program.methods
|
||||
.createBet(new BN(0), new BN(0), new BN(100), new BN(100_000_000))
|
||||
.accountsPartial({
|
||||
bettor: bettor.publicKey,
|
||||
bettorToken: bettorToken,
|
||||
vaultToken: vaultToken,
|
||||
tokenProgram: TOKEN_PROGRAM_ID,
|
||||
})
|
||||
.signers([bettor])
|
||||
.instruction();
|
||||
|
||||
check(create.programId.equals(programId), "create_bet: programId");
|
||||
const cByName = (p: PublicKey) => create.keys.find((k) => k.pubkey.equals(p));
|
||||
check(!!(cByName(bettor.publicKey)!.isSigner && cByName(bettor.publicKey)!.isWritable), "create_bet: bettor — writable signer");
|
||||
check(!!(cByName(bettorToken)!.isWritable && !cByName(bettorToken)!.isSigner), "create_bet: bettor_token — writable");
|
||||
check(!!(cByName(vaultToken)!.isWritable && !cByName(vaultToken)!.isSigner), "create_bet: vault_token — writable");
|
||||
check(cByName(TOKEN_PROGRAM_ID) !== undefined && !cByName(TOKEN_PROGRAM_ID)!.isWritable, "create_bet: token_program присутствует");
|
||||
check(create.keys.length === 8, "create_bet: 8 аккаунтов (IDL-состав)");
|
||||
|
||||
const close = await program.methods
|
||||
.closeBet(new BN(0), new BN(100_100_000))
|
||||
.accountsPartial({
|
||||
relayer: admin.publicKey,
|
||||
bettor: bettor.publicKey,
|
||||
bettorToken: bettorToken,
|
||||
vaultToken: vaultToken,
|
||||
tokenProgram: TOKEN_PROGRAM_ID,
|
||||
})
|
||||
.signers([admin])
|
||||
.instruction();
|
||||
|
||||
check(close.programId.equals(programId), "close_bet: programId");
|
||||
const kByName = (p: PublicKey) => close.keys.find((k) => k.pubkey.equals(p));
|
||||
check(kByName(admin.publicKey)!.isSigner, "close_bet: relayer — signer");
|
||||
check(kByName(bettor.publicKey) !== undefined && !kByName(bettor.publicKey)!.isSigner, "close_bet: bettor — не signer");
|
||||
check(!!(kByName(bettorToken)!.isWritable), "close_bet: bettor_token — writable");
|
||||
check(!!(kByName(vaultToken)!.isWritable), "close_bet: vault_token — writable");
|
||||
check(kByName(TOKEN_PROGRAM_ID) !== undefined && !kByName(TOKEN_PROGRAM_ID)!.isWritable, "close_bet: token_program присутствует");
|
||||
check(close.keys.length === 8, "close_bet: 8 аккаунтов (IDL-состав)");
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\ncheck: ${failed} проверок ПРОВАЛЕНО`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\ncheck: все проверки пройдены — сигнатуры релейера соответствуют новому токеновому IDL.");
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
+18
-7
@@ -13,15 +13,23 @@ export const SIDE_DOWN = 1;
|
||||
export interface Config {
|
||||
/** RPC endpoint of the cluster (localnet by default). */
|
||||
rpcUrl: string;
|
||||
/** Localnet allows naive airdrops (to bettors and the house vault). */
|
||||
/**
|
||||
* Localnet allows naive funding: SOL airdrop to bettors (rent/fees) and
|
||||
* minting tokens into the vault via the mint authority. Disable on mainnet.
|
||||
*/
|
||||
allowAirdrop: boolean;
|
||||
/** Program id. Defaults to the value embedded in the IDL. */
|
||||
programId: PublicKey;
|
||||
/** House admin/relayer keypair file path. */
|
||||
/**
|
||||
* Mint of our SPL "фантики" (decimals = 6, per the contract).
|
||||
* All amounts in this relayer are token base units of this mint.
|
||||
*/
|
||||
tokenMint: PublicKey;
|
||||
/** House admin/relayer keypair path (also the localnet mint authority). */
|
||||
adminKeypairPath: string;
|
||||
/** Initial parameters used by `init` (must match on-chain constants). */
|
||||
minAmountLamports: number;
|
||||
maxAmountLamports: number;
|
||||
/** Bet amount bounds in token base units (NOT lamports); must match the on-chain `initialize` values. */
|
||||
minAmount: number;
|
||||
maxAmount: number;
|
||||
multiplierBps: number;
|
||||
expirySeconds: number;
|
||||
}
|
||||
@@ -43,14 +51,17 @@ export function loadConfig(): Config {
|
||||
const allowAirdrop = env("SMART_ALLOW_AIRDROP", "1") === "1";
|
||||
const adminKeypairPath =
|
||||
env("SMART_ADMIN_KEYPAIR", path.join(os.homedir(), ".config/solana/id.json"));
|
||||
const tokenMintRaw = env("SMART_TOKEN_MINT", "");
|
||||
|
||||
return {
|
||||
rpcUrl,
|
||||
allowAirdrop,
|
||||
programId: PublicKey.default, // placeholder; real value read from the IDL below
|
||||
// Плейсхолдер: настоящий адрес фантичного mint задаётся через SMART_TOKEN_MINT.
|
||||
tokenMint: tokenMintRaw ? new PublicKey(tokenMintRaw) : PublicKey.default,
|
||||
adminKeypairPath,
|
||||
minAmountLamports: envNum("SMART_MIN_AMOUNT", 100_000_000), // 0.1 SOL
|
||||
maxAmountLamports: envNum("SMART_MAX_AMOUNT", 10_000_000_000), // 10 SOL
|
||||
minAmount: envNum("SMART_MIN_AMOUNT", 100_000_000), // 100 фантиков (decimals = 6)
|
||||
maxAmount: envNum("SMART_MAX_AMOUNT", 10_000_000_000), // 10 000 фантиков
|
||||
multiplierBps: envNum("SMART_MULTIPLIER_BPS", 13_000), // 1.3x
|
||||
expirySeconds: envNum("SMART_EXPIRY_SECONDS", 15),
|
||||
};
|
||||
|
||||
@@ -11,7 +11,8 @@ import { SIDE_DOWN, SIDE_UP } from "./config";
|
||||
export interface BetRequest {
|
||||
bettor: Keypair;
|
||||
side: number; // SIDE_UP | SIDE_DOWN
|
||||
amountLamports: number;
|
||||
/** Сумма ставки в базовых юнитах SPL-токена (не лампорты). */
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/** Очередь входящих ставок. В заглушке наполняем случайными значениями. */
|
||||
@@ -20,5 +21,5 @@ export function nextBetRequest(minAmount: number, maxAmount: number): BetRequest
|
||||
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 };
|
||||
return { bettor, side, amount };
|
||||
}
|
||||
|
||||
+36
-11
@@ -1,4 +1,5 @@
|
||||
import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
|
||||
import { getMint } from "@solana/spl-token";
|
||||
import { Program } from "@anchor-lang/core";
|
||||
import { loadConfig, loadKeypair, SIDE_UP } from "./config";
|
||||
import { getPrice } from "./priceSource";
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
closeBet,
|
||||
readGlobal,
|
||||
ensureVaultFunded,
|
||||
ensureBettorTokenAccount,
|
||||
} from "./ops";
|
||||
import { betPda } from "./pda";
|
||||
import { makeConnection, makeProgram } from "./program";
|
||||
@@ -21,6 +23,8 @@ 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);
|
||||
@@ -29,13 +33,23 @@ async function fundBettor(connection: any, bettorPubkey: PublicKey): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
/** Человекочитаемо-формат: токен-юниты -> "N.NNNNNN" по decimals. */
|
||||
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")}`;
|
||||
}
|
||||
|
||||
/** Закрывает ставку, повторяя при «equal price» (exit==entry) пока не зафиналится. */
|
||||
async function settleBet(
|
||||
program: Program<SmartUpdown>,
|
||||
id: any,
|
||||
entryPrice: number,
|
||||
relayer: any,
|
||||
bettor: PublicKey
|
||||
bettor: PublicKey,
|
||||
mint: PublicKey
|
||||
): Promise<void> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const exitPrice = await getPrice();
|
||||
@@ -44,7 +58,7 @@ async function settleBet(
|
||||
await sleep(2000);
|
||||
continue;
|
||||
}
|
||||
const sig = await closeBet(program, id, exitPrice, relayer, bettor);
|
||||
const sig = await closeBet(program, id, exitPrice, relayer, bettor, mint);
|
||||
console.log(`close_bet #${id} exit=${exitPrice} (attempt ${attempt}) tx=${sig}`);
|
||||
|
||||
const status = await getStatus(program, id);
|
||||
@@ -70,6 +84,15 @@ async function main(): Promise<void> {
|
||||
|
||||
console.log(`RPC: ${cfg.rpcUrl}`);
|
||||
console.log(`Admin/relayer: ${admin.publicKey.toBase58()}`);
|
||||
console.log(`Token mint: ${cfg.tokenMint.toBase58()}`);
|
||||
|
||||
// Decimals фантиков (см. state.rs = 6). Только для человекочитаемых логов.
|
||||
let decimals = 6;
|
||||
try {
|
||||
decimals = (await getMint(connection, cfg.tokenMint)).decimals;
|
||||
} catch {
|
||||
// mint ещё не создан (плейсхолдер) — дефолт из ТЗ контракта
|
||||
}
|
||||
|
||||
const global = await readGlobal(program, program.programId);
|
||||
if (!global) {
|
||||
@@ -79,21 +102,21 @@ async function main(): Promise<void> {
|
||||
);
|
||||
}
|
||||
await initialize(program, {
|
||||
minAmount: cfg.minAmountLamports,
|
||||
maxAmount: cfg.maxAmountLamports,
|
||||
minAmount: cfg.minAmount,
|
||||
maxAmount: cfg.maxAmount,
|
||||
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 ` +
|
||||
`Global: counter=${global.counter} vault=${humanize(global.vaultBalance, decimals)} fant ` +
|
||||
`expiry=${global.expirySeconds}s paused=${global.paused}`
|
||||
);
|
||||
}
|
||||
|
||||
if (cfg.allowAirdrop) {
|
||||
await ensureVaultFunded(connection, program, 10 * LAMPORTS_PER_SOL_NUM);
|
||||
await ensureVaultFunded(connection, program, admin, cfg);
|
||||
}
|
||||
|
||||
const loops = process.argv.includes("--once") ? 1 : Infinity;
|
||||
@@ -101,24 +124,26 @@ async function main(): Promise<void> {
|
||||
|
||||
while (n < loops) {
|
||||
n++;
|
||||
const req = nextBetRequest(cfg.minAmountLamports, cfg.maxAmountLamports);
|
||||
const req = nextBetRequest(cfg.minAmount, cfg.maxAmount);
|
||||
await fundBettor(connection, req.bettor.publicKey);
|
||||
// Беттору нужен токен-аккаунт (ATA) — без него create_bet не пройдёт.
|
||||
await ensureBettorTokenAccount(connection, admin, cfg.tokenMint, req.bettor.publicKey);
|
||||
|
||||
const entryPrice = await getPrice();
|
||||
await createBet(program, req.bettor, {
|
||||
await createBet(program, cfg.tokenMint, req.bettor, {
|
||||
side: req.side,
|
||||
amount: req.amountLamports,
|
||||
amount: req.amount,
|
||||
entryPrice,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`ставка ${req.side === SIDE_UP ? "UP" : "DOWN"} на ${(req.amountLamports / LAMPORTS_PER_SOL_NUM).toFixed(3)} SOL, жду ${cfg.expirySeconds}s...`
|
||||
`ставка ${req.side === SIDE_UP ? "UP" : "DOWN"} на ${humanize(req.amount, decimals)} fant, жду ${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);
|
||||
await settleBet(program, id, entryPrice, admin, req.bettor.publicKey, cfg.tokenMint);
|
||||
|
||||
const outcome = await program.account.bet.fetchNullable(betPda(program.programId, id));
|
||||
console.log(
|
||||
|
||||
+91
-25
@@ -1,14 +1,26 @@
|
||||
import { Connection, Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
|
||||
import { Connection, Keypair, PublicKey } from "@solana/web3.js";
|
||||
import {
|
||||
TOKEN_PROGRAM_ID,
|
||||
createAssociatedTokenAccount,
|
||||
getAccount,
|
||||
getAssociatedTokenAddress,
|
||||
getOrCreateAssociatedTokenAccount,
|
||||
mintTo,
|
||||
} from "@solana/spl-token";
|
||||
import BN from "bn.js";
|
||||
import { Program } from "@anchor-lang/core";
|
||||
import { SIDE_UP } from "./config";
|
||||
import { globalPda } from "./pda";
|
||||
import { Config, SIDE_UP } from "./config";
|
||||
import { globalPda, vaultPda } from "./pda";
|
||||
|
||||
import type { SmartUpdown } from "../idl/smart_updown";
|
||||
|
||||
const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL);
|
||||
const BPS_DIVISOR = 10_000n;
|
||||
|
||||
/** Считывает account Global (ваулт). Возвращает null, если ещё не инициализирован. */
|
||||
/**
|
||||
* Считывает account Global + баланс токеного ваулта.
|
||||
* Возвращает null, если глобальное состояние ещё не инициализировано.
|
||||
* Суммы — в базовых юнитах SPL-токена (decimals = 6), не в лампортах.
|
||||
*/
|
||||
export async function readGlobal(
|
||||
program: Program<SmartUpdown>,
|
||||
programId: PublicKey
|
||||
@@ -21,12 +33,21 @@ export async function readGlobal(
|
||||
paused: BN;
|
||||
admin: PublicKey;
|
||||
relayer: PublicKey;
|
||||
lamports: number;
|
||||
/** Баланс vaultToken (PDA-ваула) в токен-юнитах; "0", если аккаунт ещё не создан. */
|
||||
vaultBalance: string;
|
||||
} | 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);
|
||||
let vaultBalance = "0";
|
||||
try {
|
||||
const bal = await program.provider.connection.getTokenAccountBalance(
|
||||
vaultPda(programId)
|
||||
);
|
||||
vaultBalance = bal.value.amount || "0";
|
||||
} catch {
|
||||
vaultBalance = "0";
|
||||
}
|
||||
return {
|
||||
counter: raw.counter,
|
||||
minAmount: raw.minAmount,
|
||||
@@ -36,7 +57,7 @@ export async function readGlobal(
|
||||
paused: raw.paused,
|
||||
admin: raw.admin,
|
||||
relayer: raw.relayer,
|
||||
lamports,
|
||||
vaultBalance,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +79,7 @@ export async function initialize(
|
||||
new BN(args.multiplierBps),
|
||||
new BN(args.expirySeconds)
|
||||
)
|
||||
.accounts({
|
||||
.accountsPartial({
|
||||
admin: admin.publicKey,
|
||||
})
|
||||
.signers([admin])
|
||||
@@ -69,6 +90,7 @@ export async function initialize(
|
||||
/** Создаёт ставку от имени пользователя. Возвращает фактический id (== counter). */
|
||||
export async function createBet(
|
||||
program: Program<SmartUpdown>,
|
||||
mint: PublicKey,
|
||||
bettor: Keypair,
|
||||
args: { side: number; amount: number; entryPrice: number }
|
||||
): Promise<BN> {
|
||||
@@ -77,15 +99,25 @@ export async function createBet(
|
||||
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({
|
||||
.createBet(
|
||||
new BN(id),
|
||||
new BN(args.side),
|
||||
new BN(args.amount),
|
||||
new BN(args.entryPrice)
|
||||
)
|
||||
.accountsPartial({
|
||||
bettor: bettor.publicKey,
|
||||
bettorToken: await getAssociatedTokenAddress(mint, bettor.publicKey),
|
||||
vaultToken: vaultPda(program.programId),
|
||||
tokenProgram: TOKEN_PROGRAM_ID,
|
||||
})
|
||||
.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}`);
|
||||
console.log(
|
||||
`createBet #${id} side=${args.side === SIDE_UP ? "UP" : "DOWN"} ` +
|
||||
`amount=${args.amount} (token base units) entry=${args.entryPrice} tx=${sig}`
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -99,33 +131,67 @@ export async function closeBet(
|
||||
id: BN,
|
||||
exitPrice: number,
|
||||
relayer: Keypair,
|
||||
bettor: PublicKey
|
||||
bettor: PublicKey,
|
||||
mint: PublicKey
|
||||
): Promise<string> {
|
||||
return program.methods
|
||||
.closeBet(new BN(id), new BN(exitPrice))
|
||||
.accounts({
|
||||
.accountsPartial({
|
||||
relayer: relayer.publicKey,
|
||||
bettor,
|
||||
bettorToken: await getAssociatedTokenAddress(mint, bettor),
|
||||
vaultToken: vaultPda(program.programId),
|
||||
tokenProgram: TOKEN_PROGRAM_ID,
|
||||
})
|
||||
.signers([relayer])
|
||||
.rpc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт ATA беттора, если ещё нет (рент платит админ; сам ATA не требует
|
||||
* подписи владельца). Без аккаунта createBet не пройдёт: контракт сам
|
||||
* его не инициализирует.
|
||||
*/
|
||||
export async function ensureBettorTokenAccount(
|
||||
connection: Connection,
|
||||
admin: Keypair,
|
||||
mint: PublicKey,
|
||||
bettor: PublicKey
|
||||
): Promise<void> {
|
||||
const ata = await getAssociatedTokenAddress(mint, bettor);
|
||||
try {
|
||||
await getAccount(connection, ata);
|
||||
} catch {
|
||||
await createAssociatedTokenAccount(connection, admin, mint, bettor);
|
||||
console.log(`bettor ATA created ${bettor.toBase58()} -> ${ata.toBase58()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Догружает ваулт (house bankroll), чтобы хватило на выплаты 1.3x.
|
||||
* На localnet используем airDrop на PDA ваулта; на mainnet воздух не накапает —
|
||||
* там нужно переводить SOL с админа (off-chain, вне этого CLI).
|
||||
* Теперь это ТОКЕН, не SOL: на localnet mint-авторитетом выступает admin
|
||||
* (см. state.rs: decimals = 6), так что наливается через mintTo в PDA-ваулт.
|
||||
* На mainnet наливается off-chain переводом фантиков (вне этого CLI).
|
||||
*/
|
||||
export async function ensureVaultFunded(
|
||||
connection: Connection,
|
||||
program: Program<SmartUpdown>,
|
||||
amountLamports: number
|
||||
admin: Keypair,
|
||||
cfg: Config
|
||||
): 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`);
|
||||
const mint = cfg.tokenMint;
|
||||
const vaultToken = vaultPda(program.programId);
|
||||
|
||||
// PDA-ваулт (ATA с owner = PDA([global, vault])) может не существовать —
|
||||
// контракт его не создаёт. ATA не требует подписи owner (allowOwnerOffCurve),
|
||||
// поэтому создаём её от имени admin.
|
||||
await getOrCreateAssociatedTokenAccount(connection, admin, mint, vaultToken, true);
|
||||
|
||||
const bal = await connection.getTokenAccountBalance(vaultToken);
|
||||
const current = BigInt(bal.value.amount || "0");
|
||||
const target = (BigInt(cfg.maxAmount) * BigInt(cfg.multiplierBps) / BPS_DIVISOR) * 2n;
|
||||
if (current >= target) return;
|
||||
const topUp = target - current;
|
||||
await mintTo(connection, admin, mint, vaultToken, admin, topUp);
|
||||
console.log(`vault funded +${topUp} token base units (target ${target})`);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import BN from "bn.js";
|
||||
/** Seeds, согласованные с контрактом (см. constants.rs). */
|
||||
export const GLOBAL_SEED = Buffer.from("global");
|
||||
export const BET_SEED = Buffer.from("bet");
|
||||
export const VAULT_SEED = Buffer.from("vault");
|
||||
|
||||
export function globalPda(programId: PublicKey): PublicKey {
|
||||
return PublicKey.findProgramAddressSync([GLOBAL_SEED], programId)[0];
|
||||
@@ -14,3 +15,12 @@ export function betPda(programId: PublicKey, id: BN): PublicKey {
|
||||
idLE.writeBigUInt64LE(BigInt(id.toString(10)));
|
||||
return PublicKey.findProgramAddressSync([BET_SEED, idLE], programId)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* PDA токеного ваулта: PDA([GLOBAL_SEED, VAULT_SEED]) — совпадает с контрактом
|
||||
* (vault_token в create_bet/close_bet). Сам по себе global PDA ([GLOBAL_SEED])
|
||||
* ваултом быть не может: это адрес state-аккаунта.
|
||||
*/
|
||||
export function vaultPda(programId: PublicKey): PublicKey {
|
||||
return PublicKey.findProgramAddressSync([GLOBAL_SEED, VAULT_SEED], programId)[0];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user