fix: vault как ATA + фандинг беттора; E2E на localnet ok

This commit is contained in:
Caffeine
2026-09-10 02:12:07 +03:00
parent fb9a7f4e8b
commit f937c8993d
15 changed files with 267 additions and 148 deletions
+17 -36
View File
@@ -73,9 +73,22 @@
{
"name": "vault_token",
"docs": [
"Program-owned token vault: PDA([GLOBAL_SEED, VAULT_SEED]); payouts are paid out from here."
"Token vault ATA (owner = PDA([GLOBAL_SEED, VAULT_SEED])); payouts are paid out from here."
],
"writable": true
},
{
"name": "bettor_token",
"docs": [
"Bettor's SPL-token account; the payout is sent here."
],
"writable": true
},
{
"name": "vault_authority",
"docs": [
"It only serves as the CPI signing authority for the vault ATA payout, so no owner/type check is needed."
],
"writable": true,
"pda": {
"seeds": [
{
@@ -102,13 +115,6 @@
]
}
},
{
"name": "bettor_token",
"docs": [
"Bettor's SPL-token account; the payout is sent here."
],
"writable": true
},
{
"name": "token_program",
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
@@ -200,34 +206,9 @@
{
"name": "vault_token",
"docs": [
"Program-owned token vault: PDA([GLOBAL_SEED, VAULT_SEED]); bet amounts are escrowed here."
"Token vault ATA (owner = PDA([GLOBAL_SEED, VAULT_SEED])); bet amounts are escrowed here."
],
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
},
{
"kind": "const",
"value": [
118,
97,
117,
108,
116
]
}
]
}
"writable": true
},
{
"name": "token_program",
+17 -36
View File
@@ -78,9 +78,22 @@ export type SmartUpdown = {
{
"name": "vaultToken",
"docs": [
"Program-owned token vault: PDA([GLOBAL_SEED, VAULT_SEED]); payouts are paid out from here."
"Token vault ATA (owner = PDA([GLOBAL_SEED, VAULT_SEED])); payouts are paid out from here."
],
"writable": true
},
{
"name": "bettorToken",
"docs": [
"Bettor's SPL-token account; the payout is sent here."
],
"writable": true
},
{
"name": "vaultAuthority",
"docs": [
"It only serves as the CPI signing authority for the vault ATA payout, so no owner/type check is needed."
],
"writable": true,
"pda": {
"seeds": [
{
@@ -107,13 +120,6 @@ export type SmartUpdown = {
]
}
},
{
"name": "bettorToken",
"docs": [
"Bettor's SPL-token account; the payout is sent here."
],
"writable": true
},
{
"name": "tokenProgram",
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
@@ -205,34 +211,9 @@ export type SmartUpdown = {
{
"name": "vaultToken",
"docs": [
"Program-owned token vault: PDA([GLOBAL_SEED, VAULT_SEED]); bet amounts are escrowed here."
"Token vault ATA (owner = PDA([GLOBAL_SEED, VAULT_SEED]); bet amounts are escrowed here."
],
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
},
{
"kind": "const",
"value": [
118,
97,
117,
108,
116
]
}
]
}
"writable": true
},
{
"name": "tokenProgram",
+9 -5
View File
@@ -58,12 +58,16 @@ async function main(): Promise<void> {
"betPda(0) совпадает с седами bet из IDL"
);
check(
vaultPda(programId).equals(pdaFromIdlSeeds(programId, (acct(createIx, "vault_token") as any).pda.seeds)),
"vaultPda совпадает с седами vault_token из IDL"
vaultPda(programId).equals(pdaFromIdlSeeds(programId, (acct(closeIx, "vault_authority") as any).pda.seeds)),
"vaultPda совпадает с седами vault_authority в close_bet"
);
check(
vaultPda(programId).equals(pdaFromIdlSeeds(programId, (acct(closeIx, "vault_token") as any).pda.seeds)),
"vaultPda совпадает с седами vault_token в close_bet"
(acct(createIx, "vault_token") as any).pda === undefined,
"create_bet: vault_token — обычный ATA-счёт (без PDA/seeds)"
);
check(
(acct(closeIx, "vault_token") as any).pda === undefined,
"close_bet: vault_token — обычный ATA-счёт (без PDA/seeds)"
);
// 2. Сборка инструкций оффлайн и проверка ключей
@@ -114,7 +118,7 @@ async function main(): Promise<void> {
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-состав)");
check(close.keys.length === 9, "close_bet: 9 аккаунтов (IDL-состав)");
if (failed > 0) {
console.error(`\ncheck: ${failed} проверок ПРОВАЛЕНО`);
+10
View File
@@ -30,6 +30,13 @@ export interface Config {
/** Bet amount bounds in token base units (NOT lamports); must match the on-chain `initialize` values. */
minAmount: number;
maxAmount: number;
/**
* How many "фантики" (token base units) the localnet relayer mints into each
* bettor's ATA on start (analog of the SOL airdrop). Must cover the max
* bet size so a freshly funded bettor can place a full-size bet. Only used
* when `allowAirdrop` is true (localnet).
*/
startBalance: number;
multiplierBps: number;
expirySeconds: number;
}
@@ -64,6 +71,9 @@ export function loadConfig(): Config {
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),
// Стартовый баланс фантиков, который локальный релейер mintTo'ит беттору
// (аналог SOL-airdrop). По умолчанию равен maxAmount — хватает на максимум ставки.
startBalance: envNum("SMART_START_BALANCE", 10_000_000_000),
};
}
+10 -3
View File
@@ -94,7 +94,7 @@ async function main(): Promise<void> {
// mint ещё не создан (плейсхолдер) — дефолт из ТЗ контракта
}
const global = await readGlobal(program, program.programId);
const global = await readGlobal(program, program.programId, cfg.tokenMint);
if (!global) {
if (!process.argv.includes("--init")) {
throw new Error(
@@ -127,7 +127,14 @@ async function main(): Promise<void> {
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);
// На localnet (allowAirdrop) после создания ATA наливает фантики (аналог SOL-airdrop).
await ensureBettorTokenAccount(
connection,
admin,
cfg.tokenMint,
req.bettor.publicKey,
cfg.allowAirdrop ? BigInt(cfg.startBalance) : undefined
);
const entryPrice = await getPrice();
await createBet(program, cfg.tokenMint, req.bettor, {
@@ -141,7 +148,7 @@ async function main(): Promise<void> {
);
await sleep((cfg.expirySeconds + 1) * 1000);
const g0 = await readGlobal(program, program.programId);
const g0 = await readGlobal(program, program.programId, cfg.tokenMint);
const id = g0!.counter.subn(1);
await settleBet(program, id, entryPrice, admin, req.bettor.publicKey, cfg.tokenMint);
+34 -16
View File
@@ -23,7 +23,8 @@ const BPS_DIVISOR = 10_000n;
*/
export async function readGlobal(
program: Program<SmartUpdown>,
programId: PublicKey
programId: PublicKey,
mint: PublicKey
): Promise<{
counter: BN;
minAmount: BN;
@@ -33,7 +34,7 @@ export async function readGlobal(
paused: BN;
admin: PublicKey;
relayer: PublicKey;
/** Баланс vaultToken (PDA-ваула) в токен-юнитах; "0", если аккаунт ещё не создан. */
/** Баланс vault ATA (owner = PDA-ваула) в токен-юнитах; "0", если аккаунт ещё не создан. */
vaultBalance: string;
} | null> {
const global = globalPda(programId);
@@ -41,9 +42,8 @@ export async function readGlobal(
if (!raw) return null;
let vaultBalance = "0";
try {
const bal = await program.provider.connection.getTokenAccountBalance(
vaultPda(programId)
);
const vaultAta = await getAssociatedTokenAddress(mint, vaultPda(programId), true);
const bal = await program.provider.connection.getTokenAccountBalance(vaultAta);
vaultBalance = bal.value.amount || "0";
} catch {
vaultBalance = "0";
@@ -94,7 +94,7 @@ export async function createBet(
bettor: Keypair,
args: { side: number; amount: number; entryPrice: number }
): Promise<BN> {
const global = await readGlobal(program, program.programId);
const global = await readGlobal(program, program.programId, mint);
if (!global) throw new Error("Contract not initialized");
const id = global.counter;
@@ -108,7 +108,7 @@ export async function createBet(
.accountsPartial({
bettor: bettor.publicKey,
bettorToken: await getAssociatedTokenAddress(mint, bettor.publicKey),
vaultToken: vaultPda(program.programId),
vaultToken: await getAssociatedTokenAddress(mint, vaultPda(program.programId), true),
tokenProgram: TOKEN_PROGRAM_ID,
})
.signers([bettor])
@@ -140,7 +140,7 @@ export async function closeBet(
relayer: relayer.publicKey,
bettor,
bettorToken: await getAssociatedTokenAddress(mint, bettor),
vaultToken: vaultPda(program.programId),
vaultToken: await getAssociatedTokenAddress(mint, vaultPda(program.programId), true),
tokenProgram: TOKEN_PROGRAM_ID,
})
.signers([relayer])
@@ -151,12 +151,17 @@ export async function closeBet(
* Создаёт ATA беттора, если ещё нет (рент платит админ; сам ATA не требует
* подписи владельца). Без аккаунта createBet не пройдёт: контракт сам
* его не инициализирует.
*
* При `startBalance` !== undefined (только localnet, `cfg.allowAirdrop === true`)
* после создания ATA доналивает беттору фантики до startBalance (аналог
* SOL-airdrop, swap ещё не подключён). `admin` — mint authority.
*/
export async function ensureBettorTokenAccount(
connection: Connection,
admin: Keypair,
mint: PublicKey,
bettor: PublicKey
bettor: PublicKey,
startBalance?: bigint
): Promise<void> {
const ata = await getAssociatedTokenAddress(mint, bettor);
try {
@@ -165,13 +170,25 @@ export async function ensureBettorTokenAccount(
await createAssociatedTokenAccount(connection, admin, mint, bettor);
console.log(`bettor ATA created ${bettor.toBase58()} -> ${ata.toBase58()}`);
}
if (startBalance !== undefined) {
const bal = await getAccount(connection, ata);
const current = BigInt(bal.amount);
const topUp = startBalance - current;
if (topUp > 0n) {
await mintTo(connection, admin, mint, ata, admin, topUp);
console.log(
`bettor funded ${bettor.toBase58()} +${topUp} fant base units (ATA total ${startBalance})`
);
}
}
}
/**
* Догружает ваулт (house bankroll), чтобы хватило на выплаты 1.3x.
* Теперь это ТОКЕН, не SOL: на localnet mint-авторитетом выступает admin
* (см. state.rs: decimals = 6), так что наливается через mintTo в PDA-ваулт.
* На mainnet наливается off-chain переводом фантиков (вне этого CLI).
* (см. state.rs: decimals = 6), так что наливается через mintTo в ATA-ваулт
* (токен-счёт с owner = PDA([global, vault])). На mainnet наливается
* off-chain переводом фантиков (вне этого CLI).
*/
export async function ensureVaultFunded(
connection: Connection,
@@ -180,18 +197,19 @@ export async function ensureVaultFunded(
cfg: Config
): Promise<void> {
const mint = cfg.tokenMint;
const vaultToken = vaultPda(program.programId);
const vault = vaultPda(program.programId);
const vaultAta = await getAssociatedTokenAddress(mint, vault, true);
// PDA-ваулт (ATA с owner = PDA([global, vault])) может не существовать —
// ATA-ваулт (owner = PDA([global, vault])) может не существовать —
// контракт его не создаёт. ATA не требует подписи owner (allowOwnerOffCurve),
// поэтому создаём её от имени admin.
await getOrCreateAssociatedTokenAccount(connection, admin, mint, vaultToken, true);
await getOrCreateAssociatedTokenAccount(connection, admin, mint, vault, true);
const bal = await connection.getTokenAccountBalance(vaultToken);
const bal = await connection.getTokenAccountBalance(vaultAta);
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);
await mintTo(connection, admin, mint, vaultAta, admin, topUp);
console.log(`vault funded +${topUp} token base units (target ${target})`);
}