fix: vault как ATA + фандинг беттора; E2E на localnet ok
This commit is contained in:
@@ -3,3 +3,5 @@
|
|||||||
# ключи игроков/секрет — НЕ коммитить (имеют SOL-баланс)
|
# ключи игроков/секрет — НЕ коммитить (имеют SOL-баланс)
|
||||||
deploy/player.json
|
deploy/player.json
|
||||||
deploy/*player*.json
|
deploy/*player*.json
|
||||||
|
test-ledger/
|
||||||
|
*.idl_build_err.log
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# TASK — Выдавать фантики бетторам на localnet (как SOL airdrop)
|
||||||
|
|
||||||
|
Репозиторий: `smart-updown-token`, файл `relayer/src/index.ts` (+ возможно `ops.ts`).
|
||||||
|
|
||||||
|
## Проблема (обнаружено на localnet E2E)
|
||||||
|
`createBet` падает: `Tokenkeg... Transfer: insufficient funds`.
|
||||||
|
Причина: беттор имеет SOL (ренту/комиссии, `fundBettor` airdrop'ит SOL), но НЕ имеет
|
||||||
|
наших фантиков в своём ATA. Контракт верно требует средства игрока. Для localnet
|
||||||
|
нужно наливать игроку фантики (по аналогии с SOL-airdrop), потому что swap ещё не подключён.
|
||||||
|
|
||||||
|
## Что исправить (релейер)
|
||||||
|
1. В `relayer/src/ops.ts`: в `ensureBettorTokenAccount` (или рядом) при
|
||||||
|
`cfg.allowAirdrop === true` — после создания ATA наливать токен:
|
||||||
|
`mintTo(connection, admin, mint, ata, admin, startBalance)`.
|
||||||
|
- `startBalance` — например, `10_000_000_000` (10 тыс. фантиков, по модулю
|
||||||
|
`minAmount`..`maxAmount` достаточно). Логировать.
|
||||||
|
- Учесть, что `admin` — mint authority (на localnet таковым является админская keypair).
|
||||||
|
2. `relayer/src/index.ts` фандинг беттора: вызывать новую логику наливания токенов
|
||||||
|
(удобно чтобы работало через `ensureBettorTokenAccount`).
|
||||||
|
|
||||||
|
## Гейт
|
||||||
|
- `npm run typecheck` — 0 ошибок.
|
||||||
|
- `npm test` — зелёное.
|
||||||
|
- На localnet: createBet проходит (беттор имеет фантики), closeBet выплачивает.
|
||||||
|
|
||||||
|
## НЕ делать
|
||||||
|
- Не трогать контракт (`program/`).
|
||||||
|
- Не менять логику цен/ставок.
|
||||||
|
- Не коммитить.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# TASK — Багфикс: vault_token в релейере должен быть ATA, а не PDA
|
||||||
|
|
||||||
|
Репозиторий: `smart-updown-token`, файл `relayer/src/ops.ts`.
|
||||||
|
|
||||||
|
## Проблема (обнаружена на localnet E2E)
|
||||||
|
Контракт переведён на SPL-токен (Фаза 1). В инструкциях `create_bet`/`close_bet`
|
||||||
|
аккаунт `vault_token` — это **ATA-аккаунт токена, который принадлежит владельцу
|
||||||
|
vaultPda** (PDA `[b"global", b"vault"]`). Правильный адрес:
|
||||||
|
`getAssociatedTokenAddress(mint, vaultPda(programId))` (allowOwnerOffCurve по PDA-владельцу).
|
||||||
|
|
||||||
|
Однако в релейере `createBet`/`closeBet` используется:
|
||||||
|
```ts
|
||||||
|
vaultToken: vaultPda(program.programId),
|
||||||
|
```
|
||||||
|
Это передаёт адрес **самого PDA-владельца** (не токен-аккаунт) — контракт
|
||||||
|
отвергнет транзакцию (wrong account), а `ensureVaultFunded` зовёт
|
||||||
|
`getTokenAccountBalance(vaultToken)` где vaultToken = vaultPda → «could not find account».
|
||||||
|
Т.е. ваулт ATA даже не создаётся в нужном месте.
|
||||||
|
|
||||||
|
## Что исправить (только `relayer/src/ops.ts`)
|
||||||
|
|
||||||
|
1. **`ensureVaultFunded`**:
|
||||||
|
- Получать ATA ваулта: `const vaultAta = await getAssociatedTokenAddress(mint, vaultPda, true);`
|
||||||
|
(третий аргумент `true` — allowOwnerOffCurve, иначе TokenOwnerOffCurveError).
|
||||||
|
- Создавать/получать: `getOrCreateAssociatedTokenAccount(connection, admin, mint, vaultPda, true)`.
|
||||||
|
- `getTokenAccountBalance` звать по **ATA-адресу** (`vaultAta`), не по vaultPda.
|
||||||
|
|
||||||
|
2. **`createBet`**:
|
||||||
|
- `vaultToken: await getAssociatedTokenAddress(mint, vaultPda(program.programId), true)`
|
||||||
|
|
||||||
|
3. **`closeBet`**:
|
||||||
|
- `vaultToken: await getAssociatedTokenAddress(mint, vaultPda(program.programId), true)`
|
||||||
|
|
||||||
|
## Гейты
|
||||||
|
- `npm run typecheck` — 0 ошибок.
|
||||||
|
- `npm run test` (check.ts) — зелёные проверки.
|
||||||
|
- (Если можно) быстрый локальный smoke: создать ваулт ATA, налить токен, create/close.
|
||||||
|
|
||||||
|
## НЕ делать
|
||||||
|
- Не трогать `program/` (контракт), не менять IDL.
|
||||||
|
- Не трогать логику цен/ставок.
|
||||||
|
- Не коммитить.
|
||||||
|
|
||||||
|
## Суть одным абзацем
|
||||||
|
`vault_token` в контракте — токен-счёт (ATA), всегда считаемый через
|
||||||
|
`getAssociatedTokenAddress(mint, vaultPda, true)`. Заменить все прямые
|
||||||
|
использования `vaultPda(...)` как адреса acкаунта на ATA-адрес.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# TASK — Фикс: сделать vault_token обычным ATA-токен-счётом (убрать seeds = PDA)
|
||||||
|
|
||||||
|
Репозиторий: `smart-updown-token`. Файлы: `program/programs/smart-updown/src/instructions/create_bet.rs`
|
||||||
|
и `.../close_bet.rs`.
|
||||||
|
|
||||||
|
## Проблема (обнаружено на localnet E2E)
|
||||||
|
Токен-аккаунт ваулта объявлен так:
|
||||||
|
```rust
|
||||||
|
#[account(
|
||||||
|
mut,
|
||||||
|
seeds = [GLOBAL_SEED, VAULT_SEED], // ← требует адрес == PDA([global,vault])
|
||||||
|
)]
|
||||||
|
pub vault_token: Account<'info, TokenAccount>
|
||||||
|
```
|
||||||
|
Anchor проверяет, что **адрес `vault_token` == PDA([global,vault])**. Но казённый
|
||||||
|
(escrow) токен-счёт у нас — это ATA от владельца `vaultPda`: адрес
|
||||||
|
`getAssociatedTokenAddress(mint, vaultPda)`, который НЕ равен PDA. Отсюда
|
||||||
|
`Error Code: ConstraintSeeds` — перевод в ваулт невозможен.
|
||||||
|
|
||||||
|
У `bettor_token` такой проблемы нет (там обычный `Account<TokenAccount>`, адрес = ATA игрока).
|
||||||
|
|
||||||
|
## Что исправить
|
||||||
|
|
||||||
|
### 1. `create_bet.rs`
|
||||||
|
У `vault_token` **убрать блок `seeds = [GLOBAL_SEED, VAULT_SEED]`** — оставить просто:
|
||||||
|
```rust
|
||||||
|
#[account(mut)]
|
||||||
|
pub vault_token: Account<'info, TokenAccount>,
|
||||||
|
```
|
||||||
|
(оставить поле `bump` больше не нужно — удалить его из struct, если он был).
|
||||||
|
Это сделает адрес любого токен-счёта (ATA ваулта) приемлемым.
|
||||||
|
|
||||||
|
### 2. `close_bet.rs`
|
||||||
|
То же самое: у `vault_token` убрать `seeds = [...]`. НО: раз адрес больше не фиксируется
|
||||||
|
seeds, передавать/подписывать перевод нужно от **владельца-пользователя (vaultPda)**.
|
||||||
|
В `close_bet` AUTHORITY перевода остаётся владелец аккаунта (поле `owner` токен-счёта).
|
||||||
|
Проверить: signer_seeds `[GLOBAL_SEED, VAULT_SEED, bump]` должны по-прежнему давать
|
||||||
|
vaultPda (это и есть владелец ATA). Так как сигнатура от PDA не меняется, seed-фразы
|
||||||
|
в `close_bet.rs` (для подписи CPI) СТАВЯТСЯ из `ctx.accounts.vault_token` owner.
|
||||||
|
Возможно понадобится отдельный аккаунт `vault_authority: PDA([global,vault])`, чтобы
|
||||||
|
передать в signer seeds — оценить и сделать аккуратно (стандартный Anchor escrow).
|
||||||
|
|
||||||
|
### 3. Пересборка и проверка
|
||||||
|
- `anchor build` зелёный.
|
||||||
|
- IDL перегенерировать (`anchor idl build`), синхронизировать `relayer/idl/smart_updown.json`.
|
||||||
|
- `npm run typecheck` / `npm test` в `relayer/` — зелёные (если тесты проверяют seeds — обновить).
|
||||||
|
|
||||||
|
## Важно (архитектурно)
|
||||||
|
`vault_token` — это ATA от владельца `vaultPda` (адрес `getAssociatedTokenAddress(mint, vaultPda)`).
|
||||||
|
Контракт НЕ должен требовать конкретного адреса — только владельца-подпись для withdraw.
|
||||||
|
Всё, что нужно — чтобы в `close_bet` authority (тот, кто подписывает перевод из ваулта)
|
||||||
|
был **vaultPda** (владелец ATA), а не адрес аккаунта.
|
||||||
|
|
||||||
|
## НЕ делать
|
||||||
|
- Не трогать логику ставок/цен.
|
||||||
|
- Не трогать релейер (он уже передаёт правильный ATA-адрес).
|
||||||
|
- Не коммитить.
|
||||||
|
|
||||||
|
## Проверка успеха
|
||||||
|
- `anchor build` + IDL обновлён.
|
||||||
|
- На localnet: передача `vault_token` = ATA (`getAssociatedTokenAddress(mint, vaultPda)`),
|
||||||
|
create_bet принимает ставку, close_bet выплачивает (signer — vaultPda). E2E проходит.
|
||||||
Binary file not shown.
+17
-36
@@ -73,9 +73,22 @@
|
|||||||
{
|
{
|
||||||
"name": "vault_token",
|
"name": "vault_token",
|
||||||
"docs": [
|
"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": {
|
"pda": {
|
||||||
"seeds": [
|
"seeds": [
|
||||||
{
|
{
|
||||||
@@ -102,13 +115,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "bettor_token",
|
|
||||||
"docs": [
|
|
||||||
"Bettor's SPL-token account; the payout is sent here."
|
|
||||||
],
|
|
||||||
"writable": true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "token_program",
|
"name": "token_program",
|
||||||
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
||||||
@@ -200,34 +206,9 @@
|
|||||||
{
|
{
|
||||||
"name": "vault_token",
|
"name": "vault_token",
|
||||||
"docs": [
|
"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,
|
"writable": true
|
||||||
"pda": {
|
|
||||||
"seeds": [
|
|
||||||
{
|
|
||||||
"kind": "const",
|
|
||||||
"value": [
|
|
||||||
103,
|
|
||||||
108,
|
|
||||||
111,
|
|
||||||
98,
|
|
||||||
97,
|
|
||||||
108
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "const",
|
|
||||||
"value": [
|
|
||||||
118,
|
|
||||||
97,
|
|
||||||
117,
|
|
||||||
108,
|
|
||||||
116
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "token_program",
|
"name": "token_program",
|
||||||
|
|||||||
@@ -30,16 +30,16 @@ pub struct CloseBet<'info> {
|
|||||||
/// CHECK: recipient must be the bet's bettor and is validated in the handler.
|
/// CHECK: recipient must be the bet's bettor and is validated in the handler.
|
||||||
pub bettor: SystemAccount<'info>,
|
pub bettor: SystemAccount<'info>,
|
||||||
pub clock: Sysvar<'info, Clock>,
|
pub clock: Sysvar<'info, Clock>,
|
||||||
/// 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.
|
||||||
#[account(
|
#[account(mut)]
|
||||||
mut,
|
|
||||||
seeds = [GLOBAL_SEED, VAULT_SEED],
|
|
||||||
bump
|
|
||||||
)]
|
|
||||||
pub vault_token: Account<'info, TokenAccount>,
|
pub vault_token: Account<'info, TokenAccount>,
|
||||||
/// Bettor's SPL-token account; the payout is sent here.
|
/// Bettor's SPL-token account; the payout is sent here.
|
||||||
#[account(mut)]
|
#[account(mut)]
|
||||||
pub bettor_token: Account<'info, TokenAccount>,
|
pub bettor_token: Account<'info, TokenAccount>,
|
||||||
|
/// CHECK: vault owner PDA, address enforced by the seeds = [GLOBAL_SEED, VAULT_SEED] + bump above.
|
||||||
|
/// It only serves as the CPI signing authority for the vault ATA payout, so no owner/type check is needed.
|
||||||
|
#[account(seeds = [GLOBAL_SEED, VAULT_SEED], bump)]
|
||||||
|
pub vault_authority: UncheckedAccount<'info>,
|
||||||
pub token_program: Program<'info, Token>,
|
pub token_program: Program<'info, Token>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,16 +86,16 @@ let payout;
|
|||||||
payout = payout_amount;
|
payout = payout_amount;
|
||||||
{
|
{
|
||||||
// Pay the bettor: transfer the payout from the vault to the bettor's token account.
|
// Pay the bettor: transfer the payout from the vault to the bettor's token account.
|
||||||
// The vault is owned by the `global` PDA, so the CPI is signed with its seeds.
|
// The vault ATA is owned by the vault PDA, so the CPI is signed with its seeds.
|
||||||
let cpi_accounts = anchor_spl::token::Transfer {
|
let cpi_accounts = anchor_spl::token::Transfer {
|
||||||
from: ctx.accounts.vault_token.to_account_info(),
|
from: ctx.accounts.vault_token.to_account_info(),
|
||||||
to: ctx.accounts.bettor_token.to_account_info(),
|
to: ctx.accounts.bettor_token.to_account_info(),
|
||||||
authority: ctx.accounts.vault_token.to_account_info(),
|
authority: ctx.accounts.vault_authority.to_account_info(),
|
||||||
};
|
};
|
||||||
let signer_seeds: &[&[u8]] = &[
|
let signer_seeds: &[&[u8]] = &[
|
||||||
GLOBAL_SEED,
|
GLOBAL_SEED,
|
||||||
VAULT_SEED,
|
VAULT_SEED,
|
||||||
&[ctx.bumps.vault_token],
|
&[ctx.bumps.vault_authority],
|
||||||
];
|
];
|
||||||
let vault_signer_seeds = [signer_seeds];
|
let vault_signer_seeds = [signer_seeds];
|
||||||
let cpi_ctx = CpiContext::new_with_signer(
|
let cpi_ctx = CpiContext::new_with_signer(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use anchor_lang::prelude::*;
|
|||||||
use anchor_spl::token::{Token, TokenAccount};
|
use anchor_spl::token::{Token, TokenAccount};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
constants::{BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_OPEN, VAULT_SEED},
|
constants::{BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_OPEN},
|
||||||
error::SmartUpdownError,
|
error::SmartUpdownError,
|
||||||
events::BetCreated,
|
events::BetCreated,
|
||||||
state::{Bet, Global},
|
state::{Bet, Global},
|
||||||
@@ -32,12 +32,8 @@ pub struct CreateBet<'info> {
|
|||||||
/// Bettor's SPL-token account (our "fanti" mint); bet amount is pulled from here.
|
/// Bettor's SPL-token account (our "fanti" mint); bet amount is pulled from here.
|
||||||
#[account(mut)]
|
#[account(mut)]
|
||||||
pub bettor_token: Account<'info, TokenAccount>,
|
pub bettor_token: Account<'info, TokenAccount>,
|
||||||
/// 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.
|
||||||
#[account(
|
#[account(mut)]
|
||||||
mut,
|
|
||||||
seeds = [GLOBAL_SEED, VAULT_SEED],
|
|
||||||
bump
|
|
||||||
)]
|
|
||||||
pub vault_token: Account<'info, TokenAccount>,
|
pub vault_token: Account<'info, TokenAccount>,
|
||||||
pub token_program: Program<'info, Token>,
|
pub token_program: Program<'info, Token>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ fn make_close_ix(
|
|||||||
clock: CLOCK_ADDR.parse().unwrap(),
|
clock: CLOCK_ADDR.parse().unwrap(),
|
||||||
vault_token: vault_pda(),
|
vault_token: vault_pda(),
|
||||||
bettor_token: *bettor_ata,
|
bettor_token: *bettor_ata,
|
||||||
|
vault_authority: vault_pda(),
|
||||||
token_program: spl_token::ID,
|
token_program: spl_token::ID,
|
||||||
}
|
}
|
||||||
.to_account_metas(None),
|
.to_account_metas(None),
|
||||||
|
|||||||
@@ -73,9 +73,22 @@
|
|||||||
{
|
{
|
||||||
"name": "vault_token",
|
"name": "vault_token",
|
||||||
"docs": [
|
"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": {
|
"pda": {
|
||||||
"seeds": [
|
"seeds": [
|
||||||
{
|
{
|
||||||
@@ -102,13 +115,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "bettor_token",
|
|
||||||
"docs": [
|
|
||||||
"Bettor's SPL-token account; the payout is sent here."
|
|
||||||
],
|
|
||||||
"writable": true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "token_program",
|
"name": "token_program",
|
||||||
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
||||||
@@ -200,34 +206,9 @@
|
|||||||
{
|
{
|
||||||
"name": "vault_token",
|
"name": "vault_token",
|
||||||
"docs": [
|
"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,
|
"writable": true
|
||||||
"pda": {
|
|
||||||
"seeds": [
|
|
||||||
{
|
|
||||||
"kind": "const",
|
|
||||||
"value": [
|
|
||||||
103,
|
|
||||||
108,
|
|
||||||
111,
|
|
||||||
98,
|
|
||||||
97,
|
|
||||||
108
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "const",
|
|
||||||
"value": [
|
|
||||||
118,
|
|
||||||
97,
|
|
||||||
117,
|
|
||||||
108,
|
|
||||||
116
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "token_program",
|
"name": "token_program",
|
||||||
|
|||||||
+17
-36
@@ -78,9 +78,22 @@ export type SmartUpdown = {
|
|||||||
{
|
{
|
||||||
"name": "vaultToken",
|
"name": "vaultToken",
|
||||||
"docs": [
|
"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": {
|
"pda": {
|
||||||
"seeds": [
|
"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",
|
"name": "tokenProgram",
|
||||||
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
||||||
@@ -205,34 +211,9 @@ export type SmartUpdown = {
|
|||||||
{
|
{
|
||||||
"name": "vaultToken",
|
"name": "vaultToken",
|
||||||
"docs": [
|
"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,
|
"writable": true
|
||||||
"pda": {
|
|
||||||
"seeds": [
|
|
||||||
{
|
|
||||||
"kind": "const",
|
|
||||||
"value": [
|
|
||||||
103,
|
|
||||||
108,
|
|
||||||
111,
|
|
||||||
98,
|
|
||||||
97,
|
|
||||||
108
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "const",
|
|
||||||
"value": [
|
|
||||||
118,
|
|
||||||
97,
|
|
||||||
117,
|
|
||||||
108,
|
|
||||||
116
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tokenProgram",
|
"name": "tokenProgram",
|
||||||
|
|||||||
@@ -58,12 +58,16 @@ async function main(): Promise<void> {
|
|||||||
"betPda(0) совпадает с седами bet из IDL"
|
"betPda(0) совпадает с седами bet из IDL"
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
vaultPda(programId).equals(pdaFromIdlSeeds(programId, (acct(createIx, "vault_token") as any).pda.seeds)),
|
vaultPda(programId).equals(pdaFromIdlSeeds(programId, (acct(closeIx, "vault_authority") as any).pda.seeds)),
|
||||||
"vaultPda совпадает с седами vault_token из IDL"
|
"vaultPda совпадает с седами vault_authority в close_bet"
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
vaultPda(programId).equals(pdaFromIdlSeeds(programId, (acct(closeIx, "vault_token") as any).pda.seeds)),
|
(acct(createIx, "vault_token") as any).pda === undefined,
|
||||||
"vaultPda совпадает с седами vault_token в close_bet"
|
"create_bet: vault_token — обычный ATA-счёт (без PDA/seeds)"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
(acct(closeIx, "vault_token") as any).pda === undefined,
|
||||||
|
"close_bet: vault_token — обычный ATA-счёт (без PDA/seeds)"
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. Сборка инструкций оффлайн и проверка ключей
|
// 2. Сборка инструкций оффлайн и проверка ключей
|
||||||
@@ -114,7 +118,7 @@ async function main(): Promise<void> {
|
|||||||
check(!!(kByName(bettorToken)!.isWritable), "close_bet: bettor_token — writable");
|
check(!!(kByName(bettorToken)!.isWritable), "close_bet: bettor_token — writable");
|
||||||
check(!!(kByName(vaultToken)!.isWritable), "close_bet: vault_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(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) {
|
if (failed > 0) {
|
||||||
console.error(`\ncheck: ${failed} проверок ПРОВАЛЕНО`);
|
console.error(`\ncheck: ${failed} проверок ПРОВАЛЕНО`);
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ export interface Config {
|
|||||||
/** Bet amount bounds in token base units (NOT lamports); must match the on-chain `initialize` values. */
|
/** Bet amount bounds in token base units (NOT lamports); must match the on-chain `initialize` values. */
|
||||||
minAmount: number;
|
minAmount: number;
|
||||||
maxAmount: 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;
|
multiplierBps: number;
|
||||||
expirySeconds: number;
|
expirySeconds: number;
|
||||||
}
|
}
|
||||||
@@ -64,6 +71,9 @@ export function loadConfig(): Config {
|
|||||||
maxAmount: envNum("SMART_MAX_AMOUNT", 10_000_000_000), // 10 000 фантиков
|
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),
|
||||||
|
// Стартовый баланс фантиков, который локальный релейер mintTo'ит беттору
|
||||||
|
// (аналог SOL-airdrop). По умолчанию равен maxAmount — хватает на максимум ставки.
|
||||||
|
startBalance: envNum("SMART_START_BALANCE", 10_000_000_000),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -94,7 +94,7 @@ async function main(): Promise<void> {
|
|||||||
// mint ещё не создан (плейсхолдер) — дефолт из ТЗ контракта
|
// mint ещё не создан (плейсхолдер) — дефолт из ТЗ контракта
|
||||||
}
|
}
|
||||||
|
|
||||||
const global = await readGlobal(program, program.programId);
|
const global = await readGlobal(program, program.programId, cfg.tokenMint);
|
||||||
if (!global) {
|
if (!global) {
|
||||||
if (!process.argv.includes("--init")) {
|
if (!process.argv.includes("--init")) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -127,7 +127,14 @@ async function main(): Promise<void> {
|
|||||||
const req = nextBetRequest(cfg.minAmount, cfg.maxAmount);
|
const req = nextBetRequest(cfg.minAmount, cfg.maxAmount);
|
||||||
await fundBettor(connection, req.bettor.publicKey);
|
await fundBettor(connection, req.bettor.publicKey);
|
||||||
// Беттору нужен токен-аккаунт (ATA) — без него create_bet не пройдёт.
|
// Беттору нужен токен-аккаунт (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();
|
const entryPrice = await getPrice();
|
||||||
await createBet(program, cfg.tokenMint, req.bettor, {
|
await createBet(program, cfg.tokenMint, req.bettor, {
|
||||||
@@ -141,7 +148,7 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
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, cfg.tokenMint);
|
||||||
const id = g0!.counter.subn(1);
|
const id = g0!.counter.subn(1);
|
||||||
await settleBet(program, id, entryPrice, admin, req.bettor.publicKey, cfg.tokenMint);
|
await settleBet(program, id, entryPrice, admin, req.bettor.publicKey, cfg.tokenMint);
|
||||||
|
|
||||||
|
|||||||
+34
-16
@@ -23,7 +23,8 @@ const BPS_DIVISOR = 10_000n;
|
|||||||
*/
|
*/
|
||||||
export async function readGlobal(
|
export async function readGlobal(
|
||||||
program: Program<SmartUpdown>,
|
program: Program<SmartUpdown>,
|
||||||
programId: PublicKey
|
programId: PublicKey,
|
||||||
|
mint: PublicKey
|
||||||
): Promise<{
|
): Promise<{
|
||||||
counter: BN;
|
counter: BN;
|
||||||
minAmount: BN;
|
minAmount: BN;
|
||||||
@@ -33,7 +34,7 @@ export async function readGlobal(
|
|||||||
paused: BN;
|
paused: BN;
|
||||||
admin: PublicKey;
|
admin: PublicKey;
|
||||||
relayer: PublicKey;
|
relayer: PublicKey;
|
||||||
/** Баланс vaultToken (PDA-ваула) в токен-юнитах; "0", если аккаунт ещё не создан. */
|
/** Баланс vault ATA (owner = PDA-ваула) в токен-юнитах; "0", если аккаунт ещё не создан. */
|
||||||
vaultBalance: string;
|
vaultBalance: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
const global = globalPda(programId);
|
const global = globalPda(programId);
|
||||||
@@ -41,9 +42,8 @@ export async function readGlobal(
|
|||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
let vaultBalance = "0";
|
let vaultBalance = "0";
|
||||||
try {
|
try {
|
||||||
const bal = await program.provider.connection.getTokenAccountBalance(
|
const vaultAta = await getAssociatedTokenAddress(mint, vaultPda(programId), true);
|
||||||
vaultPda(programId)
|
const bal = await program.provider.connection.getTokenAccountBalance(vaultAta);
|
||||||
);
|
|
||||||
vaultBalance = bal.value.amount || "0";
|
vaultBalance = bal.value.amount || "0";
|
||||||
} catch {
|
} catch {
|
||||||
vaultBalance = "0";
|
vaultBalance = "0";
|
||||||
@@ -94,7 +94,7 @@ export async function createBet(
|
|||||||
bettor: Keypair,
|
bettor: Keypair,
|
||||||
args: { side: number; amount: number; entryPrice: number }
|
args: { side: number; amount: number; entryPrice: number }
|
||||||
): Promise<BN> {
|
): 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");
|
if (!global) throw new Error("Contract not initialized");
|
||||||
const id = global.counter;
|
const id = global.counter;
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export async function createBet(
|
|||||||
.accountsPartial({
|
.accountsPartial({
|
||||||
bettor: bettor.publicKey,
|
bettor: bettor.publicKey,
|
||||||
bettorToken: await getAssociatedTokenAddress(mint, bettor.publicKey),
|
bettorToken: await getAssociatedTokenAddress(mint, bettor.publicKey),
|
||||||
vaultToken: vaultPda(program.programId),
|
vaultToken: await getAssociatedTokenAddress(mint, vaultPda(program.programId), true),
|
||||||
tokenProgram: TOKEN_PROGRAM_ID,
|
tokenProgram: TOKEN_PROGRAM_ID,
|
||||||
})
|
})
|
||||||
.signers([bettor])
|
.signers([bettor])
|
||||||
@@ -140,7 +140,7 @@ export async function closeBet(
|
|||||||
relayer: relayer.publicKey,
|
relayer: relayer.publicKey,
|
||||||
bettor,
|
bettor,
|
||||||
bettorToken: await getAssociatedTokenAddress(mint, bettor),
|
bettorToken: await getAssociatedTokenAddress(mint, bettor),
|
||||||
vaultToken: vaultPda(program.programId),
|
vaultToken: await getAssociatedTokenAddress(mint, vaultPda(program.programId), true),
|
||||||
tokenProgram: TOKEN_PROGRAM_ID,
|
tokenProgram: TOKEN_PROGRAM_ID,
|
||||||
})
|
})
|
||||||
.signers([relayer])
|
.signers([relayer])
|
||||||
@@ -151,12 +151,17 @@ export async function closeBet(
|
|||||||
* Создаёт ATA беттора, если ещё нет (рент платит админ; сам ATA не требует
|
* Создаёт ATA беттора, если ещё нет (рент платит админ; сам ATA не требует
|
||||||
* подписи владельца). Без аккаунта createBet не пройдёт: контракт сам
|
* подписи владельца). Без аккаунта createBet не пройдёт: контракт сам
|
||||||
* его не инициализирует.
|
* его не инициализирует.
|
||||||
|
*
|
||||||
|
* При `startBalance` !== undefined (только localnet, `cfg.allowAirdrop === true`)
|
||||||
|
* после создания ATA доналивает беттору фантики до startBalance (аналог
|
||||||
|
* SOL-airdrop, swap ещё не подключён). `admin` — mint authority.
|
||||||
*/
|
*/
|
||||||
export async function ensureBettorTokenAccount(
|
export async function ensureBettorTokenAccount(
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
admin: Keypair,
|
admin: Keypair,
|
||||||
mint: PublicKey,
|
mint: PublicKey,
|
||||||
bettor: PublicKey
|
bettor: PublicKey,
|
||||||
|
startBalance?: bigint
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ata = await getAssociatedTokenAddress(mint, bettor);
|
const ata = await getAssociatedTokenAddress(mint, bettor);
|
||||||
try {
|
try {
|
||||||
@@ -165,13 +170,25 @@ export async function ensureBettorTokenAccount(
|
|||||||
await createAssociatedTokenAccount(connection, admin, mint, bettor);
|
await createAssociatedTokenAccount(connection, admin, mint, bettor);
|
||||||
console.log(`bettor ATA created ${bettor.toBase58()} -> ${ata.toBase58()}`);
|
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.
|
* Догружает ваулт (house bankroll), чтобы хватило на выплаты 1.3x.
|
||||||
* Теперь это ТОКЕН, не SOL: на localnet mint-авторитетом выступает admin
|
* Теперь это ТОКЕН, не SOL: на localnet mint-авторитетом выступает admin
|
||||||
* (см. state.rs: decimals = 6), так что наливается через mintTo в PDA-ваулт.
|
* (см. state.rs: decimals = 6), так что наливается через mintTo в ATA-ваулт
|
||||||
* На mainnet наливается off-chain переводом фантиков (вне этого CLI).
|
* (токен-счёт с owner = PDA([global, vault])). На mainnet наливается
|
||||||
|
* off-chain переводом фантиков (вне этого CLI).
|
||||||
*/
|
*/
|
||||||
export async function ensureVaultFunded(
|
export async function ensureVaultFunded(
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
@@ -180,18 +197,19 @@ export async function ensureVaultFunded(
|
|||||||
cfg: Config
|
cfg: Config
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const mint = cfg.tokenMint;
|
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),
|
// контракт его не создаёт. ATA не требует подписи owner (allowOwnerOffCurve),
|
||||||
// поэтому создаём её от имени admin.
|
// поэтому создаём её от имени 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 current = BigInt(bal.value.amount || "0");
|
||||||
const target = (BigInt(cfg.maxAmount) * BigInt(cfg.multiplierBps) / BPS_DIVISOR) * 2n;
|
const target = (BigInt(cfg.maxAmount) * BigInt(cfg.multiplierBps) / BPS_DIVISOR) * 2n;
|
||||||
if (current >= target) return;
|
if (current >= target) return;
|
||||||
const topUp = target - current;
|
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})`);
|
console.log(`vault funded +${topUp} token base units (target ${target})`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user