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:
Caffeine
2026-09-10 00:19:21 +03:00
parent 45d64d45a7
commit fb9a7f4e8b
12 changed files with 1726 additions and 71 deletions
+56
View File
@@ -0,0 +1,56 @@
# TASK — Фаза 2: перевести релейер с SOL на фантики (SPL-токен)
Репозиторий: `smart-updown-token` (тот, где контракт уже на токене). Работаем ТОЛЬКО в `relayer/`.
Контракт уже переведён (Фаза 1, коммит 45d64d4) — релейер теперь должен звать его токеновые инструкции.
## Контекст
- Контракт переведён: `create_bet` escrow'ит фантики bettor→vault; `close_bet` платит vault→bettor.
- Новые аккаунты в инструкциях: `bettor_token`, `vault_token`, `token_program`. IDL уже содержит их
(в `relayer/idl/smart_updown.json` — регенерирован).
- Сейчас `relayer/` заточен под SOL: `readGlobal` берёт `getBalance(global)`, `ensureVaultFunded`
airdrop'ит SOL, `createBet/closeBet` не передают токен-аккаунты.
## Что сделать (в `relayer/src/` и `relayer/`)
### 1. `pda.ts`
- Добавить `vaultPda(programId): PublicKey` — PDA по седам `[b"global", b"vault"]` (совпадает с контрактом).
- (Убедиться, что `globalPda` и `betPda` соответствуют контракту.)
### 2. `config.ts`
- Добавить `tokenMint: PublicKey` (mint наших фантиков) в конфиг/cfg, с default-плейсхолдером.
- Проверить `.env.example` — добавить переменную для mint (напр. `TOKEN_MINT`).
### 3. `ops.ts`
- **`readGlobal`**: вместо `getBalance(global)` (SOL) — читать **баланс `vault_token`** (PDA `vaultPda`),
через connection/getTokenAccountBalance (или через spl-token). Поле, что возвращаем, переименовать/пометить
как `vaultBalance` (в токен-единицах).
- **`createBet`**: в `.accounts` добавить `bettor_token` (ATA bettor), `vault_token` (vaultPda), `token_program`.
`min/amount` — уже в токен-единицах (не lamports), логировать в токене.
- **`closeBet`**: в `.accounts` добавить `bettor_token` (ATA bettor по адресу из bet), `vault_token`,
`token_program`.
- **`ensureVaultFunded`**: теперь не airDrop SOL в global, а **наливать токен в vault_token** — на localnet
можно mint'ить mint-авторити (токен создать/налить). Либо вызывать mint по необходимости. Не SOL.
### 4. `index.ts` (главный цикл)
- Привести в соответствие: вызовы `readGlobal`/`createBet`/`closeBet`/`ensureVaultFunded` — с новыми сигнатурами.
- Логирование: суммы в токене, а не в SOL.
### 5. Тесты
- Обновить интеграционный тест релейера на токеновом флоу (если есть в `relayer/`): создать mint/ATA,
налить токен, прогнать create→close. `npm test` / `ts-node` зелёный.
## Гейты (после каждого шага)
- `npm run build` (или `tsc`) — компилируется без ошибок.
- В конце: релейер собирается, сигнатуры соответствуют новому IDL.
## ЧЕГО НЕ ДЕЛАТЬ
- Не трогать контракт (`program/`).
- Не коммитить — всё в рабочем дереве до приёмки человеком.
- Не менять `program/` и `deploy/`.
- Логику игры (вверх/вниз, статусы) не менять.
## Проверка (что релеер готов)
- `npm run build` — 0 ошибок.
- Сигнатуры `createBet`/`closeBet` передают `bettor_token`/`vault_token`/`token_program`.
- `ensureVaultFunded` наливает токен, а не SOL.
- Логи/цикл работают с токен-единицами.
+13 -5
View File
@@ -1,14 +1,22 @@
# RPC эндпоинт кластера (localnet = запущенный solana-test-validator) # RPC эндпоинт кластера (localnet = запущенный solana-test-validator)
SMART_RPC_URL=http://127.0.0.1:8899 SMART_RPC_URL=http://127.0.0.1:8899
# Разрешить airDrop на localnet (bettor'ам и ваулту). На mainnet выключить. # Mint наших фантиков (SPL-токен, decimals = 6; см. state.rs контракта).
# На localnet — адрес mint, созданный до старта релейера (mint_authority = админ).
# Обязателен: без него ATA/ваулт нельзя привязать к нужному токену.
SMART_TOKEN_MINT=
# Разрешить «наивное» финансирование на localnet: SOL-airDrop бетторам
# (ренты/комиссии) + налив фантиков в ваулт через mint authority (ensureVaultFunded).
# На mainnet выключить — ваулт там финансируется off-chain.
SMART_ALLOW_AIRDROP=1 SMART_ALLOW_AIRDROP=1
# Путь к keypair админа/релеера (тот же ключ = admin и relayer) # Путь к keypair админа/релеера (тот же ключ = admin и relayer; на localnet ещё и mint authority)
SMART_ADMIN_KEYPAIR=~/.config/solana/id.json SMART_ADMIN_KEYPAIR=~/.config/solana/id.json
# Параметры контракта (должны совпадать со значениями при initialize) # Параметры контракта (должны совпадать со значениями при initialize).
SMART_MIN_AMOUNT=100000000 # 0.1 SOL в лампортах # ВАЖНО: теперь это ТОКЕН-юниты (base units фантиков, decimals = 6), а не лампорты!
SMART_MAX_AMOUNT=10000000000 # 10 SOL в лампортах SMART_MIN_AMOUNT=100000000 # 100 фантиков
SMART_MAX_AMOUNT=10000000000 # 10 000 фантиков
SMART_MULTIPLIER_BPS=13000 # 1.3x выплата в базисных пунктах SMART_MULTIPLIER_BPS=13000 # 1.3x выплата в базисных пунктах
SMART_EXPIRY_SECONDS=15 # экспирация ставки (секунды) SMART_EXPIRY_SECONDS=15 # экспирация ставки (секунды)
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+141 -21
View File
@@ -1,8 +1,7 @@
/** /**
* Program IDL in camelCase format in order to be used in JS/TS. * Program IDL in camelCase format (see @anchor-lang/core convertIdlToCamelCase).
* * Mirrors relayer/idl/smart_updown.json but with names camelCased, exactly as
* Note that this is only a type helper and is not the actual IDL. The original * the runtime Program instance exposes them (methods/accounts/types).
* IDL can be found at `target/idl/smart_updown.json`.
*/ */
export type SmartUpdown = { export type SmartUpdown = {
"address": "9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf", "address": "9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf",
@@ -16,7 +15,14 @@ export type SmartUpdown = {
{ {
"name": "closeBet", "name": "closeBet",
"discriminator": [ "discriminator": [
2 185,
206,
13,
184,
176,
108,
140,
107
], ],
"accounts": [ "accounts": [
{ {
@@ -63,15 +69,54 @@ export type SmartUpdown = {
} }
}, },
{ {
"name": "bettor", "name": "bettor"
"docs": [
"CHECK: recipient must be the bet's bettor and is validated in the handler."
],
"writable": true
}, },
{ {
"name": "clock", "name": "clock",
"address": "SysvarC1ock11111111111111111111111111111111" "address": "SysvarC1ock11111111111111111111111111111111"
},
{
"name": "vaultToken",
"docs": [
"Program-owned token vault: PDA([GLOBAL_SEED, VAULT_SEED]); payouts are paid out from here."
],
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
},
{
"kind": "const",
"value": [
118,
97,
117,
108,
116
]
}
]
}
},
{
"name": "bettorToken",
"docs": [
"Bettor's SPL-token account; the payout is sent here."
],
"writable": true
},
{
"name": "tokenProgram",
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
} }
], ],
"args": [ "args": [
@@ -88,7 +133,14 @@ export type SmartUpdown = {
{ {
"name": "createBet", "name": "createBet",
"discriminator": [ "discriminator": [
1 197,
42,
153,
2,
59,
63,
143,
246
], ],
"accounts": [ "accounts": [
{ {
@@ -142,6 +194,49 @@ export type SmartUpdown = {
{ {
"name": "systemProgram", "name": "systemProgram",
"address": "11111111111111111111111111111111" "address": "11111111111111111111111111111111"
},
{
"name": "bettorToken",
"docs": [
"Bettor's SPL-token account (our \"fanti\" mint); bet amount is pulled from here."
],
"writable": true
},
{
"name": "vaultToken",
"docs": [
"Program-owned token vault: 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
]
}
]
}
},
{
"name": "tokenProgram",
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
} }
], ],
"args": [ "args": [
@@ -166,7 +261,14 @@ export type SmartUpdown = {
{ {
"name": "initialize", "name": "initialize",
"discriminator": [ "discriminator": [
0 175,
175,
109,
31,
13,
152,
155,
237
], ],
"accounts": [ "accounts": [
{ {
@@ -220,7 +322,14 @@ export type SmartUpdown = {
{ {
"name": "update", "name": "update",
"discriminator": [ "discriminator": [
3 219,
200,
88,
176,
158,
63,
253,
127
], ],
"accounts": [ "accounts": [
{ {
@@ -386,10 +495,9 @@ export type SmartUpdown = {
"types": [ "types": [
{ {
"name": "bet", "name": "bet",
"serialization": "bytemuck", "docs": [
"repr": { "Open bet. `amount` is in token base units (decimals = 6), not lamports."
"kind": "c" ],
},
"type": { "type": {
"kind": "struct", "kind": "struct",
"fields": [ "fields": [
@@ -494,10 +602,13 @@ export type SmartUpdown = {
}, },
{ {
"name": "global", "name": "global",
"serialization": "bytemuck", "docs": [
"repr": { "Global game state.",
"kind": "c" "",
}, "All money-related fields are denominated in **SPL-token base units**",
"(our \"fanti\" token, decimals = 6), not in lamports:",
"`min_amount`/`max_amount` bound a bet amount in token units."
],
"type": { "type": {
"kind": "struct", "kind": "struct",
"fields": [ "fields": [
@@ -547,6 +658,15 @@ export type SmartUpdown = {
"name": "globalSeed", "name": "globalSeed",
"type": "bytes", "type": "bytes",
"value": "[103, 108, 111, 98, 97, 108]" "value": "[103, 108, 111, 98, 97, 108]"
},
{
"name": "vaultSeed",
"docs": [
"Vault PDA seed: the token vault lives at PDA([GLOBAL_SEED, VAULT_SEED]).",
"It cannot reuse GLOBAL_SEED alone — that PDA address is the `global` state account itself."
],
"type": "bytes",
"value": "[118, 97, 117, 108, 116]"
} }
] ]
}; };
+1224
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -4,13 +4,16 @@
"private": true, "private": true,
"license": "ISC", "license": "ISC",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"test": "ts-node src/check.ts",
"init": "ts-node src/index.ts --init --once", "init": "ts-node src/index.ts --init --once",
"run": "ts-node src/index.ts --once", "run": "ts-node src/index.ts --once",
"loop": "ts-node src/index.ts" "loop": "ts-node src/index.ts"
}, },
"dependencies": { "dependencies": {
"@anchor-lang/core": "^1.0.0", "@anchor-lang/core": "^1.0.0",
"@solana/spl-token": "^0.4.15",
"@solana/web3.js": "^1.98.0", "@solana/web3.js": "^1.98.0",
"bn.js": "^5.2.0" "bn.js": "^5.2.0"
}, },
+129
View File
@@ -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
View File
@@ -13,15 +13,23 @@ export const SIDE_DOWN = 1;
export interface Config { export interface Config {
/** RPC endpoint of the cluster (localnet by default). */ /** RPC endpoint of the cluster (localnet by default). */
rpcUrl: string; 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; allowAirdrop: boolean;
/** Program id. Defaults to the value embedded in the IDL. */ /** Program id. Defaults to the value embedded in the IDL. */
programId: PublicKey; 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; adminKeypairPath: string;
/** Initial parameters used by `init` (must match on-chain constants). */ /** Bet amount bounds in token base units (NOT lamports); must match the on-chain `initialize` values. */
minAmountLamports: number; minAmount: number;
maxAmountLamports: number; maxAmount: number;
multiplierBps: number; multiplierBps: number;
expirySeconds: number; expirySeconds: number;
} }
@@ -43,14 +51,17 @@ export function loadConfig(): Config {
const allowAirdrop = env("SMART_ALLOW_AIRDROP", "1") === "1"; const allowAirdrop = env("SMART_ALLOW_AIRDROP", "1") === "1";
const adminKeypairPath = const adminKeypairPath =
env("SMART_ADMIN_KEYPAIR", path.join(os.homedir(), ".config/solana/id.json")); env("SMART_ADMIN_KEYPAIR", path.join(os.homedir(), ".config/solana/id.json"));
const tokenMintRaw = env("SMART_TOKEN_MINT", "");
return { return {
rpcUrl, rpcUrl,
allowAirdrop, allowAirdrop,
programId: PublicKey.default, // placeholder; real value read from the IDL below programId: PublicKey.default, // placeholder; real value read from the IDL below
// Плейсхолдер: настоящий адрес фантичного mint задаётся через SMART_TOKEN_MINT.
tokenMint: tokenMintRaw ? new PublicKey(tokenMintRaw) : PublicKey.default,
adminKeypairPath, adminKeypairPath,
minAmountLamports: envNum("SMART_MIN_AMOUNT", 100_000_000), // 0.1 SOL minAmount: envNum("SMART_MIN_AMOUNT", 100_000_000), // 100 фантиков (decimals = 6)
maxAmountLamports: envNum("SMART_MAX_AMOUNT", 10_000_000_000), // 10 SOL maxAmount: envNum("SMART_MAX_AMOUNT", 10_000_000_000), // 10 000 фантиков
multiplierBps: envNum("SMART_MULTIPLIER_BPS", 13_000), // 1.3x multiplierBps: envNum("SMART_MULTIPLIER_BPS", 13_000), // 1.3x
expirySeconds: envNum("SMART_EXPIRY_SECONDS", 15), expirySeconds: envNum("SMART_EXPIRY_SECONDS", 15),
}; };
+3 -2
View File
@@ -11,7 +11,8 @@ import { SIDE_DOWN, SIDE_UP } from "./config";
export interface BetRequest { export interface BetRequest {
bettor: Keypair; bettor: Keypair;
side: number; // SIDE_UP | SIDE_DOWN 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 side = Math.random() < 0.5 ? SIDE_UP : SIDE_DOWN;
const amount = const amount =
minAmount + Math.floor(Math.random() * (maxAmount - minAmount + 1)); minAmount + Math.floor(Math.random() * (maxAmount - minAmount + 1));
return { bettor, side, amountLamports: amount }; return { bettor, side, amount };
} }
+36 -11
View File
@@ -1,4 +1,5 @@
import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"; import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import { getMint } from "@solana/spl-token";
import { Program } from "@anchor-lang/core"; import { Program } from "@anchor-lang/core";
import { loadConfig, loadKeypair, SIDE_UP } from "./config"; import { loadConfig, loadKeypair, SIDE_UP } from "./config";
import { getPrice } from "./priceSource"; import { getPrice } from "./priceSource";
@@ -9,6 +10,7 @@ import {
closeBet, closeBet,
readGlobal, readGlobal,
ensureVaultFunded, ensureVaultFunded,
ensureBettorTokenAccount,
} from "./ops"; } from "./ops";
import { betPda } from "./pda"; import { betPda } from "./pda";
import { makeConnection, makeProgram } from "./program"; import { makeConnection, makeProgram } from "./program";
@@ -21,6 +23,8 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const STATUS_OPEN = 0; const STATUS_OPEN = 0;
async function fundBettor(connection: any, bettorPubkey: PublicKey): Promise<void> { async function fundBettor(connection: any, bettorPubkey: PublicKey): Promise<void> {
// SOL всё ещё нужен беттору: рент PDA-аккаунта ставки + комиссия за tx.
// Саму ставку (фантики) он закрывает из своего токен-аккаунта (ATA).
const bal = await connection.getBalance(bettorPubkey); const bal = await connection.getBalance(bettorPubkey);
if (bal < 2 * LAMPORTS_PER_SOL_NUM) { if (bal < 2 * LAMPORTS_PER_SOL_NUM) {
await connection.requestAirdrop(bettorPubkey, 5 * 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) пока не зафиналится. */ /** Закрывает ставку, повторяя при «equal price» (exit==entry) пока не зафиналится. */
async function settleBet( async function settleBet(
program: Program<SmartUpdown>, program: Program<SmartUpdown>,
id: any, id: any,
entryPrice: number, entryPrice: number,
relayer: any, relayer: any,
bettor: PublicKey bettor: PublicKey,
mint: PublicKey
): Promise<void> { ): Promise<void> {
for (let attempt = 1; ; attempt++) { for (let attempt = 1; ; attempt++) {
const exitPrice = await getPrice(); const exitPrice = await getPrice();
@@ -44,7 +58,7 @@ async function settleBet(
await sleep(2000); await sleep(2000);
continue; 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}`); console.log(`close_bet #${id} exit=${exitPrice} (attempt ${attempt}) tx=${sig}`);
const status = await getStatus(program, id); const status = await getStatus(program, id);
@@ -70,6 +84,15 @@ async function main(): Promise<void> {
console.log(`RPC: ${cfg.rpcUrl}`); console.log(`RPC: ${cfg.rpcUrl}`);
console.log(`Admin/relayer: ${admin.publicKey.toBase58()}`); 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); const global = await readGlobal(program, program.programId);
if (!global) { if (!global) {
@@ -79,21 +102,21 @@ async function main(): Promise<void> {
); );
} }
await initialize(program, { await initialize(program, {
minAmount: cfg.minAmountLamports, minAmount: cfg.minAmount,
maxAmount: cfg.maxAmountLamports, maxAmount: cfg.maxAmount,
multiplierBps: cfg.multiplierBps, multiplierBps: cfg.multiplierBps,
expirySeconds: cfg.expirySeconds, expirySeconds: cfg.expirySeconds,
}, admin); }, admin);
console.log("Контракт инициализирован."); console.log("Контракт инициализирован.");
} else { } else {
console.log( 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}` `expiry=${global.expirySeconds}s paused=${global.paused}`
); );
} }
if (cfg.allowAirdrop) { 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; const loops = process.argv.includes("--once") ? 1 : Infinity;
@@ -101,24 +124,26 @@ async function main(): Promise<void> {
while (n < loops) { while (n < loops) {
n++; n++;
const req = nextBetRequest(cfg.minAmountLamports, cfg.maxAmountLamports); const req = nextBetRequest(cfg.minAmount, cfg.maxAmount);
await fundBettor(connection, req.bettor.publicKey); await fundBettor(connection, req.bettor.publicKey);
// Беттору нужен токен-аккаунт (ATA) — без него create_bet не пройдёт.
await ensureBettorTokenAccount(connection, admin, cfg.tokenMint, req.bettor.publicKey);
const entryPrice = await getPrice(); const entryPrice = await getPrice();
await createBet(program, req.bettor, { await createBet(program, cfg.tokenMint, req.bettor, {
side: req.side, side: req.side,
amount: req.amountLamports, amount: req.amount,
entryPrice, entryPrice,
}); });
console.log( 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); await sleep((cfg.expirySeconds + 1) * 1000);
const g0 = await readGlobal(program, program.programId); const g0 = await readGlobal(program, program.programId);
const id = g0!.counter.subn(1); 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)); const outcome = await program.account.bet.fetchNullable(betPda(program.programId, id));
console.log( console.log(
+91 -25
View File
@@ -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 BN from "bn.js";
import { Program } from "@anchor-lang/core"; import { Program } from "@anchor-lang/core";
import { SIDE_UP } from "./config"; import { Config, SIDE_UP } from "./config";
import { globalPda } from "./pda"; import { globalPda, vaultPda } from "./pda";
import type { SmartUpdown } from "../idl/smart_updown"; 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( export async function readGlobal(
program: Program<SmartUpdown>, program: Program<SmartUpdown>,
programId: PublicKey programId: PublicKey
@@ -21,12 +33,21 @@ export async function readGlobal(
paused: BN; paused: BN;
admin: PublicKey; admin: PublicKey;
relayer: PublicKey; relayer: PublicKey;
lamports: number; /** Баланс vaultToken (PDA-ваула) в токен-юнитах; "0", если аккаунт ещё не создан. */
vaultBalance: string;
} | null> { } | null> {
const global = globalPda(programId); const global = globalPda(programId);
const raw = await program.account.global.fetchNullable(global); const raw = await program.account.global.fetchNullable(global);
if (!raw) return null; 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 { return {
counter: raw.counter, counter: raw.counter,
minAmount: raw.minAmount, minAmount: raw.minAmount,
@@ -36,7 +57,7 @@ export async function readGlobal(
paused: raw.paused, paused: raw.paused,
admin: raw.admin, admin: raw.admin,
relayer: raw.relayer, relayer: raw.relayer,
lamports, vaultBalance,
}; };
} }
@@ -58,7 +79,7 @@ export async function initialize(
new BN(args.multiplierBps), new BN(args.multiplierBps),
new BN(args.expirySeconds) new BN(args.expirySeconds)
) )
.accounts({ .accountsPartial({
admin: admin.publicKey, admin: admin.publicKey,
}) })
.signers([admin]) .signers([admin])
@@ -69,6 +90,7 @@ export async function initialize(
/** Создаёт ставку от имени пользователя. Возвращает фактический id (== counter). */ /** Создаёт ставку от имени пользователя. Возвращает фактический id (== counter). */
export async function createBet( export async function createBet(
program: Program<SmartUpdown>, program: Program<SmartUpdown>,
mint: PublicKey,
bettor: Keypair, bettor: Keypair,
args: { side: number; amount: number; entryPrice: number } args: { side: number; amount: number; entryPrice: number }
): Promise<BN> { ): Promise<BN> {
@@ -77,15 +99,25 @@ export async function createBet(
const id = global.counter; const id = global.counter;
const sig = await program.methods const sig = await program.methods
.createBet(new BN(id), new BN(args.side), new BN(args.amount), new BN(args.entryPrice)) .createBet(
.accounts({ new BN(id),
new BN(args.side),
new BN(args.amount),
new BN(args.entryPrice)
)
.accountsPartial({
bettor: bettor.publicKey, bettor: bettor.publicKey,
bettorToken: await getAssociatedTokenAddress(mint, bettor.publicKey),
vaultToken: vaultPda(program.programId),
tokenProgram: TOKEN_PROGRAM_ID,
}) })
.signers([bettor]) .signers([bettor])
.rpc(); .rpc();
console.log(`create_bet #${id} side=${args.side === SIDE_UP ? "UP" : "DOWN"} ` + console.log(
`amount=${(args.amount / LAMPORTS_PER_SOL_NUM).toFixed(3)} SOL entry=${args.entryPrice} tx=${sig}`); `createBet #${id} side=${args.side === SIDE_UP ? "UP" : "DOWN"} ` +
`amount=${args.amount} (token base units) entry=${args.entryPrice} tx=${sig}`
);
return id; return id;
} }
@@ -99,33 +131,67 @@ export async function closeBet(
id: BN, id: BN,
exitPrice: number, exitPrice: number,
relayer: Keypair, relayer: Keypair,
bettor: PublicKey bettor: PublicKey,
mint: PublicKey
): Promise<string> { ): Promise<string> {
return program.methods return program.methods
.closeBet(new BN(id), new BN(exitPrice)) .closeBet(new BN(id), new BN(exitPrice))
.accounts({ .accountsPartial({
relayer: relayer.publicKey, relayer: relayer.publicKey,
bettor, bettor,
bettorToken: await getAssociatedTokenAddress(mint, bettor),
vaultToken: vaultPda(program.programId),
tokenProgram: TOKEN_PROGRAM_ID,
}) })
.signers([relayer]) .signers([relayer])
.rpc(); .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. * Догружает ваулт (house bankroll), чтобы хватило на выплаты 1.3x.
* На localnet используем airDrop на PDA ваулта; на mainnet воздух не накапает — * Теперь это ТОКЕН, не SOL: на localnet mint-авторитетом выступает admin
* там нужно переводить SOL с админа (off-chain, вне этого CLI). * (см. state.rs: decimals = 6), так что наливается через mintTo в PDA-ваулт.
* На mainnet наливается off-chain переводом фантиков (вне этого CLI).
*/ */
export async function ensureVaultFunded( export async function ensureVaultFunded(
connection: Connection, connection: Connection,
program: Program<SmartUpdown>, program: Program<SmartUpdown>,
amountLamports: number admin: Keypair,
cfg: Config
): Promise<void> { ): Promise<void> {
const global = globalPda(program.programId); const mint = cfg.tokenMint;
const current = await connection.getBalance(global); const vaultToken = vaultPda(program.programId);
const needed = amountLamports; // будущая выплата (до 1.3x), запасаем с запасом
if (current >= needed) return; // PDA-ваулт (ATA с owner = PDA([global, vault])) может не существовать —
const topUp = needed - current + 5 * LAMPORTS_PER_SOL_NUM; // +5 SOL запаса // контракт его не создаёт. ATA не требует подписи owner (allowOwnerOffCurve),
await connection.requestAirdrop(global, topUp); // поэтому создаём её от имени admin.
console.log(`vault funded +${(topUp / LAMPORTS_PER_SOL_NUM).toFixed(2)} SOL`); 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})`);
} }
+10
View File
@@ -4,6 +4,7 @@ import BN from "bn.js";
/** Seeds, согласованные с контрактом (см. constants.rs). */ /** Seeds, согласованные с контрактом (см. constants.rs). */
export const GLOBAL_SEED = Buffer.from("global"); export const GLOBAL_SEED = Buffer.from("global");
export const BET_SEED = Buffer.from("bet"); export const BET_SEED = Buffer.from("bet");
export const VAULT_SEED = Buffer.from("vault");
export function globalPda(programId: PublicKey): PublicKey { export function globalPda(programId: PublicKey): PublicKey {
return PublicKey.findProgramAddressSync([GLOBAL_SEED], programId)[0]; 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))); idLE.writeBigUInt64LE(BigInt(id.toString(10)));
return PublicKey.findProgramAddressSync([BET_SEED, idLE], programId)[0]; 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];
}