Compare commits

..

16 Commits

Author SHA1 Message Date
Caffeine 92cf2f61b4 feat(relayer): цена SOL из NATS + WS-раздача + деплой-артефакты
deploy-contract / deploy-contract (release) Failing after 3m34s
priceSource больше не рандомит: цена берётся из NATS (market.price.solusdt),
конверсия строка->целое x1e8 без float. Шлюз раздаёт цену по WS (/ws,
нормализация /api/... -> /...) — фронт просто выводит её. Добавлены
Dockerfile и helm-чарт для деплоя в k3s.
2026-09-13 05:53:48 +03:00
Caffeine bf9d05f2f2 feat: /faucet/{address} в шлюз — налив SOL+фантиков на произвольный адрес (localnet) 2026-09-10 05:23:50 +03:00
Caffeine f426f58778 feat: HTTP-шлюз поверх релейера (POST /bet,/close; GET /balance,/state) для пульта без Phantom 2026-09-10 04:33:34 +03:00
Caffeine f937c8993d fix: vault как ATA + фандинг беттора; E2E на localnet ok 2026-09-10 02:12:07 +03:00
Caffeine fb9a7f4e8b 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
2026-09-10 00:19:21 +03:00
Caffeine 45d64d45a7 feat: migrate smart-updown contract to SPL-token (фантики)
- create_bet: escrow фантиков с bettor на vault (PDA[global,vault]) через anchor_spl::token
- close_bet: выплата фантиков из vault на bettor (CPI с signer_seeds vault)
- state/constants: comments про единицы (token units decimals=6), + VAULT_SEED
- IDL регенерирован (idl/ + relayer/idl/) с аккаунтами bettor_token/vault_token/token_program
- тесты обновлены под токен (10/10 + doctest)
2026-09-09 22:00:41 +03:00
subochev ff2289e68d fix: вернуть declare_id на 9ALs (согласовать с Anchor.toml/CI/нашим keypair); контракт живёт на нашем адресе 2026-09-05 17:31:10 +03:00
subochev a51fb3ada2 chore: игнорировать игровые ключи в deploy/ (имеют SOL-баланс) 2026-09-03 23:48:16 +03:00
subochev 4cf583f497 fix: declare_id = FMMD (фактический адрес развёрнутой программы, совпадает с program-id бэка) 2026-09-03 21:08:42 +03:00
subochev 32faec7515 docs: README под актуальную SBPFv3-сборку (anchor 0.31.1, --tools-version v1.57, two-arch тесты) 2026-09-03 13:57:31 +03:00
subochev 515adb25c9 chore: ignore .cortexkit (opencode working dir) 2026-09-03 05:28:17 +03:00
subochev 9f100ee122 feat: миграция smart-updown на anchor 0.31.1 + solana 2.x (SBPFv3)
- anchor-lang 0.31.1 вместо 2.0-rc/anchor-next (git)
- Address -> Pubkey (state/events/instructions)
- убран #[discrim], добавлен #[derive(InitSpace)] + space=8+INIT_SPACE
- тесты переписаны на solana-program-test 2.x (10 passed)
- сборка SBPFv3 через cargo build-sbf --tools-version v1.57
- deploy успешен в нашу сеть (agave 4.2.2, SBPFv3)
2026-09-03 05:27:34 +03:00
subochev 2133f65e21 ci: пробуем доустановку sbpfv3 target + build --arch v3 для нашего прод-блокчейна 2026-09-03 01:27:04 +03:00
subochev c8a95b1f0e ci: собand контракт--arch v3 (SBPFv3, как прод mainnet) на builder agave 4.2.2 2026-09-02 23:31:23 +03:00
subochev 2081c6cb0b ci: builder на agave 4.2.2 (arch v0..v3) — как прод, целевой SBPFv3 2026-09-02 23:24:36 +03:00
subochev 5fdd6902bd dbg: замер возможностей cargo-build-sbf (arch/sbpf) 2026-09-02 23:16:44 +03:00
49 changed files with 9976 additions and 1588 deletions
+3
View File
@@ -0,0 +1,3 @@
# >>> cortexkit:magic-context
magic-context/
# <<< cortexkit:magic-context
+11 -15
View File
@@ -43,13 +43,13 @@ jobs:
/tmp/rustup-init.sh -y --profile minimal --default-toolchain 1.91.0
fi
- name: Setup Solana CLI (GitHub, минуя release.solana.com)
- name: Setup Solana CLI (Agave 4.2.2 — builder умеет SBPFv3, как прод mainnet)
run: |
set -e
SOL_VERSION=v1.18.26
SOL_VERSION=v4.2.2
cd /tmp
curl -sSfL \
"https://github.com/solana-labs/solana/releases/download/${SOL_VERSION}/solana-release-x86_64-unknown-linux-gnu.tar.bz2" \
"https://github.com/anza-xyz/agave/releases/download/${SOL_VERSION}/solana-release-x86_64-unknown-linux-gnu.tar.bz2" \
-o solana-release.tar.bz2
tar xjf solana-release.tar.bz2
mkdir -p "$HOME/solana-local"
@@ -72,8 +72,10 @@ jobs:
if [ -d "$HOME/solana-local/sdk/sbf/dependencies/platform-tools/rust" ]; then
rustup toolchain link solana "$HOME/solana-local/sdk/sbf/dependencies/platform-tools/rust" 2>/dev/null || true
fi
echo "### sbf-sdk на месте:"
ls "$HOME/solana-local/sdk/sbf/" 2>&1 | head -5
echo "### доустановка sbpfv3 target (для --arch v3):"
rustup target add --toolchain solana sbpfv3-solana-solana 2>&1 | tail -2 || true
rustup target list --installed 2>&1 | grep -iE 'sbpf|sbpfv3' || true
echo "### targets (конец):"
echo "$HOME/solana-local" >> "$GITHUB_PATH"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
export PATH="$HOME/solana-local:$PATH"
@@ -112,8 +114,8 @@ jobs:
cp ../deploy/dev_smart_updown-keypair.json .anchor/smart_updown-keypair.json 2>/dev/null || true
cp ../deploy/dev_smart_updown-keypair.json .anchor/9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf-keypair.json 2>/dev/null || true
ls -la .anchor/
echo "### построение:"
anchor build --ignore-keys
echo "### построение (явно --arch v3, как прод mainnet):"
anchor build --ignore-keys -- --arch v3 2>&1 | tail -8
- name: Test program
working-directory: program
@@ -128,14 +130,8 @@ jobs:
export PATH="$HOME/solana-local:$HOME/.cargo/bin:$PATH"
RPC=http://192.168.76.181:8899
echo ">>> Деплой в НАШУ сеть solana-net (RPC $RPC)"
echo "### sbpf-версия артефакта:"
SO=target/deploy/smart_updown.so
ls -la "$SO"
readelf -S "$SO" 2>/dev/null | grep -iE "sbpf|note|\.dynamic" | head
readelf -p .dynamic "$SO" 2>/dev/null | grep -iE "SB|VERSION|tags" | head
# TAGS/sbpf версия в строке .dynstr / env
strings "$SO" 2>/dev/null | grep -iE "sbpf|sbf" | head
echo "### sbpf-версия (end)"
echo "### собранный артефакт:"
ls -la target/deploy/smart_updown.so
# проверка, что наша сеть жива
for i in $(seq 1 20); do
curl -s $RPC -H 'Content-Type: application/json' \
+7
View File
@@ -0,0 +1,7 @@
.cortexkit/
# ключи игроков/секрет — НЕ коммитить (имеют SOL-баланс)
deploy/player.json
deploy/*player*.json
test-ledger/
*.idl_build_err.log
+51
View File
@@ -0,0 +1,51 @@
# ТЗ: перевод смарт-контракта smart-updown на Anchor 0.31.1 + Solana 2.x (SBPFv3)
Цель: контракт собирается под SBPFv3 и успешно деплоится в наш прод-блокчейн
(агave 4.2.2 / feature-set 565236538 = mainnet). Логика игры сохраняется 100%.
Рабочая ветка: `extract-sources`. Репо: ~/WORK/games/Smart-game (push в git.binom.pw).
## Что сделать
1. **Cargo.toml** (`program/programs/smart-updown/Cargo.toml`):
- `anchor-lang = "0.31.1"` (crates.io), убрать git-зависимость otter-sec/anchor branch anchor-next.
- Убрать `anchor-v2-testing` из dev-deps (git).
- `solana-clock`/`solana-pubkey` dev-deps → версии 2.x (совместимы с solana-program ^2).
- Оставить остальные зависимости как есть, если они совместимы с 0.31.1.
2. **Типы**: заменить `Address` на `Pubkey` во всех state/events/instructions
(state.rs: admin, relayer, bettor; events.rs: bet, bettor; update.rs: new_relayer).
Тип `Pubkey` — из `solana_program::pubkey::Pubkey` (доступен через anchor prelude).
3. **Убрать `#[discrim = N]`** со всех 4 инструкций в lib.rs (initialize/create_bet/close_bet/update).
Anchor 0.31 генерирует дискриминатор автоматически. **Перегенерить IDL** (idl/smart_updown.json, .ts;
relayer/idl/*) — дискримы могут измениться, клиенты должны использовать новый IDL.
4. **Математика payout (1.3x) — НЕ менять**: `(amount as u128).checked_mul(multiplier_bps as u128) / BPS_DIVISOR`
через checked_mul/checked_div, BPS_DIVISOR=10000. Никаких других формул/округлений.
5. **Логика НЕ менять**: seeds (`GLOBAL_SEED`=b"global", `BET_SEED`+id), статусы, paused-проверки, админ/релейер-проверки, события — как есть.
6. **API-адаптация под 0.31** (если что-то из 2.0-rc синтаксиса недоступно):
- `ctx.accounts.admin.address()` → использовать `.key()` или `*ctx.accounts.admin.key`
- `cpi_handle_mut()` → `to_account_info()` (классический CpiContext::cpi_accounts)
- `.view().lamports()` → `.to_account_info().lamports()`
- `address_eq` → сравнение через `==` на Pubkey
- аккаунты: `space` НЕ задавать явно (авто-вывод 8-байт дискриминатора) — не ломать авто-аллокацию.
7. **Тесты** (`program/programs/smart-updown/tests/test_smart_updown.rs`):
перевести на Rust API Anchor 0.31 (НЕ JS). Убрать `anchor_v2_testing`, `Address`.
9 тестов долube зелёные (`cargo test`). Не использовать хардкод-дискриминаторы —
использовать генерируемые данные из `smart_updown::instruction::*`.
## Критерии приёмки
- `anchor build` (агave 4.2.2 CI, `--arch v3`) → успешно, `.so` собран.
- `cargo test` → 9 тестов зелёные.
- IDL перегенерён, дискримы согласованы.
- Деплой в нашу сеть (http://192.168.76.181:8899, агave 4.2.2) успешен.
## Запрещено
- Менять игровую логику (правила, множитель, статусы, seeds, проверки).
- Ломать явные ошибки компиляции скрытием (`#[allow]`, unwrap без причины).
+21 -14
View File
@@ -4,7 +4,8 @@
на 15 секунд; при выигрыше получает ставку + 30% (1.3x). Дом (контракт) — оппонент.
Цена берётся off-chain релеером (он же админ) с вашей БД; SOL хранится в ваулте (PDA `global`).
Программа: **Anchor v2 (fork `otter-sec/anchor`, ветка `anchor-next`)**, Solana, Rust.
Программа: **Anchor 0.31.1 + Solana 2.x (Rust)**, собирается в **SBPFv3** —
совместимо с агave-валидатором 4.2.x (= текущий mainnet Solana).
> **Фронтенд игры** живёт в отдельном репозитории:
> **https://git.binom.pw/subochev/fun-game-front2**
@@ -51,11 +52,13 @@ smart-updown/
`cargo build-sbf`, `solana-keygen`. Установка:
`sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"`
- **Node.js 18+ / npm** — для relayer.
- (опционально) **Rust 1.89.0** через rustup — только если пересобираете программу.
- (опционально) **Rust 1.85+** через rustup — только если пересобираете программу.
> **Важно про `.so`**: это SBF-байткод и он не зависит от архитектуры хоста,
> поэтому `deploy/smart_updown.so`, собранный на macOS, **работает на Linux-валидаторе**
> как есть — пересобирать не нужно.
> **Важно про `.so` и SBPF-версию**: контракт собирается под конкретный SBPF-архитектуру
> (здесь — **SBPFv3**, под агave 4.2.x). Собранный `.so` кроссплатформенный
> по CPU хоста (работает и с macOS-сборкой), но **должен совпадать по SBPF-версии
> с валидатором**, иначе деплой падает (`Detected sbpf_version ... not enabled`).
> При сборке через `cargo build-sbf --tools-version v1.57` — артефакт SBPFv3.
## 2. Развёртывание (localnet)
@@ -110,22 +113,26 @@ solana program deploy --program-id deploy/smart_updown-keypair.json deploy/smart
## 6. (Опционально) Пересборка программы из исходников + тесты
Та же команда, что использовалась для сборки:
Сборка контракта под **SBPFv3** (для агave 4.2.x):
```bash
cd program
rustup toolchain install 1.89.0 # если нет
# собрать .so:
cargo build-sbf --manifest-path programs/smart-updown/Cargo.toml
# собрать .so (SBPFv3, с entrypoint):
cargo build-sbf --tools-version v1.57 --manifest-path programs/smart-updown/Cargo.toml
# артефакт появится в program/target/deploy/smart_updown.so
# интеграционные тесты (LiteSVM), без валидатора:
cargo test -p smart-updown
# интеграционные тесты (solana-program-test 2.x, 10 кейсов):
SBF_OUT_DIR="$(pwd)/target/deploy" cargo test -p smart-updown
```
> `--tools-version v1.5x` критичен: v1.57 даёт rustc ≥1.85 (нужен для edition2024)
> и валидный `.so` с символом `entrypoint`. **Нюанс про SBPF-версии для тестов**:
> тестовый рантайм `solana-program-test` не грузит SBPFv3-ELF — он принимает максимум
> arch-v0. Поэтому тесты гоняются на `.so`, собранном под v0 (тот же код), а в
> прод деплоится SBPFv3-артефакт. Логика контракта одна — различается только arch-цель.
Если пересобираете, IDL не меняется, пока не меняете layout аккаунтов/инструкций.
После изменения контракта: пересоберите `.so`, перегенерируйте IDL
(`anchor build`, при наличии anchor-cli 2.0.0-rc.1) и обновите `relayer/idl/`
и `idl/`.
При смене дискриминаторов (напр. уход от `#[discrim]` при миграции на anchor 0.31.1)
обязательно перегенерируйте IDL и обновите `relayer/idl/` и `idl/`.
## 7. Переменные окружения relayer (см. .env.example)
+29
View File
@@ -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/`).
- Не менять логику цен/ставок.
- Не коммитить.
+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.
- Логи/цикл работают с токен-единицами.
+79
View File
@@ -0,0 +1,79 @@
# TASK — перевод смарт-контракта smart-updown на фантики (SPL-токен)
Репозиторий: `smart-updown-token` (копия `Smart-game`, ветка `main`). Оригинал НЕ трогаем.
> Работаем ТОЛЬКО `program/programs/smart-updown/src/**`. Остальное (relayer, deploy) не менять
> в этом проходе.
## Цель
Контракт сейчас принимает/выплачивает **нативный SOL (lamports)** через `system_program` и
`add/sub_lamports`. Перевести на **наши фантики = SPL-token** (обычный SPL Token, НЕ Token-2022 —
для MVP берём простой стандарт; swap на pump.fun конвертит SOL↔фантики безотносительно стандарта).
**Логика игры не меняется:** ставка вверх/вниз по цене SOL, `entry_price`/`exit_price` — обычные
u64 и сравниваются как раньше. Меняется ТОЛЬКО норма расчётов (валюта): с lamports на токен-единицы.
## Что изменится
### 1. `state.rs`
- Поля `Global` остаются (counter, min/max_amount, multiplier_bps, expiry_seconds, paused, admin, relayer).
`min_amount`/`max_amount` теперь = **токен-единицы**, не lamports. (decimals=6; логика количеств не меняется, просто ценовая шкала).
- `Bet` — без изменений (amount = токен-единицы).
### 2. `create_bet.rs`
- Добавить аккаунты (через `anchor_spl::token::Token`):
- `bettor_token: Account<'info, TokenAccount>` (ATA игрока, `#[account(mut)]`) — откуда списываем фантики.
- `vault_token: Account<'info, TokenAccount>` (ATA на global, `#[account(seeds=[GLOBAL_SEED], bump)]`, `mut`) — куда кладём.
- `token_program: Program<'info, Token>`.
- Заменить блок `system_program::transfer(...)` на `anchor_spl::token::transfer(...)`:
из `bettor_token` в `vault_token`, `amount`.
- `bettor` остаётся `Signer`, но теперь просто auth (токен-подпись).
### 3. `close_bet.rs`
- Добавить:
- `vault_token: Account<'info, TokenAccount>` (`mut`, seeds GLOBAL_SEED) — источник фантиков.
- `bettor_token: Account<'info, TokenAccount>` (`mut`) — куда платим.
- `token_program: Program<'info, Token>`.
- Заменить ваулт-логику: вместо `add/sub_lamports` у global — `token::transfer` из `vault_token`
в `bettor_token` на `payout`.
- `global` больше НЕ касса SOL — убрать `SystemAccount`-требование; `bettor` в `close_bet` можно убрать
из аккаунтов (адрес в `bet.bettor` уже есть), но для обратной совместимости можно оставить как `SystemAccount` (не мутируя).
- Проверка `global.get_lamports() >= payout` заменяется на проверку баланса `vault_token.amount >= payout`
(или просто положиться на token::transfer failure — но лучше явно проверить `InsufficientVault`).
### 4. `initialize.rs` / `update.rs`
- Убедиться, что `min/max_amount` интерпретируются как токен-единицы. Если в `initialize` передаются
как lamports — просто поменять смысл (или оставить, числа вводим в токен-единицах). Логику не трогаем.
### 5. Cargo.toml
- Добавить зависимость: `anchor-spl = "0.31.1"` (даёт `anchor_spl::token`).
### 6. IDL (idl/*.json)
- Обновить после сборки (`anchor build` регенерит). Новые аккаунты в create_bet/close_bet.
## Требования (гейты)
1. Каждый шаг компилируется: `anchor build` (или `cargo build-sbf`) — БЕЗ ошибок.
2. В конце: `anchor build` зелёный + обновлённый IDL.
3. Семантика: create_bet берёт фантики с bettor на ваулт; close_bet платит фантики с ваулта на bettor.
4. Тесты (`program/tests/`) — обновить под токен (создать mint, ATA, задеплоить), прогнать `anchor test`.
## Порядок работы (шаг за шагом, компиляз после каждого)
- **Шаг 1** — Cargo.toml: + анchor-spl.
- **Шаг 2** — state.rs: только комментарий (min/max = токен-единицы), без изменения полей.
- **Шаг 3** — create_bet.rs: добавить токен-аккаунты + `token::transfer`.
- **Шаг 4** — close_bet.rs: добавить токен-аккаунты + `token::transfer` из ваулта на bettor.
- **Шаг 5** — IDL реген + тесты.
- **Шаг 6** — финальный `anchor test` зелёный.
## ЧЕГО НЕ ДЕЛАТЬ
- Не трогать логику «вверх/вниз», entry/exit_price, статусы.
- Не менять relayer/скрипты деплоя.
- Не коммитить (всё остаётся в рабочем дереве до приёмки человеком).
## Проверка
- `anchor build` — 0 ошибок.
- `anchor test` (или `cargo test` в program) — зелёный.
- Все новые аккаунты корректно работают с токен-кейсами (mint, ATA, ваулт).
+47
View File
@@ -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-адрес.
+62
View File
@@ -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 проходит.
+56
View File
@@ -0,0 +1,56 @@
# ТЗ: дописать тесты для smart-updown под Anchor 0.31.1 / Solana 2.x
Контракт уже переведён на anchor-lang 0.31.1 + solana 2.x и `.so` собран в SBPFv3.
Всё в `program/programs/smart-updown/`, ветка `extract-sources`.
## Текущее состояние тестов
Файл `tests/test_smart_updown.rs` сейчас содержит ТОЛЬКО первый тест
(`initialize_creates_global`) полностью + каркас (setup, read_account, helpers).
Остальные 8 тестов НАДО дописать по original-логике (см. ниже). Файл уже
переведён на `solana-program-test` + `tokio::test` + async `BanksClient`.
## Задача
Дописать в `tests/test_smart_updown.rs` все 8 недостающих тестов, сохранив
РОВНО их исходную игровую логику:
1. `create_bet_escrows_sol` — create_bet (UP, side=0): эскроу на global-pda,
read_bet: bettor, side=0, amount, entry_price, bet_time=1_700_000_000,
expire_time=+EXPIRY_SECONDS, status=0; counter становится 1.
2. `close_bet_user_wins_up` — close UP после expiry, exit>entry: bettor получает
payout=amount*MULTIPLIER_BPS/10000 (1.3x), статус=1, exit_price установлен.
Перед этим айрдроп на global-pda ≥10*amount (для выплаты из vault).
3. `close_bet_house_wins_down` — side=DOWN, exit>entry (проиграл): баланс не
меняется, статус=2.
4. `close_bet_equal_price_stays_open` — exit==entry: статус остаётся 0, баланс не меняется.
5. `close_bet_already_finalized` — повторный close после успешного: вторая
транзакция падает (err), статус остаётся 1.
6. `close_bet_before_expiry_rejected` — close до expiry: падает, статус 0.
7. `close_bet_wrong_relayer_rejected` — close не-релейером: падает, статус 0.
8. `close_bet_replay_rejected` — close после уже закрытого: падает.
Плюс тест `create_bet_rejects_out_of_range` (amount=1 < MIN_AMOUNT) — падает.
## Критичные API-условия (solana-program-test 2.x)
- `setup()` возвращает `(ProgramTestContext, Keypair)`; первый тест уже это делает.
- Airdrop любому аккаунту: `system_instruction::transfer(&ctx.payer.pubkey(), to, lamports)` в транзакции от payer.
- Системные часы: получить `Clock` через `ctx.banks_client.get_sysvar().await`, изменить `unix_timestamp`, затем `ctx.set_sysvar(&clk)`.
- Читать аккаунт: `ctx.banks_client.get_account(pubkey).await.unwrap().unwrap()` (Account), данные через `bytemuck::from_bytes::<Global/Bet>(&data[8..])`.
- Проверка баланса: `ctx.banks_client.get_balance(pubkey).await.unwrap()`.
- Все тесты `#[tokio::test] async fn`, всегда первая строка `let (mut ctx, admin) = setup().await;`.
## Как исполнять
Дописать тесты, затем ЗАПУСТИТЬ:
```
cd program && cargo test -p smart-updown 2>&1 | tail -40
```
Добиться, чтобы ВСЕ тесты прошли зелёными (а те, что ожидают ошибку, получали её).
Устранить ошибки компиляции (импорты, типы). НЕ менять игровую логику в src.
## Критерии
- `cargo test -p smart-updown` → все 9+ тестов green (ожидаемые ошибки — как задумано).
- Отчёт: какие тесты добавил, что чинит, какие остались красными (если есть).
Binary file not shown.
+119 -17
View File
@@ -10,7 +10,14 @@
{
"name": "close_bet",
"discriminator": [
2
185,
206,
13,
184,
176,
108,
140,
107
],
"accounts": [
{
@@ -57,15 +64,60 @@
}
},
{
"name": "bettor",
"docs": [
"CHECK: recipient must be the bet's bettor and is validated in the handler."
],
"writable": true
"name": "bettor"
},
{
"name": "clock",
"address": "SysvarC1ock11111111111111111111111111111111"
},
{
"name": "vault_token",
"docs": [
"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."
],
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
},
{
"kind": "const",
"value": [
118,
97,
117,
108,
116
]
}
]
}
},
{
"name": "token_program",
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
}
],
"args": [
@@ -82,7 +134,14 @@
{
"name": "create_bet",
"discriminator": [
1
197,
42,
153,
2,
59,
63,
143,
246
],
"accounts": [
{
@@ -136,6 +195,24 @@
{
"name": "system_program",
"address": "11111111111111111111111111111111"
},
{
"name": "bettor_token",
"docs": [
"Bettor's SPL-token account (our \"fanti\" mint); bet amount is pulled from here."
],
"writable": true
},
{
"name": "vault_token",
"docs": [
"Token vault ATA (owner = PDA([GLOBAL_SEED, VAULT_SEED])); bet amounts are escrowed here."
],
"writable": true
},
{
"name": "token_program",
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
}
],
"args": [
@@ -160,7 +237,14 @@
{
"name": "initialize",
"discriminator": [
0
175,
175,
109,
31,
13,
152,
155,
237
],
"accounts": [
{
@@ -214,7 +298,14 @@
{
"name": "update",
"discriminator": [
3
219,
200,
88,
176,
158,
63,
253,
127
],
"accounts": [
{
@@ -380,10 +471,9 @@
"types": [
{
"name": "Bet",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"docs": [
"Open bet. `amount` is in token base units (decimals = 6), not lamports."
],
"type": {
"kind": "struct",
"fields": [
@@ -488,10 +578,13 @@
},
{
"name": "Global",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"docs": [
"Global game state.",
"",
"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": {
"kind": "struct",
"fields": [
@@ -541,6 +634,15 @@
"name": "GLOBAL_SEED",
"type": "bytes",
"value": "[103, 108, 111, 98, 97, 108]"
},
{
"name": "VAULT_SEED",
"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]"
}
]
}
+5782 -1179
View File
File diff suppressed because it is too large Load Diff
+15 -10
View File
@@ -14,20 +14,25 @@ default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-log-ix-name = []
idl-build = []
profile = ["anchor-v2-testing/profile"]
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
[dependencies]
# Once anchor-lang is published to crates.io, swap to: anchor-lang = "2.0.0-rc.1"
anchor-lang = { git = "https://github.com/otter-sec/anchor.git", branch = "anchor-next" }
solana-program-log = { version = "1.1", features = ["macro"] }
wincode = { version = "0.5", features = ["derive"] }
anchor-lang = "0.31.1"
anchor-spl = "0.31.1"
[dev-dependencies]
anchor-v2-testing = { git = "https://github.com/otter-sec/anchor.git", branch = "anchor-next" }
solana-clock = "3.0.1"
solana-pubkey = "3.0.0"
anchor-lang = "0.31.1"
solana-program-test = "2.1"
solana-client = "2.1"
solana-pubkey = "2.1"
solana-signer = "2.1"
solana-keypair = "2.1"
solana-clock = "2.1"
solana-account = "2.1"
solana-transaction = "2.1"
spl-token = "7.0.0"
spl-associated-token-account = "6.0.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
@@ -6,6 +6,11 @@ pub const GLOBAL_SEED: &[u8] = b"global";
#[constant]
pub const BET_SEED: &[u8] = b"bet";
/// 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.
#[constant]
pub const VAULT_SEED: &[u8] = b"vault";
pub const SIDE_UP: u64 = 0;
pub const SIDE_DOWN: u64 = 1;
+3 -3
View File
@@ -2,8 +2,8 @@ use anchor_lang::prelude::*;
#[event]
pub struct BetCreated {
pub bet: Address,
pub bettor: Address,
pub bet: Pubkey,
pub bettor: Pubkey,
pub side: u64,
pub amount: u64,
pub bet_time: i64,
@@ -13,7 +13,7 @@ pub struct BetCreated {
#[event]
pub struct BetClosed {
pub bet: Address,
pub bet: Pubkey,
pub exit_price: u64,
pub user_won: bool,
pub payout: u64,
@@ -1,9 +1,10 @@
use anchor_lang::{prelude::*, Lamports};
use anchor_lang::prelude::*;
use anchor_spl::token::{Token, TokenAccount};
use crate::{
constants::{
BPS_DIVISOR, BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_HOUSE_WON,
STATUS_PAYOUT_DONE, STATUS_OPEN,
STATUS_PAYOUT_DONE, STATUS_OPEN, VAULT_SEED,
},
error::SmartUpdownError,
events::BetClosed,
@@ -12,33 +13,43 @@ use crate::{
#[derive(Accounts)]
#[instruction(id: u64, exit_price: u64)]
pub struct CloseBet {
pub relayer: Signer,
pub struct CloseBet<'info> {
pub relayer: Signer<'info>,
#[account(
mut,
seeds = [BET_SEED, id.to_le_bytes()],
seeds = [BET_SEED, &id.to_le_bytes()],
bump
)]
pub bet: Account<Bet>,
pub bet: Account<'info, Bet>,
#[account(
mut,
seeds = [GLOBAL_SEED],
bump
)]
pub global: Account<Global>,
pub global: Account<'info, Global>,
/// CHECK: recipient must be the bet's bettor and is validated in the handler.
pub bettor: SystemAccount<'info>,
pub clock: Sysvar<'info, Clock>,
/// Token vault ATA (owner = PDA([GLOBAL_SEED, VAULT_SEED])); payouts are paid out from here.
#[account(mut)]
pub bettor: SystemAccount,
pub clock: Sysvar<Clock>,
pub vault_token: Account<'info, TokenAccount>,
/// Bettor's SPL-token account; the payout is sent here.
#[account(mut)]
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 fn handler(ctx: &mut Context<CloseBet>, id: u64, exit_price: u64) -> Result<()> {
require!(
anchor_lang::address_eq(ctx.accounts.relayer.address(), &ctx.accounts.global.relayer),
ctx.accounts.relayer.key() == ctx.accounts.global.relayer,
SmartUpdownError::NotRelayer
);
require!(
anchor_lang::address_eq(ctx.accounts.bettor.address(), &ctx.accounts.bet.bettor),
ctx.accounts.bettor.key() == ctx.accounts.bet.bettor,
SmartUpdownError::NotRelayer
);
require!(
@@ -59,7 +70,7 @@ pub fn handler(ctx: &mut Context<CloseBet>, id: u64, exit_price: u64) -> Result<
let user_won = (side == SIDE_UP && exit_price > entry)
|| (side == SIDE_DOWN && exit_price < entry);
let payout;
let payout;
if user_won {
let multiplier = ctx.accounts.global.multiplier_bps;
let payout_amount =
@@ -69,12 +80,31 @@ pub fn handler(ctx: &mut Context<CloseBet>, id: u64, exit_price: u64) -> Result<
.checked_div(BPS_DIVISOR as u128)
.unwrap() as u64;
require!(
ctx.accounts.global.view().lamports() >= payout_amount,
ctx.accounts.vault_token.amount >= payout_amount,
SmartUpdownError::InsufficientVault
);
payout = payout_amount;
ctx.accounts.global.sub_lamports(payout)?;
ctx.accounts.bettor.add_lamports(payout)?;
{
// Pay the bettor: transfer the payout from the vault to the bettor's token account.
// The vault ATA is owned by the vault PDA, so the CPI is signed with its seeds.
let cpi_accounts = anchor_spl::token::Transfer {
from: ctx.accounts.vault_token.to_account_info(),
to: ctx.accounts.bettor_token.to_account_info(),
authority: ctx.accounts.vault_authority.to_account_info(),
};
let signer_seeds: &[&[u8]] = &[
GLOBAL_SEED,
VAULT_SEED,
&[ctx.bumps.vault_authority],
];
let vault_signer_seeds = [signer_seeds];
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
&vault_signer_seeds,
);
anchor_spl::token::transfer(cpi_ctx, payout)?;
}
ctx.accounts.bet.status = STATUS_PAYOUT_DONE;
} else {
payout = 0;
@@ -84,7 +114,7 @@ pub fn handler(ctx: &mut Context<CloseBet>, id: u64, exit_price: u64) -> Result<
ctx.accounts.bet.exit_price = exit_price;
emit!(BetClosed {
bet: *ctx.accounts.bet.address(),
bet: ctx.accounts.bet.key(),
exit_price,
user_won,
payout,
@@ -1,4 +1,5 @@
use anchor_lang::prelude::*;
use anchor_spl::token::{Token, TokenAccount};
use crate::{
constants::{BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_OPEN},
@@ -9,24 +10,32 @@ use crate::{
#[derive(Accounts)]
#[instruction(id: u64, side: u64, amount: u64, entry_price: u64)]
pub struct CreateBet {
pub struct CreateBet<'info> {
#[account(mut)]
pub bettor: Signer,
pub bettor: Signer<'info>,
#[account(
mut,
seeds = [GLOBAL_SEED],
bump
)]
pub global: Account<Global>,
pub global: Account<'info, Global>,
#[account(
init,
payer = bettor,
seeds = [BET_SEED, id.to_le_bytes()],
space = 8 + Bet::INIT_SPACE,
seeds = [BET_SEED, &id.to_le_bytes()],
bump
)]
pub bet: Account<Bet>,
pub clock: Sysvar<Clock>,
pub system_program: Program<System>,
pub bet: Account<'info, Bet>,
pub clock: Sysvar<'info, Clock>,
pub system_program: Program<'info, System>,
/// Bettor's SPL-token account (our "fanti" mint); bet amount is pulled from here.
#[account(mut)]
pub bettor_token: Account<'info, TokenAccount>,
/// Token vault ATA (owner = PDA([GLOBAL_SEED, VAULT_SEED])); bet amounts are escrowed here.
#[account(mut)]
pub vault_token: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
}
pub fn handler(
@@ -52,8 +61,8 @@ pub fn handler(
);
let now = ctx.accounts.clock.unix_timestamp;
let bettor_address = *ctx.accounts.bettor.address();
let bet_address = *ctx.accounts.bet.address();
let bettor_address = ctx.accounts.bettor.key();
let bet_address = ctx.accounts.bet.key();
let expiry_seconds = ctx.accounts.global.expiry_seconds;
{
@@ -71,12 +80,18 @@ pub fn handler(
ctx.accounts.global.counter = ctx.accounts.global.counter.checked_add(1).unwrap();
{
let cpi_accounts = system_program::Transfer {
from: ctx.accounts.bettor.cpi_handle_mut(),
to: ctx.accounts.global.cpi_handle_mut(),
// Escrow: move the bet amount (token units) from the bettor's account to the vault.
// The SPL token program enforces that `bettor_token` is owned by `bettor` (authority).
let cpi_accounts = anchor_spl::token::Transfer {
from: ctx.accounts.bettor_token.to_account_info(),
to: ctx.accounts.vault_token.to_account_info(),
authority: ctx.accounts.bettor.to_account_info(),
};
let cpi_ctx = CpiContext::new(ctx.accounts.system_program.address(), cpi_accounts);
system_program::transfer(cpi_ctx, amount)?;
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
anchor_spl::token::transfer(cpi_ctx, amount)?;
}
emit!(BetCreated {
@@ -3,17 +3,18 @@ use anchor_lang::prelude::*;
use crate::{constants::GLOBAL_SEED, state::Global};
#[derive(Accounts)]
pub struct Initialize {
pub struct Initialize<'info> {
#[account(mut)]
pub admin: Signer,
pub admin: Signer<'info>,
#[account(
init,
payer = admin,
space = 8 + Global::INIT_SPACE,
seeds = [GLOBAL_SEED],
bump
)]
pub global: Account<Global>,
pub system_program: Program<System>,
pub global: Account<'info, Global>,
pub system_program: Program<'info, System>,
}
pub fn handler(
@@ -24,8 +25,8 @@ pub fn handler(
expiry_seconds: i64,
) -> Result<()> {
let global = &mut ctx.accounts.global;
global.admin = *ctx.accounts.admin.address();
global.relayer = *ctx.accounts.admin.address();
global.admin = ctx.accounts.admin.key();
global.relayer = ctx.accounts.admin.key();
global.counter = 0;
global.min_amount = min_amount;
global.max_amount = max_amount;
@@ -7,19 +7,19 @@ use crate::{
};
#[derive(Accounts)]
pub struct Update {
pub admin: Signer,
pub struct Update<'info> {
pub admin: Signer<'info>,
#[account(
mut,
seeds = [GLOBAL_SEED],
bump
)]
pub global: Account<Global>,
pub global: Account<'info, Global>,
}
pub fn handler(
ctx: &mut Context<Update>,
new_relayer: Address,
new_relayer: Pubkey,
min_amount: u64,
max_amount: u64,
multiplier_bps: u64,
@@ -27,7 +27,7 @@ pub fn handler(
expiry_seconds: i64,
) -> Result<()> {
require!(
anchor_lang::address_eq(ctx.accounts.admin.address(), &ctx.accounts.global.admin),
ctx.accounts.admin.key() == ctx.accounts.global.admin,
SmartUpdownError::NotAdmin
);
let global = &mut ctx.accounts.global;
+9 -13
View File
@@ -14,41 +14,37 @@ declare_id!("9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf");
pub mod smart_updown {
use super::*;
#[discrim = 0]
pub fn initialize(
ctx: &mut Context<Initialize>,
mut ctx: Context<Initialize>,
min_amount: u64,
max_amount: u64,
multiplier_bps: u64,
expiry_seconds: i64,
) -> Result<()> {
initialize::handler(ctx, min_amount, max_amount, multiplier_bps, expiry_seconds)
initialize::handler(&mut ctx, min_amount, max_amount, multiplier_bps, expiry_seconds)
}
#[discrim = 1]
pub fn create_bet(
ctx: &mut Context<CreateBet>,
mut ctx: Context<CreateBet>,
id: u64,
side: u64,
amount: u64,
entry_price: u64,
) -> Result<()> {
create_bet::handler(ctx, id, side, amount, entry_price)
create_bet::handler(&mut ctx, id, side, amount, entry_price)
}
#[discrim = 2]
pub fn close_bet(
ctx: &mut Context<CloseBet>,
mut ctx: Context<CloseBet>,
id: u64,
exit_price: u64,
) -> Result<()> {
close_bet::handler(ctx, id, exit_price)
close_bet::handler(&mut ctx, id, exit_price)
}
#[discrim = 3]
pub fn update(
ctx: &mut Context<Update>,
new_relayer: Address,
mut ctx: Context<Update>,
new_relayer: Pubkey,
min_amount: u64,
max_amount: u64,
multiplier_bps: u64,
@@ -56,7 +52,7 @@ pub mod smart_updown {
expiry_seconds: i64,
) -> Result<()> {
update::handler(
ctx,
&mut ctx,
new_relayer,
min_amount,
max_amount,
+11 -3
View File
@@ -1,6 +1,12 @@
use anchor_lang::prelude::*;
/// Global game state.
///
/// 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.
#[account]
#[derive(InitSpace)]
pub struct Global {
pub counter: u64,
pub min_amount: u64,
@@ -8,11 +14,13 @@ pub struct Global {
pub multiplier_bps: u64,
pub expiry_seconds: i64,
pub paused: u64,
pub admin: Address,
pub relayer: Address,
pub admin: Pubkey,
pub relayer: Pubkey,
}
/// Open bet. `amount` is in token base units (decimals = 6), not lamports.
#[account]
#[derive(InitSpace)]
pub struct Bet {
pub amount: u64,
pub bet_time: i64,
@@ -21,5 +29,5 @@ pub struct Bet {
pub exit_price: u64,
pub side: u64,
pub status: u64,
pub bettor: Address,
pub bettor: Pubkey,
}
@@ -1,51 +1,155 @@
use {
anchor_lang::{
bytemuck, cpi::find_program_address, solana_program::instruction::Instruction, Id,
InstructionData, ToAccountMetas,
solana_program::{
instruction::Instruction,
program_option::COption,
program_pack::Pack,
pubkey::Pubkey,
rent::Rent,
system_instruction,
},
anchor_v2_testing::{Keypair, LiteSVM, Message, Signer, VersionedMessage, VersionedTransaction},
smart_updown::state::{Bet, Global},
AccountDeserialize, InstructionData, ToAccountMetas,
},
smart_updown::{constants::VAULT_SEED, state::{Bet, Global}},
solana_account::{Account as SolanaAccount},
solana_clock::Clock,
solana_keypair::Keypair,
solana_program_test::{BanksClient, ProgramTest, ProgramTestContext},
solana_signer::Signer,
solana_transaction::Transaction,
spl_associated_token_account,
spl_token,
spl_token::state::{Account as TokenData, AccountState, Mint},
};
const CLOCK_ADDR: &str = "SysvarC1ock11111111111111111111111111111111";
const SYSTEM_ADDR: &str = "11111111111111111111111111111111";
const MIN_AMOUNT: u64 = 1_000_000_000; // 1 SOL
const MIN_AMOUNT: u64 = 1_000_000_000; // minimum bet, token units
const MULTIPLIER_BPS: u64 = 13_000; // 1.3x
const EXPIRY_SECONDS: i64 = 15;
const START_TIME: i64 = 1_700_000_000;
type Addr = anchor_lang::prelude::Address;
// SPL token (фантики) environment
const TOKEN_DECIMALS: u8 = 6;
const MINT_SIZE: u64 = 82; // classic SPL token mint account size
const TOKEN_ACCOUNT_SIZE: u64 = 165; // classic SPL token account size
const VAULT_TOKEN_SUPPLY: u64 = 100 * MIN_AMOUNT; // vault seed, covers many payouts
const BETTOR_TOKEN_SUPPLY: u64 = 10 * MIN_AMOUNT; // per-bettor token supply
/// Fixed фантики mint: mint + vault token accounts are pre-loaded into
/// genesis because the vault is a PDA and cannot sign its own create_account.
const MINT: Pubkey = Pubkey::new_from_array([3u8; 32]);
fn pid() -> Addr {
fn pid() -> Pubkey {
smart_updown::id()
}
fn global_pda() -> Addr {
find_program_address(&[b"global"], &pid()).0
fn global_pda() -> Pubkey {
Pubkey::find_program_address(&[b"global"], &pid()).0
}
fn bet_pda(id: u64) -> Addr {
find_program_address(&[b"bet", &id.to_le_bytes()], &pid()).0
fn vault_pda() -> Pubkey {
Pubkey::find_program_address(&[b"global", VAULT_SEED], &pid()).0
}
fn setup() -> (LiteSVM, Keypair) {
let mut svm = LiteSVM::new();
let bytes = include_bytes!("../../../target/deploy/smart_updown.so");
svm.add_program(pid(), bytes).expect("deploy program");
let payer = Keypair::new();
svm.airdrop(&payer.pubkey(), 100_000_000_000).unwrap();
(svm, payer)
fn bet_pda(id: u64) -> Pubkey {
Pubkey::find_program_address(&[b"bet", &id.to_le_bytes()], &pid()).0
}
fn send(svm: &mut LiteSVM, signers: &[&Keypair], instruction: Instruction) -> Result<(), String> {
let blockhash = svm.latest_blockhash();
let msg = Message::new_with_blockhash(&[instruction], Some(&signers[0].pubkey()), &blockhash);
let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), signers).unwrap();
let res = svm.send_transaction(tx);
res.map(|_| ()).map_err(|e| format!("{:?}", e))
/// Boot the program-test context with the фантики mint and the program's
/// token vault (PDA([global, vault])) pre-loaded into genesis. The vault is a
/// PDA owned by itself, so it can never sign a top-level create_account; the
/// account data below matches what the token program would have created.
async fn setup() -> (ProgramTestContext, Keypair) {
let admin = Keypair::new();
let mint_data = {
let m = Mint {
mint_authority: COption::Some(admin.pubkey()),
supply: 0,
decimals: TOKEN_DECIMALS,
is_initialized: true,
freeze_authority: COption::None,
};
let mut buf = [0u8; MINT_SIZE as usize];
Pack::pack(m, &mut buf).expect("pack mint");
buf
};
let vault_data = {
let a = TokenData {
mint: MINT,
owner: vault_pda(),
amount: VAULT_TOKEN_SUPPLY,
delegate: COption::None,
state: AccountState::Initialized,
is_native: COption::None,
delegated_amount: 0,
close_authority: COption::None,
};
let mut buf = [0u8; TOKEN_ACCOUNT_SIZE as usize];
Pack::pack(a, &mut buf).expect("pack vault token account");
buf
};
let mut pt = ProgramTest::default();
pt.add_program("smart_updown", pid(), None);
let rent = Rent::default();
pt.add_account(
MINT,
SolanaAccount {
lamports: rent.minimum_balance(MINT_SIZE as usize),
data: mint_data.to_vec(),
owner: spl_token::ID,
executable: false,
rent_epoch: 0,
},
);
pt.add_account(
vault_pda(),
SolanaAccount {
lamports: rent.minimum_balance(TOKEN_ACCOUNT_SIZE as usize),
data: vault_data.to_vec(),
owner: spl_token::ID,
executable: false,
rent_epoch: 0,
},
);
// Fund the deploy keypair separately in the test; the validator in CI funds it.
let context = pt.start_with_context().await;
context.banks_client.process_transaction(Transaction::new_signed_with_payer(
&[system_instruction::transfer(
&context.payer.pubkey(),
&admin.pubkey(),
100_000_000_000,
)],
Some(&context.payer.pubkey()),
&[&context.payer],
context.last_blockhash,
)).await.unwrap();
(context, admin)
}
fn initialize(svm: &mut LiteSVM, admin: &Keypair) {
let ix = Instruction::new_with_bytes(
async fn set_clock(ctx: &mut ProgramTestContext, unix: i64) {
let mut clk: Clock = ctx.banks_client.get_sysvar().await.unwrap();
clk.unix_timestamp = unix;
ctx.set_sysvar(&clk);
}
async fn read_account_data(
bank: &BanksClient,
addr: &Pubkey,
) -> Vec<u8> {
bank.get_account(*addr).await.unwrap().expect("account exists").data
}
fn read_global_typed(data: &[u8]) -> Global {
Global::try_deserialize(&mut &data[..]).unwrap()
}
fn read_bet_typed(data: &[u8]) -> Bet {
Bet::try_deserialize(&mut &data[..]).unwrap()
}
fn make_initialize_ix(admin: Pubkey) -> Instruction {
Instruction::new_with_bytes(
pid(),
&smart_updown::instruction::Initialize {
min_amount: MIN_AMOUNT,
@@ -55,37 +159,22 @@ fn initialize(svm: &mut LiteSVM, admin: &Keypair) {
}
.data(),
smart_updown::accounts::Initialize {
admin: admin.pubkey(),
admin,
global: global_pda(),
system_program: SYSTEM_ADDR.parse().unwrap(),
}
.to_account_metas(None),
);
send(svm, &[admin], ix).expect("initialize");
)
}
fn set_clock(svm: &mut LiteSVM, unix: i64) {
use solana_clock::Clock;
let mut clk = svm.get_sysvar::<Clock>();
clk.unix_timestamp = unix;
svm.set_sysvar(&clk);
}
fn fund_vault(svm: &mut LiteSVM, lamports: u64) {
svm.airdrop(&global_pda(), lamports).unwrap();
}
fn read_global(svm: &LiteSVM) -> Global {
let acc = svm.get_account(&global_pda()).expect("global account");
*bytemuck::from_bytes::<Global>(&acc.data[8..])
}
fn read_bet(svm: &LiteSVM, id: u64) -> Bet {
let acc = svm.get_account(&bet_pda(id)).expect("bet account");
*bytemuck::from_bytes::<Bet>(&acc.data[8..])
}
fn make_create_ix(id: u64, side: u64, amount: u64, entry_price: u64, bettor: Addr) -> Instruction {
fn make_create_ix(
id: u64,
side: u64,
amount: u64,
entry_price: u64,
bettor: Pubkey,
bettor_ata: &Pubkey,
) -> Instruction {
Instruction::new_with_bytes(
pid(),
&smart_updown::instruction::CreateBet {
@@ -101,12 +190,21 @@ fn make_create_ix(id: u64, side: u64, amount: u64, entry_price: u64, bettor: Add
bet: bet_pda(id),
clock: CLOCK_ADDR.parse().unwrap(),
system_program: SYSTEM_ADDR.parse().unwrap(),
bettor_token: *bettor_ata,
vault_token: vault_pda(),
token_program: spl_token::ID,
}
.to_account_metas(None),
)
}
fn make_close_ix(id: u64, exit_price: u64, relayer: Addr, bettor: Addr) -> Instruction {
fn make_close_ix(
id: u64,
exit_price: u64,
relayer: Pubkey,
bettor: Pubkey,
bettor_ata: &Pubkey,
) -> Instruction {
Instruction::new_with_bytes(
pid(),
&smart_updown::instruction::CloseBet { id, exit_price }.data(),
@@ -116,27 +214,145 @@ fn make_close_ix(id: u64, exit_price: u64, relayer: Addr, bettor: Addr) -> Instr
global: global_pda(),
bettor,
clock: CLOCK_ADDR.parse().unwrap(),
vault_token: vault_pda(),
bettor_token: *bettor_ata,
vault_authority: vault_pda(),
token_program: spl_token::ID,
}
.to_account_metas(None),
)
}
fn create_up_bet(svm: &mut LiteSVM, admin: &Keypair, side: u64) -> (Keypair, u64, u64) {
let bettor = Keypair::new();
svm.airdrop(&bettor.pubkey(), 100_000_000_000).unwrap();
set_clock(svm, 1_700_000_000);
let amount = MIN_AMOUNT;
let entry_price = 100_000_000u64;
let create_ix = make_create_ix(0, side, amount, entry_price, bettor.pubkey());
send(svm, &[&bettor], create_ix).expect("create_bet");
(bettor, amount, entry_price)
async fn run_ix(
ctx: &mut ProgramTestContext,
signer: &Keypair,
ix: Instruction,
) -> Result<(), String> {
let tx = Transaction::new_signed_with_payer(
&[ix],
Some(&signer.pubkey()),
&[signer],
ctx.last_blockhash,
);
ctx.banks_client
.process_transaction(tx)
.await
.map(|_| ())
.map_err(|e| format!("{:?}", e))
}
#[test]
fn initialize_creates_global() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let g = read_global(&svm);
async fn initialize(ctx: &mut ProgramTestContext, admin: &Keypair) {
run_ix(ctx, admin, make_initialize_ix(admin.pubkey()))
.await
.expect("initialize");
}
async fn airdrop_to(ctx: &mut ProgramTestContext, to: &Pubkey, lamports: u64) {
let tx = Transaction::new_signed_with_payer(
&[system_instruction::transfer(
&ctx.payer.pubkey(),
to,
lamports,
)],
Some(&ctx.payer.pubkey()),
&[&ctx.payer],
ctx.last_blockhash,
);
ctx.banks_client.process_transaction(tx).await.unwrap();
}
/// Create the token mint, the program's token vault (PDA([global, vault]),
/// owned by the global PDA), and seed the vault with tokens.
// The фантики env (mint + seeded vault) is pre-loaded into genesis by setup();
// a PDA vault can never sign its own top-level create_account, so this only
// returns the fixed mint the genesis accounts were built around.
async fn create_token_env(_ctx: &mut ProgramTestContext, _admin: &Keypair) -> Pubkey {
MINT
}
/// Mint tokens to a destination account (admin is the mint authority).
async fn mint_to(
ctx: &mut ProgramTestContext,
admin: &Keypair,
mint: &Pubkey,
dest: &Pubkey,
amount: u64,
) {
let ix = spl_token::instruction::mint_to(&spl_token::ID, mint, dest, &admin.pubkey(), &[], amount)
.expect("mint_to");
run_ix(ctx, admin, ix).await.expect("mint_to");
}
/// New bettor: lamports account + associated token account + token supply.
async fn create_bettor(
ctx: &mut ProgramTestContext,
admin: &Keypair,
mint: &Pubkey,
) -> (Keypair, Pubkey) {
let bettor = Keypair::new();
airdrop_to(ctx, &bettor.pubkey(), 100_000_000_000).await;
let ata = spl_associated_token_account::get_associated_token_address(&bettor.pubkey(), mint);
let ix = spl_associated_token_account::instruction::create_associated_token_account(
&admin.pubkey(),
&bettor.pubkey(),
mint,
&spl_token::ID,
);
run_ix(ctx, admin, ix).await.expect("create ata");
mint_to(ctx, admin, mint, &ata, BETTOR_TOKEN_SUPPLY).await;
(bettor, ata)
}
/// Read the `amount` field of a classic SPL token account (offset 64).
async fn read_token_balance(ctx: &mut ProgramTestContext, addr: &Pubkey) -> u64 {
let data = read_account_data(&ctx.banks_client, addr).await;
u64::from_le_bytes(data[64..72].try_into().expect("amount offset"))
}
/// close_bet pays out by moving tokens out of the vault; the vault PDA signs
/// that transfer CPI inside the program, so the transaction needs only the
/// relayer's signature.
async fn run_close_tx(
ctx: &mut ProgramTestContext,
signer: &Keypair,
ix: &Instruction,
) -> Result<(), String> {
// The vault PDA signs the payout CPI inside the program (via signer_seeds);
// the transaction itself needs only the relayer's signature.
let tx = Transaction::new_signed_with_payer(
std::slice::from_ref(ix),
Some(&signer.pubkey()),
&[signer],
ctx.last_blockhash,
);
ctx.banks_client
.process_transaction(tx)
.await
.map(|_| ())
.map_err(|e| format!("{:?}", e))
}
async fn create_up_bet(
ctx: &mut ProgramTestContext,
side: u64,
mint: &Pubkey,
admin: &Keypair,
) -> (Keypair, Pubkey, u64, u64) {
let (bettor, ata) = create_bettor(ctx, admin, mint).await;
set_clock(ctx, START_TIME).await;
let amount = MIN_AMOUNT;
let entry_price = 100_000_000u64;
let create_ix = make_create_ix(0, side, amount, entry_price, bettor.pubkey(), &ata);
run_ix(ctx, &bettor, create_ix).await.expect("create_bet");
(bettor, ata, amount, entry_price)
}
#[tokio::test]
async fn initialize_creates_global() {
let (mut ctx, admin) = setup().await;
initialize(&mut ctx, &admin).await;
let acc = read_account_data(&ctx.banks_client, &global_pda()).await;
let g = read_global_typed(&acc);
assert_eq!(g.admin, admin.pubkey());
assert_eq!(g.relayer, admin.pubkey());
assert_eq!(g.counter, 0);
@@ -145,139 +361,201 @@ fn initialize_creates_global() {
assert_eq!(g.paused, 0);
}
#[test]
fn create_bet_escrows_sol() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
#[tokio::test]
async fn create_bet_escrows_tokens() {
let (mut ctx, admin) = setup().await;
initialize(&mut ctx, &admin).await;
let mint = create_token_env(&mut ctx, &admin).await;
let (bettor, ata, amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await;
let vault_balance = svm.get_balance(&global_pda()).unwrap();
assert!(vault_balance >= amount);
let b = read_bet(&svm, 0);
assert_eq!(
read_token_balance(&mut ctx, &vault_pda()).await,
VAULT_TOKEN_SUPPLY + amount
);
assert_eq!(
read_token_balance(&mut ctx, &ata).await,
BETTOR_TOKEN_SUPPLY - amount
);
let b = read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await);
assert_eq!(b.bettor, bettor.pubkey());
assert_eq!(b.side, 0);
assert_eq!(b.amount, amount);
assert_eq!(b.entry_price, entry_price);
assert_eq!(b.bet_time, 1_700_000_000);
assert_eq!(b.expire_time, 1_700_000_000 + EXPIRY_SECONDS);
assert_eq!(b.bet_time, START_TIME);
assert_eq!(b.expire_time, START_TIME + EXPIRY_SECONDS);
assert_eq!(b.status, 0);
assert_eq!(read_global(&svm).counter, 1);
assert_eq!(read_global_typed(&read_account_data(&ctx.banks_client, &global_pda()).await).counter, 1);
}
#[test]
fn close_bet_user_wins_up() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
#[tokio::test]
async fn close_bet_user_wins_up() {
let (mut ctx, admin) = setup().await;
initialize(&mut ctx, &admin).await;
let mint = create_token_env(&mut ctx, &admin).await;
let (bettor, ata, amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await;
set_clock(&mut svm, 1_700_000_000 + EXPIRY_SECONDS + 1);
set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await;
let exit_price = entry_price + 1_000;
let before = svm.get_balance(&bettor.pubkey()).unwrap();
let before = read_token_balance(&mut ctx, &ata).await;
let payout = amount * MULTIPLIER_BPS / 10_000;
fund_vault(&mut svm, 10 * amount);
let close_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey());
send(&mut svm, &[&admin], close_ix).expect("close_bet");
run_close_tx(
&mut ctx,
&admin,
&make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey(), &ata),
)
.await
.expect("close_bet");
assert_eq!(svm.get_balance(&bettor.pubkey()).unwrap(), before + payout);
let b = read_bet(&svm, 0);
assert_eq!(read_token_balance(&mut ctx, &ata).await, before + payout);
assert_eq!(
read_token_balance(&mut ctx, &vault_pda()).await,
VAULT_TOKEN_SUPPLY + amount - payout
);
let b = read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await);
assert_eq!(b.status, 1);
assert_eq!(b.exit_price, exit_price);
}
#[test]
fn close_bet_house_wins_down() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, _amount, entry_price) = create_up_bet(&mut svm, &admin, 1);
#[tokio::test]
async fn close_bet_house_wins_down() {
let (mut ctx, admin) = setup().await;
initialize(&mut ctx, &admin).await;
let mint = create_token_env(&mut ctx, &admin).await;
let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 1, &mint, &admin).await;
set_clock(&mut svm, 1_700_000_000 + EXPIRY_SECONDS + 1);
set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await;
let exit_price = entry_price + 1_000; // side=DOWN but price rose -> lose
let before = svm.get_balance(&bettor.pubkey()).unwrap();
let before = read_token_balance(&mut ctx, &ata).await;
let close_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey());
send(&mut svm, &[&admin], close_ix).expect("close_bet");
run_close_tx(
&mut ctx,
&admin,
&make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey(), &ata),
)
.await
.expect("close_bet");
assert_eq!(svm.get_balance(&bettor.pubkey()).unwrap(), before);
assert_eq!(read_bet(&svm, 0).status, 2);
assert_eq!(read_token_balance(&mut ctx, &ata).await, before);
assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 2);
}
#[test]
fn close_bet_equal_price_stays_open() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
#[tokio::test]
async fn close_bet_equal_price_stays_open() {
let (mut ctx, admin) = setup().await;
initialize(&mut ctx, &admin).await;
let mint = create_token_env(&mut ctx, &admin).await;
let (bettor, ata, amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await;
set_clock(&mut svm, 1_700_000_000 + EXPIRY_SECONDS + 1);
let before = svm.get_balance(&bettor.pubkey()).unwrap();
set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await;
let before = read_token_balance(&mut ctx, &ata).await;
let payout = amount * MULTIPLIER_BPS / 10_000;
fund_vault(&mut svm, 10 * amount);
let equal_ix = make_close_ix(0, entry_price, admin.pubkey(), bettor.pubkey());
send(&mut svm, &[&admin], equal_ix).expect("equal close");
assert_eq!(read_bet(&svm, 0).status, 0);
assert_eq!(svm.get_balance(&bettor.pubkey()).unwrap(), before);
let equal_ix = make_close_ix(0, entry_price, admin.pubkey(), bettor.pubkey(), &ata);
run_close_tx(&mut ctx, &admin, &equal_ix).await.expect("equal close");
assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 0);
assert_eq!(read_token_balance(&mut ctx, &ata).await, before);
let exit_price = entry_price + 1_000;
let second_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey());
send(&mut svm, &[&admin], second_ix).expect("second close");
let second_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey(), &ata);
run_close_tx(&mut ctx, &admin, &second_ix).await.expect("second close");
assert_eq!(
svm.get_balance(&bettor.pubkey()).unwrap(),
read_token_balance(&mut ctx, &ata).await,
before + payout
);
assert_eq!(read_bet(&svm, 0).status, 1);
assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 1);
}
#[test]
fn close_bet_before_expiry_rejected() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, _amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
#[tokio::test]
async fn close_bet_already_finalized() {
let (mut ctx, admin) = setup().await;
initialize(&mut ctx, &admin).await;
let mint = create_token_env(&mut ctx, &admin).await;
let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await;
let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey());
let res = send(&mut svm, &[&admin], close_ix);
set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await;
let win_price = entry_price + 500;
run_close_tx(
&mut ctx,
&admin,
&make_close_ix(0, win_price, admin.pubkey(), bettor.pubkey(), &ata),
)
.await
.expect("first close");
let res = run_close_tx(
&mut ctx,
&admin,
&make_close_ix(0, win_price + 1, admin.pubkey(), bettor.pubkey(), &ata),
)
.await;
assert!(res.is_err(), "expected close after finalization to be rejected");
let b = read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await);
assert_eq!(b.status, 1);
assert_eq!(b.exit_price, win_price);
}
#[tokio::test]
async fn close_bet_before_expiry_rejected() {
let (mut ctx, admin) = setup().await;
initialize(&mut ctx, &admin).await;
let mint = create_token_env(&mut ctx, &admin).await;
let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await;
let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey(), &ata);
let res = run_close_tx(&mut ctx, &admin, &close_ix).await;
assert!(res.is_err(), "expected premature close to be rejected");
assert_eq!(read_bet(&svm, 0).status, 0);
assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 0);
}
#[test]
fn close_bet_wrong_relayer_rejected() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, _amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
#[tokio::test]
async fn close_bet_wrong_relayer_rejected() {
let (mut ctx, admin) = setup().await;
initialize(&mut ctx, &admin).await;
let mint = create_token_env(&mut ctx, &admin).await;
let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await;
let attacker = Keypair::new();
svm.airdrop(&attacker.pubkey(), 100_000_000_000).unwrap();
airdrop_to(&mut ctx, &attacker.pubkey(), 100_000_000_000).await;
set_clock(&mut svm, 1_700_000_000 + EXPIRY_SECONDS + 1);
let close_ix = make_close_ix(0, entry_price + 500, attacker.pubkey(), bettor.pubkey());
let res = send(&mut svm, &[&attacker], close_ix);
set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await;
let close_ix = make_close_ix(0, entry_price + 500, attacker.pubkey(), bettor.pubkey(), &ata);
let res = run_close_tx(&mut ctx, &attacker, &close_ix).await;
assert!(res.is_err(), "expected wrong relayer to be rejected");
assert_eq!(read_bet(&svm, 0).status, 0);
assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 0);
}
#[test]
fn close_bet_replay_rejected() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, _amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
#[tokio::test]
async fn close_bet_replay_rejected() {
let (mut ctx, admin) = setup().await;
initialize(&mut ctx, &admin).await;
let mint = create_token_env(&mut ctx, &admin).await;
let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await;
set_clock(&mut svm, 1_700_000_000 + EXPIRY_SECONDS + 1);
fund_vault(&mut svm, 10 * MIN_AMOUNT);
let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey());
send(&mut svm, &[&admin], close_ix).expect("first close");
let res = send(&mut svm, &[&admin], make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey()));
set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await;
let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey(), &ata);
run_close_tx(&mut ctx, &admin, &close_ix).await.expect("first close");
let before = read_token_balance(&mut ctx, &ata).await;
let res = run_close_tx(
&mut ctx,
&admin,
&make_close_ix(0, entry_price + 501, admin.pubkey(), bettor.pubkey(), &ata),
)
.await;
assert!(res.is_err(), "expected replay close to be rejected");
assert_eq!(read_token_balance(&mut ctx, &ata).await, before);
}
#[test]
fn create_bet_rejects_out_of_range() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let bettor = Keypair::new();
svm.airdrop(&bettor.pubkey(), 100_000_000_000).unwrap();
set_clock(&mut svm, 1_700_000_000);
#[tokio::test]
async fn create_bet_rejects_out_of_range() {
let (mut ctx, admin) = setup().await;
initialize(&mut ctx, &admin).await;
let mint = create_token_env(&mut ctx, &admin).await;
let (bettor, ata) = create_bettor(&mut ctx, &admin, &mint).await;
set_clock(&mut ctx, START_TIME).await;
let ix = make_create_ix(0, 0, 1, 100_000_000u64, bettor.pubkey());
let res = send(&mut svm, &[&bettor], ix);
let vault_before = read_token_balance(&mut ctx, &vault_pda()).await;
let ix = make_create_ix(0, 0, 1, 100_000_000u64, bettor.pubkey(), &ata);
let res = run_ix(&mut ctx, &bettor, ix).await;
assert!(res.is_err(), "expected below-min bet to be rejected");
assert_eq!(read_token_balance(&mut ctx, &vault_pda()).await, vault_before);
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.log
.gateway-bettors.json
*.md
+13 -5
View File
@@ -1,14 +1,22 @@
# RPC эндпоинт кластера (localnet = запущенный solana-test-validator)
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
# Путь к keypair админа/релеера (тот же ключ = admin и relayer)
# Путь к keypair админа/релеера (тот же ключ = admin и relayer; на localnet ещё и mint authority)
SMART_ADMIN_KEYPAIR=~/.config/solana/id.json
# Параметры контракта (должны совпадать со значениями при initialize)
SMART_MIN_AMOUNT=100000000 # 0.1 SOL в лампортах
SMART_MAX_AMOUNT=10000000000 # 10 SOL в лампортах
# Параметры контракта (должны совпадать со значениями при initialize).
# ВАЖНО: теперь это ТОКЕН-юниты (base units фантиков, decimals = 6), а не лампорты!
SMART_MIN_AMOUNT=100000000 # 100 фантиков
SMART_MAX_AMOUNT=10000000000 # 10 000 фантиков
SMART_MULTIPLIER_BPS=13000 # 1.3x выплата в базисных пунктах
SMART_EXPIRY_SECONDS=15 # экспирация ставки (секунды)
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
# custodial-ключи бетторов HTTP-шлюза (gateway.ts) — НЕ коммитить
.gateway-bettors.json
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY src/ ./src/
COPY idl/ ./idl/
COPY tsconfig.json ./
USER node
ENV GW_PORT=8895
EXPOSE 8895
CMD ["npx", "ts-node", "src/gateway.ts"]
+43
View File
@@ -0,0 +1,43 @@
# TASK — HTTP-шлюз поверх релейера (чтобы пульт ставил ставки без Phantom)
## Контекст
`smart-updown-token` — контракт на SPL-токене (фантики) + релейер (`relayer/`), который
**уже умеет** на localnet (RPC 8890): `initialize`, `createBet`, `closeBet`, фандинг
ваулта и беттора. Релейер — CLI скрипт (`npm run run`/`--once`), автономный (сам генерирует
ставки через frontStub), **без HTTP-входа**.
Хочется: статичный веб-пульт (`test-front/index.html`, лежит на `game2.binom.pw`) мог
присылать команду «поставить фантиками от адреса X» на маленький HTTP-шлюз, который
вызывает функции релейера и исполняет ставку на localnet — БЕЗ Phantom.
## РЕАЛИЗАЦИЯ (выполни)
Создай в `relayer/` HTTP-шлюз `gateway.ts` (node http, БЕЗ express, только stdlib),
который:
- `POST /bet {address, side, amountUnits}`:
- derives ATA беттора от `address` и mint
- вызывает существующую функцию `createBet` (не переписывай уже готовую логику,
переиспользуй из `ops.ts`/`index.ts`, если нужно — слегка вынеси)
- фандит беттора если надо (как в `fundBettor`)
- возвращает `{tx, id}` (id нового bet) — id вычислить из Global.counter
- `POST /close {id}`: вызывает `closeBet`
- `GET /balance/{address}`: возвращает `{sol, tokenUnits, tokenHuman}` (SOL-баланс +
токен-баланс по ATA беттора). Токен-баланс — через `getTokenAccountBalance` по ATA.
- `GET /state`: вернуть Global (min/max/expiry, counter) — реюз там же.
- CORS: `Access-Control-Allow-Origin: *` (фронт на другом домене/порту может).
- Порт — из env `GW_PORT` (default 8895). Слушать на 0.0.0.0.
- Логирование запросов в stdout.
Запуск: `npm run gateway` → `ts-node src/gateway.ts`. Проверь `npm run typecheck` и
`npm test` — не ломать существующий релейер.
## НЕ делать
- Не менять контракт `program/`.
- Не переписывать саму логику ставки — только дёргать готовые функции.
- Не коммитить.
## Гейт
- `npm run typecheck` 0 ошибок, `npm test` зелёный.
- Шлюз стартует и отвечает на `GET /state` и `POST /bet` (проверь на localnet 8890:
init уже выполнен).
Отчитайся: какие файлы создал/изменил, как запускать, результат smoke-теста.
+5
View File
@@ -0,0 +1,5 @@
apiVersion: v2
name: smart-updown-relayer
type: application
version: "9.9.9"
appVersion: "9.9.9"
+41
View File
@@ -0,0 +1,41 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "smart-updown-relayer.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "smart-updown-relayer.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Common labels.
*/}}
{{- define "smart-updown-relayer.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
app.kubernetes.io/name: {{ include "smart-updown-relayer.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels.
*/}}
{{- define "smart-updown-relayer.selectorLabels" -}}
app.kubernetes.io/name: {{ include "smart-updown-relayer.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
+111
View File
@@ -0,0 +1,111 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "smart-updown-relayer.fullname" . }}
labels:
{{- include "smart-updown-relayer.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "smart-updown-relayer.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "smart-updown-relayer.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
initContainers:
- name: seed-bettors
image: "{{ .Values.image.name }}:{{ default .Chart.AppVersion .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- sh
- -c
- '[ -f /app/data/.gateway-bettors.json ] || cp /seed/.gateway-bettors.json /app/data/.gateway-bettors.json'
volumeMounts:
- name: data
mountPath: /app/data
- name: bettors-seed
mountPath: /seed
readOnly: true
containers:
- name: gateway
image: "{{ .Values.image.name }}:{{ default .Chart.AppVersion .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.port }}
protocol: TCP
env:
- name: SMART_RPC_URL
value: {{ .Values.smart.rpcUrl | quote }}
- name: SMART_PROGRAM_ID
value: {{ .Values.smart.programId | quote }}
- name: SMART_TOKEN_MINT
value: {{ .Values.smart.tokenMint | quote }}
- name: SMART_ADMIN_KEYPAIR
value: {{ .Values.smart.adminKeypair | quote }}
- name: SMART_ALLOW_AIRDROP
value: {{ .Values.smart.allowAirdrop | ternary "1" "0" | quote }}
- name: SMART_MIN_AMOUNT
value: {{ .Values.smart.minAmount | quote }}
- name: SMART_MAX_AMOUNT
value: {{ .Values.smart.maxAmount | quote }}
- name: SMART_MULTIPLIER_BPS
value: {{ .Values.smart.multiplierBps | quote }}
- name: SMART_EXPIRY_SECONDS
value: {{ .Values.smart.expirySeconds | quote }}
- name: SMART_START_BALANCE
value: {{ .Values.smart.startBalance | quote }}
- name: GW_PORT
value: {{ .Values.port | quote }}
- name: GW_BETTOR_KEYSTORE
value: {{ .Values.keystorePath | quote }}
- name: NATS_URL
value: {{ .Values.nats.url | quote }}
- name: NATS_PRICE_SUBJECT
value: {{ .Values.nats.priceSubject | quote }}
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 20
periodSeconds: 15
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 20
periodSeconds: 15
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /app/data
- name: admin-keypair
mountPath: /app/secrets
readOnly: true
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "smart-updown-relayer.fullname" . }}-data
{{- else }}
emptyDir: {}
{{- end }}
- name: admin-keypair
secret:
secretName: smart-updown-keypair
items:
- key: admin-keypair.json
path: admin-keypair.json
defaultMode: 0444
- name: bettors-seed
secret:
secretName: testfront-bettors
items:
- key: .gateway-bettors.json
path: .gateway-bettors.json
defaultMode: 0444
+20
View File
@@ -0,0 +1,20 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "smart-updown-relayer.fullname" . }}
labels:
{{- include "smart-updown-relayer.labels" . | nindent 4 }}
spec:
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "smart-updown-relayer.fullname" . }}
port:
number: {{ .Values.port }}
{{- end }}
+17
View File
@@ -0,0 +1,17 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "smart-updown-relayer.fullname" . }}-data
labels:
{{- include "smart-updown-relayer.labels" . | nindent 4 }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- if .Values.persistence.storageClassName }}
storageClassName: {{ .Values.persistence.storageClassName }}
{{- end }}
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "smart-updown-relayer.fullname" . }}
labels:
{{- include "smart-updown-relayer.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- name: http
port: {{ .Values.port }}
targetPort: http
protocol: TCP
selector:
{{- include "smart-updown-relayer.selectorLabels" . | nindent 4 }}
+40
View File
@@ -0,0 +1,40 @@
replicaCount: 1
image:
name: 'images.binom.pw/smart/updown-relayer'
pullPolicy: IfNotPresent
tag: ''
imagePullSecrets:
- name: regcred
port: 8895
ingress:
host: ''
enabled: false
smart:
rpcUrl: 'http://192.168.76.181:8899'
programId: '9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf'
tokenMint: '8dpXNUgWN4m8ohhi6dkVqw4XezbDF7oKDSgnbf2VKjQJ'
adminKeypair: '/app/secrets/admin-keypair.json'
allowAirdrop: true
minAmount: 100000000
maxAmount: 10000000000
multiplierBps: 13000
expirySeconds: 15
startBalance: 10000000000
nats:
url: 'nats://192.168.88.93:4222'
priceSubject: 'market.price.solusdt'
keystorePath: '/app/data/.gateway-bettors.json'
persistence:
enabled: true
size: 1Gi
storageClassName: local-path
resources: {}
+119 -17
View File
@@ -10,7 +10,14 @@
{
"name": "close_bet",
"discriminator": [
2
185,
206,
13,
184,
176,
108,
140,
107
],
"accounts": [
{
@@ -57,15 +64,60 @@
}
},
{
"name": "bettor",
"docs": [
"CHECK: recipient must be the bet's bettor and is validated in the handler."
],
"writable": true
"name": "bettor"
},
{
"name": "clock",
"address": "SysvarC1ock11111111111111111111111111111111"
},
{
"name": "vault_token",
"docs": [
"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."
],
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
},
{
"kind": "const",
"value": [
118,
97,
117,
108,
116
]
}
]
}
},
{
"name": "token_program",
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
}
],
"args": [
@@ -82,7 +134,14 @@
{
"name": "create_bet",
"discriminator": [
1
197,
42,
153,
2,
59,
63,
143,
246
],
"accounts": [
{
@@ -136,6 +195,24 @@
{
"name": "system_program",
"address": "11111111111111111111111111111111"
},
{
"name": "bettor_token",
"docs": [
"Bettor's SPL-token account (our \"fanti\" mint); bet amount is pulled from here."
],
"writable": true
},
{
"name": "vault_token",
"docs": [
"Token vault ATA (owner = PDA([GLOBAL_SEED, VAULT_SEED])); bet amounts are escrowed here."
],
"writable": true
},
{
"name": "token_program",
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
}
],
"args": [
@@ -160,7 +237,14 @@
{
"name": "initialize",
"discriminator": [
0
175,
175,
109,
31,
13,
152,
155,
237
],
"accounts": [
{
@@ -214,7 +298,14 @@
{
"name": "update",
"discriminator": [
3
219,
200,
88,
176,
158,
63,
253,
127
],
"accounts": [
{
@@ -380,10 +471,9 @@
"types": [
{
"name": "Bet",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"docs": [
"Open bet. `amount` is in token base units (decimals = 6), not lamports."
],
"type": {
"kind": "struct",
"fields": [
@@ -488,10 +578,13 @@
},
{
"name": "Global",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"docs": [
"Global game state.",
"",
"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": {
"kind": "struct",
"fields": [
@@ -541,6 +634,15 @@
"name": "GLOBAL_SEED",
"type": "bytes",
"value": "[103, 108, 111, 98, 97, 108]"
},
{
"name": "VAULT_SEED",
"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]"
}
]
}
+122 -21
View File
@@ -1,8 +1,7 @@
/**
* Program IDL in camelCase format in order to be used in JS/TS.
*
* Note that this is only a type helper and is not the actual IDL. The original
* IDL can be found at `target/idl/smart_updown.json`.
* Program IDL in camelCase format (see @anchor-lang/core convertIdlToCamelCase).
* Mirrors relayer/idl/smart_updown.json but with names camelCased, exactly as
* the runtime Program instance exposes them (methods/accounts/types).
*/
export type SmartUpdown = {
"address": "9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf",
@@ -16,7 +15,14 @@ export type SmartUpdown = {
{
"name": "closeBet",
"discriminator": [
2
185,
206,
13,
184,
176,
108,
140,
107
],
"accounts": [
{
@@ -63,15 +69,60 @@ export type SmartUpdown = {
}
},
{
"name": "bettor",
"docs": [
"CHECK: recipient must be the bet's bettor and is validated in the handler."
],
"writable": true
"name": "bettor"
},
{
"name": "clock",
"address": "SysvarC1ock11111111111111111111111111111111"
},
{
"name": "vaultToken",
"docs": [
"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."
],
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
},
{
"kind": "const",
"value": [
118,
97,
117,
108,
116
]
}
]
}
},
{
"name": "tokenProgram",
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
}
],
"args": [
@@ -88,7 +139,14 @@ export type SmartUpdown = {
{
"name": "createBet",
"discriminator": [
1
197,
42,
153,
2,
59,
63,
143,
246
],
"accounts": [
{
@@ -142,6 +200,24 @@ export type SmartUpdown = {
{
"name": "systemProgram",
"address": "11111111111111111111111111111111"
},
{
"name": "bettorToken",
"docs": [
"Bettor's SPL-token account (our \"fanti\" mint); bet amount is pulled from here."
],
"writable": true
},
{
"name": "vaultToken",
"docs": [
"Token vault ATA (owner = PDA([GLOBAL_SEED, VAULT_SEED]); bet amounts are escrowed here."
],
"writable": true
},
{
"name": "tokenProgram",
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
}
],
"args": [
@@ -166,7 +242,14 @@ export type SmartUpdown = {
{
"name": "initialize",
"discriminator": [
0
175,
175,
109,
31,
13,
152,
155,
237
],
"accounts": [
{
@@ -220,7 +303,14 @@ export type SmartUpdown = {
{
"name": "update",
"discriminator": [
3
219,
200,
88,
176,
158,
63,
253,
127
],
"accounts": [
{
@@ -386,10 +476,9 @@ export type SmartUpdown = {
"types": [
{
"name": "bet",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"docs": [
"Open bet. `amount` is in token base units (decimals = 6), not lamports."
],
"type": {
"kind": "struct",
"fields": [
@@ -494,10 +583,13 @@ export type SmartUpdown = {
},
{
"name": "global",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"docs": [
"Global game state.",
"",
"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": {
"kind": "struct",
"fields": [
@@ -547,6 +639,15 @@ export type SmartUpdown = {
"name": "globalSeed",
"type": "bytes",
"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]"
}
]
};
+1258
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -4,19 +4,26 @@
"private": true,
"license": "ISC",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "ts-node src/check.ts",
"init": "ts-node src/index.ts --init --once",
"run": "ts-node src/index.ts --once",
"loop": "ts-node src/index.ts"
"loop": "ts-node src/index.ts",
"gateway": "ts-node src/gateway.ts"
},
"dependencies": {
"@anchor-lang/core": "^1.0.0",
"@solana/spl-token": "^0.4.15",
"@solana/web3.js": "^1.98.0",
"bn.js": "^5.2.0"
"bn.js": "^5.2.0",
"nats": "^2.29.3",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/bn.js": "^5.2.0",
"@types/node": "^25.0.0",
"@types/ws": "^8.5.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Оффлайн-проверка релейера (без сети): компиляция и сигнатуры под новый
* токеновый 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(closeIx, "vault_authority") as any).pda.seeds)),
"vaultPda совпадает с седами vault_authority в close_bet"
);
check(
(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. Сборка инструкций оффлайн и проверка ключей
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 === 9, "close_bet: 9 аккаунтов (IDL-состав)");
if (failed > 0) {
console.error(`\ncheck: ${failed} проверок ПРОВАЛЕНО`);
process.exit(1);
}
console.log("\ncheck: все проверки пройдены — сигнатуры релейера соответствуют новому токеновому IDL.");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+28 -7
View File
@@ -13,15 +13,30 @@ 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;
/**
* 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;
}
@@ -43,16 +58,22 @@ 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),
// Стартовый баланс фантиков, который локальный релейер mintTo'ит беттору
// (аналог SOL-airdrop). По умолчанию равен maxAmount — хватает на максимум ставки.
startBalance: envNum("SMART_START_BALANCE", 10_000_000_000),
};
}
+3 -2
View File
@@ -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 };
}
+675
View File
@@ -0,0 +1,675 @@
/**
* HTTP-шлюз поверх релейера: пульт ставит фантики БЕЗ Phantom.
*
* Шлюз — custodial: keypair'ы бетторов хранит у себя (keystore-файл
* `.gateway-bettors.json` рядом с relayer/, в .gitignore) и подписывает
* create_bet от их имени. Для /close ключи не нужны — подписывает
* relayer (admin). Все сетевые операции — переиспользованные функции
* из ops.ts (createBet / closeBet / readGlobal / fundBettor / ...).
*
* Эндпоинты (CORS: Access-Control-Allow-Origin: *):
* GET /state — Global: counter, min/max, multiplier, expiry, paused, vault
* GET /price — текущая сырая цена SOL из NATS (для отладки); 503 если ещё нет
* POST /bet {address?, side, amountUnits}
* — address: custodial-беттор шлюза; если не задан — шлюз
* генерирует новый и регистрирует в keystore.
* Ответ: {tx, id, bettor, entryPrice}
* POST /close {id} — close_bet по текущей цене (подпись relayer)
* GET /balance/{address} — {sol, solLamports, tokenUnits, tokenHuman}
* GET /faucet/{address} — налив SOL + фантиков на произвольный адрес
* (только localnet, allowAirdrop=1; иначе 403). Ответ:
* {address, sol, solLamports, tokenUnits, tokenHuman}
* GET /ws (WebSocket) — push цены SOL из NATS; на подключение шлёт
* снапшот (если цена уже есть), дальше — каждый тик.
*
* Пути нормализуются: ingress пробрасывает путь как есть, поэтому
* /api/... и /... должны работать одинаково (strip /api).
*
* Запуск: npm run gateway (ts-node src/gateway.ts)
* Env: GW_PORT (default 8895), GW_BETTOR_KEYSTORE, SMART_* (как у релейера),
* NATS_URL (default nats://192.168.88.93:4222),
* NATS_PRICE_SUBJECT (default market.price.solusdt),
* PRICE_WAIT_TIMEOUT_MS (default 15000).
*/
import http from "http";
import * as fs from "fs";
import * as path from "path";
import { Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import { getAssociatedTokenAddress, getMint } from "@solana/spl-token";
import BN from "bn.js";
import type { Program } from "@anchor-lang/core";
import { WebSocketServer, WebSocket } from "ws";
import { loadConfig, loadKeypair, SIDE_DOWN, SIDE_UP } from "./config";
import {
closeBet,
createBet,
ensureBettorTokenAccount,
ensureVaultFunded,
fundBettor,
readGlobal,
} from "./ops";
import { betPda } from "./pda";
import { makeConnection, makeProgram } from "./program";
import { currentPrice, onPrice, connectNats } from "./natsPrice";
import { currentPriceHuman, getPrice } from "./priceSource";
import type { SmartUpdown } from "../idl/smart_updown";
const STATUS_OPEN = 0;
const STATUS_PAYOUT_DONE = 1;
const STATUS_HOUSE_WON = 2;
const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL);
const MAX_BODY_BYTES = 100 * 1024;
const NATS_URL_DEFAULT = "nats://192.168.88.93:4222";
const NATS_PRICE_SUBJECT_DEFAULT = "market.price.solusdt";
const WS_PATH = "/ws";
const WS_HEARTBEAT_MS = 30_000;
const WS_MISSED_PONGS = 2;
/**
* Ingress пробрасывает путь как есть, без stripPrefix. Нормализуем
* `/api/...` к `/...`, чтобы `/api/state` и `/state` работали одинаково.
*/
function normalizePath(p: string): string {
if (p === "/api" || p === "/api/") return "/";
if (p.startsWith("/api/")) return p.slice(4);
return p;
}
/** Токен-юниты -> "N.NNNNNN" по decimals (дефолт 6, как в контракте). */
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")}`;
}
/**
* Custodial keystore бетторов шлюза: адрес -> keypair.
* Файл — JSON { "<pubkey>": [секретные байты...] }, права 0600.
*/
class BettorKeystore {
private pairs = new Map<string, Keypair>();
private readonly file: string;
constructor(file: string) {
this.file = file;
if (fs.existsSync(file)) {
try {
const raw = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, number[]>;
for (const [addr, secret] of Object.entries(raw)) {
this.pairs.set(addr, Keypair.fromSecretKey(Uint8Array.from(secret)));
}
console.log(`gateway: loaded ${this.pairs.size} bettor key(s) from ${file}`);
} catch (e) {
console.error(`gateway: keystore ${file} unreadable, starting empty: ${e}`);
}
}
}
get(address: string): Keypair | undefined {
return this.pairs.get(address);
}
/** Новый custodial-кошелёк: генерирует keypair, регистрирует под СВОИМ pubkey. */
generate(): Keypair {
const kp = Keypair.generate();
this.pairs.set(kp.publicKey.toBase58(), kp);
this.persist();
console.log(`gateway: generated custodial bettor keypair ${kp.publicKey.toBase58()}`);
return kp;
}
private persist(): void {
const data: Record<string, number[]> = {};
for (const [addr, kp] of this.pairs) data[addr] = Array.from(kp.secretKey);
fs.writeFileSync(this.file, JSON.stringify(data, null, 2), { mode: 0o600 });
}
}
interface Ctx {
cfg: ReturnType<typeof loadConfig>;
admin: Keypair;
connection: ReturnType<typeof makeConnection>;
program: Program<SmartUpdown>;
decimals: number;
keystore: BettorKeystore;
}
interface HttpJson {
status: number;
body: Record<string, unknown>;
}
function parsePubkey(value: unknown): PublicKey | null {
if (typeof value !== "string") return null;
try {
return new PublicKey(value.trim());
} catch {
return null;
}
}
function normalizeSide(value: unknown): number | null {
if (value === 0 || value === "0" || value === "UP" || value === "up" || value === "Up") {
return SIDE_UP;
}
if (value === 1 || value === "1" || value === "DOWN" || value === "down" || value === "Down") {
return SIDE_DOWN;
}
return null;
}
function parseAmountUnits(value: unknown): number | null {
const n = typeof value === "string" ? Number(value) : value;
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) return null;
return n;
}
async function handleState(ctx: Ctx): Promise<HttpJson> {
const g = await readGlobal(ctx.program, ctx.program.programId, ctx.cfg.tokenMint);
if (!g) {
return {
status: 409,
body: { error: "Contract not initialized (relayer: npm run init)" },
};
}
return {
status: 200,
body: {
counter: g.counter.toString(),
nextBetId: g.counter.toString(),
minAmount: g.minAmount.toString(),
maxAmount: g.maxAmount.toString(),
minAmountHuman: humanize(g.minAmount.toString(), ctx.decimals),
maxAmountHuman: humanize(g.maxAmount.toString(), ctx.decimals),
multiplierBps: g.multiplierBps.toString(),
expirySeconds: g.expirySeconds.toString(),
paused: g.paused.toNumber(),
admin: g.admin.toBase58(),
relayer: g.relayer.toBase58(),
vaultBalance: g.vaultBalance,
vaultBalanceHuman: humanize(g.vaultBalance, ctx.decimals),
},
};
}
async function handlePrice(): Promise<HttpJson> {
const human = currentPriceHuman();
if (human === null) {
return { status: 503, body: { error: "price not available yet" } };
}
const last = currentPrice();
return {
status: 200,
body: {
symbol: "solusdt",
price: human,
ts: last ? last.ts : Date.now(),
},
};
}
async function handleBet(ctx: Ctx, body: Record<string, unknown>): Promise<HttpJson> {
const side = normalizeSide(body.side);
if (side === null) {
return { status: 400, body: { error: 'side must be 0/"UP" or 1/"DOWN"' } };
}
const amountUnits = parseAmountUnits(body.amountUnits);
if (amountUnits === null) {
return {
status: 400,
body: { error: "amountUnits must be a positive integer (token base units)" },
};
}
const g = await readGlobal(ctx.program, ctx.program.programId, ctx.cfg.tokenMint);
if (!g) {
return {
status: 409,
body: { error: "Contract not initialized (relayer: npm run init)" },
};
}
if (amountUnits < g.minAmount.toNumber() || amountUnits > g.maxAmount.toNumber()) {
return {
status: 400,
body: {
error: `amountUnits ${amountUnits} out of bounds [${g.minAmount.toString()}, ${g.maxAmount.toString()}]`,
},
};
}
// Кому принадлежат фантики: либо зарегистрированный custodial-адрес,
// либо новый кошелёк, который шлюз только что сгенерировал.
let bettor: PublicKey;
let kp: Keypair | undefined;
if (body.address !== undefined && body.address !== null && body.address !== "") {
const pub = parsePubkey(body.address);
if (!pub) {
return { status: 400, body: { error: "address is not a valid Solana pubkey" } };
}
kp = ctx.keystore.get(pub.toBase58());
if (!kp) {
return {
status: 400,
body: {
error: `bettor ${pub.toBase58()} is not registered in the gateway keystore ` +
"(send a bet without address to let the gateway create the wallet)",
},
};
}
bettor = pub;
} else {
kp = ctx.keystore.generate();
bettor = kp.publicKey;
}
// Фандинг как в релейере: SOL под ренту/комиссию + фантики в ATA.
if (ctx.cfg.allowAirdrop) {
await fundBettor(ctx.connection, bettor);
await ensureBettorTokenAccount(
ctx.connection,
ctx.admin,
ctx.cfg.tokenMint,
bettor,
BigInt(ctx.cfg.startBalance)
);
}
const entryPrice = await getPrice();
const { id, tx } = await createBet(ctx.program, ctx.cfg.tokenMint, kp, {
side,
amount: amountUnits,
entryPrice,
});
return {
status: 200,
body: {
tx,
id: id.toString(),
bettor: bettor.toBase58(),
entryPrice,
},
};
}
// Глобальный mutex: две параллельные ставки на один counter — BetIdMismatch.
let betChain: Promise<unknown> = Promise.resolve();
function withBetLock<T>(fn: () => Promise<T>): Promise<T> {
const run = betChain.then(fn);
betChain = run.then(
() => undefined,
() => undefined
);
return run;
}
async function handleClose(ctx: Ctx, body: Record<string, unknown>): Promise<HttpJson> {
let id: BN;
try {
id = new BN(String(body.id));
} catch {
return { status: 400, body: { error: "id must be an integer" } };
}
const programId = ctx.program.programId;
const bet = await ctx.program.account.bet.fetchNullable(betPda(programId, id));
if (!bet) {
return { status: 404, body: { error: `bet #${id.toString()} not found` } };
}
const exitPrice = await getPrice();
const tx = await closeBet(ctx.program, id, exitPrice, ctx.admin, bet.bettor, ctx.cfg.tokenMint);
const after = await ctx.program.account.bet.fetchNullable(betPda(programId, id));
const status = after ? after.status.toNumber() : STATUS_OPEN;
const bodyOut: Record<string, unknown> = {
tx,
id: id.toString(),
exitPrice,
status,
statusName:
status === STATUS_PAYOUT_DONE
? "payout_done"
: status === STATUS_HOUSE_WON
? "house_won"
: "open",
bettor: bet.bettor.toBase58(),
};
if (status === STATUS_OPEN) {
bodyOut.note =
"exit==entry: ставка осталась open (контракт не финализирует при равной цене) — повторите /close позже";
}
return { status: 200, body: bodyOut };
}
/** Балансы адреса (SOL + фантики в ATA по нашему mint) — переиспользуется
* и /balance, и /faucet (реальные значения после налива). */
async function readBalances(
ctx: Ctx,
pub: PublicKey
): Promise<{
sol: number;
solLamports: number;
tokenUnits: string;
tokenHuman: string;
}> {
const [lamports, tokenUnits] = await Promise.all([
ctx.connection.getBalance(pub),
(async (): Promise<string> => {
try {
const ata = await getAssociatedTokenAddress(ctx.cfg.tokenMint, pub);
const bal = await ctx.connection.getTokenAccountBalance(ata);
return bal.value.amount || "0";
} catch {
return "0";
}
})(),
]);
return {
sol: lamports / LAMPORTS_PER_SOL_NUM,
solLamports: lamports,
tokenUnits,
tokenHuman: humanize(tokenUnits, ctx.decimals),
};
}
async function handleBalance(ctx: Ctx, address: string): Promise<HttpJson> {
const pub = parsePubkey(address);
if (!pub) {
return { status: 400, body: { error: "address is not a valid Solana pubkey" } };
}
return { status: 200, body: await readBalances(ctx, pub) };
}
/**
* Налив фантиков/SOL на ПРОИЗВОЛЬНЫЙ адрес (Phantom и т.п.) — только localnet.
* Переиспользует готовые функции ops.ts без дублирования логики:
* - SOL (рента/комиссия) — тот же airdrop, что fundBettor;
* - фантики — mintTo в ATA адреса (до startBalance, дефолт = maxAmount),
* та же функция, которой /bet фандит ATA (ensureBettorTokenAccount).
* Ответ — адрес + реальные балансы после налива (readBalances, как в /balance).
*/
async function handleFaucet(ctx: Ctx, address: string): Promise<HttpJson> {
if (!ctx.cfg.allowAirdrop) {
return { status: 403, body: { error: "faucet only on localnet" } };
}
const pub = parsePubkey(address);
if (!pub) {
return { status: 400, body: { error: "address is not a valid Solana pubkey" } };
}
// SOL под ренту/комиссию — тот же airdrop, что fundBettor.
await fundBettor(ctx.connection, pub);
// Фантики — mintTo в ATA адреса до startBalance (дефолт = maxAmount из Global).
await ensureBettorTokenAccount(
ctx.connection,
ctx.admin,
ctx.cfg.tokenMint,
pub,
BigInt(ctx.cfg.startBalance)
);
const balances = await readBalances(ctx, pub);
return {
status: 200,
body: { address: pub.toBase58(), ...balances },
};
}
function readJsonBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => {
size += c.length;
if (size > MAX_BODY_BYTES) {
reject(new Error("body too large"));
req.destroy();
return;
}
chunks.push(c);
});
req.on("end", () => {
if (chunks.length === 0) {
resolve({});
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>);
} catch {
const err = new Error("invalid JSON body") as Error & { clientError?: boolean };
err.clientError = true;
reject(err);
}
});
req.on("error", reject);
});
}
/** WS-клиенты с флагом живости (2 цикла пропущенных pong -> terminate). */
interface WsClient {
ws: WebSocket;
alive: boolean;
}
function broadcastPrice(wsClients: Set<WsClient>): (p: { price: string; ts: number }) => void {
return (p) => {
const msg = JSON.stringify({
type: "price",
symbol: "solusdt",
price: p.price,
ts: p.ts,
});
for (const c of wsClients) {
if (c.ws.readyState === WebSocket.OPEN) {
c.ws.send(msg);
}
}
};
}
async function main(): Promise<void> {
const cfg = loadConfig();
const admin = loadKeypair(cfg.adminKeypairPath);
const connection = makeConnection(cfg.rpcUrl);
const program = makeProgram(connection, admin);
let decimals = 6;
try {
decimals = (await getMint(connection, cfg.tokenMint)).decimals;
} catch {
// mint ещё не создан — дефолт из ТЗ контракта
}
const keystore = new BettorKeystore(
process.env.GW_BETTOR_KEYSTORE ?? path.join(__dirname, "..", ".gateway-bettors.json")
);
const ctx: Ctx = { cfg, admin, connection, program, decimals, keystore };
// Прелоад как в index.ts: ваулт-фантики на localnet (если включён airdrop).
// Не блокируем старт сервера: подтверждение tx может вешаться на 30с,
// а /bet сам добудет, чего не хватает.
if (cfg.allowAirdrop) {
ensureVaultFunded(connection, program, admin, cfg)
.then(() => console.log("gateway: vault pre-funded at startup"))
.catch((e) => console.error(`gateway: vault pre-funding skipped: ${e}`));
}
const g0 = await readGlobal(program, program.programId, cfg.tokenMint).catch(() => null);
if (!g0) {
console.log("gateway: contract NOT initialized — /state будет 409 до `npm run init`");
}
// NATS-подписка на цену: НЕ блокируем старт сервера — если NATS недоступен,
// шлюз всё равно поднимается, /bet будет ждать цену до таймаута.
const natsUrl = process.env.NATS_URL ?? NATS_URL_DEFAULT;
const natsSubject = process.env.NATS_PRICE_SUBJECT ?? NATS_PRICE_SUBJECT_DEFAULT;
connectNats(natsUrl, natsSubject).catch((e) => {
console.error(`gateway: NATS connect failed (${natsUrl}): ${e instanceof Error ? e.message : String(e)}`);
});
const port = Number(process.env.GW_PORT ?? "8895");
const server = http.createServer((req, res) => {
const started = Date.now();
const cors = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
for (const [k, v] of Object.entries(cors)) res.setHeader(k, v);
const finish = (status: number, body?: unknown) => {
if (body !== undefined) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
} else {
res.writeHead(status);
res.end();
}
// Логируем ИСХОДНЫЙ путь, как требует ТЗ.
const url = req.url ?? "/";
console.log(`gateway: ${req.method} ${url} -> ${status} (${Date.now() - started}ms)`);
};
(async () => {
const reqUrl = new URL(req.url ?? "/", "http://gateway.local");
const p = normalizePath(reqUrl.pathname);
const m = req.method ?? "GET";
if (m === "OPTIONS") {
finish(204);
return;
}
let result: HttpJson;
try {
if (m === "GET" && p === "/state") {
result = await handleState(ctx);
} else if (m === "GET" && p === "/price") {
result = await handlePrice();
} else if (m === "POST" && p === "/bet") {
const body = await readJsonBody(req);
result = await withBetLock(() => handleBet(ctx, body));
} else if (m === "POST" && p === "/close") {
const body = await readJsonBody(req);
result = await handleClose(ctx, body);
} else if (m === "GET" && p.startsWith("/balance/")) {
const address = decodeURIComponent(p.slice("/balance/".length));
result = await handleBalance(ctx, address);
} else if (m === "GET" && p.startsWith("/faucet/")) {
const address = decodeURIComponent(p.slice("/faucet/".length));
result = await handleFaucet(ctx, address);
} else {
result = { status: 404, body: { error: "not found" } };
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const isClient = e instanceof Error && !!(e as unknown as { clientError?: boolean }).clientError;
result = { status: isClient ? 400 : 500, body: { error: msg } };
}
if (result.status === 204) {
finish(204);
return;
}
finish(result.status, result.body);
})().catch((e) => {
console.error("gateway: request failed:", e);
try {
finish(500, { error: "internal error" });
} catch {
// соединение уже разорвано
}
});
});
// WS-сервер на том же порту (noServer=true), обновляем апгрейды вручную.
const wss = new WebSocketServer({ noServer: true });
const wsClients = new Set<WsClient>();
wss.on("connection", (ws: WebSocket) => {
const client: WsClient = { ws, alive: true };
wsClients.add(client);
ws.on("pong", () => {
client.alive = true;
});
ws.on("close", () => {
wsClients.delete(client);
});
ws.on("error", () => {
wsClients.delete(client);
});
// Снапшот сразу, если цена уже пришла из NATS.
const snap = currentPrice();
if (snap !== null) {
const msg = JSON.stringify({
type: "price",
symbol: "solusdt",
price: snap.price,
ts: snap.ts,
});
ws.send(msg);
}
});
// Рассылка каждого нового NATS-тика по всем подключённым WS.
onPrice(broadcastPrice(wsClients));
// Heartbeat: пинг раз в 30с, нет pong 2 цикла — terminate.
const heartbeat = setInterval(() => {
for (const c of wsClients) {
if (!c.alive) {
try {
c.ws.terminate();
} catch {
// ignore
}
wsClients.delete(c);
continue;
}
c.alive = false;
try {
c.ws.ping();
} catch {
wsClients.delete(c);
}
}
}, WS_HEARTBEAT_MS);
server.on("upgrade", (req, socket, head) => {
const rawUrl = req.url ?? "/";
const reqUrl = new URL(rawUrl, "http://gateway.local");
const p = normalizePath(reqUrl.pathname);
if (p !== WS_PATH) {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
server.listen(port, "0.0.0.0", () => {
console.log(`gateway: listening on 0.0.0.0:${port} (RPC ${cfg.rpcUrl}, mint ${cfg.tokenMint.toBase58()})`);
console.log(
`gateway: endpoints: GET /state, GET /price, POST /bet, POST /close, GET /balance/{address}, GET /faucet/{address}, WS /ws`
);
});
// Корректная остановка heartbeat при завершении.
const shutdown = () => {
clearInterval(heartbeat);
wss.close();
server.close();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+44 -23
View File
@@ -1,4 +1,5 @@
import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import { 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,24 +10,25 @@ import {
closeBet,
readGlobal,
ensureVaultFunded,
ensureBettorTokenAccount,
fundBettor,
} from "./ops";
import { betPda } from "./pda";
import { makeConnection, makeProgram } from "./program";
import type { SmartUpdown } from "../idl/smart_updown";
const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const STATUS_OPEN = 0;
async function fundBettor(connection: any, bettorPubkey: PublicKey): Promise<void> {
const bal = await connection.getBalance(bettorPubkey);
if (bal < 2 * LAMPORTS_PER_SOL_NUM) {
await connection.requestAirdrop(bettorPubkey, 5 * LAMPORTS_PER_SOL_NUM);
await sleep(800);
console.log(`bettor funded ${bettorPubkey.toBase58()} (+5 SOL)`);
}
/** Человекочитаемо-формат: токен-юниты -> "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) пока не зафиналится. */
@@ -35,7 +37,8 @@ async function settleBet(
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 +47,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,8 +73,17 @@ async function main(): Promise<void> {
console.log(`RPC: ${cfg.rpcUrl}`);
console.log(`Admin/relayer: ${admin.publicKey.toBase58()}`);
console.log(`Token mint: ${cfg.tokenMint.toBase58()}`);
const global = await readGlobal(program, program.programId);
// Decimals фантиков (см. state.rs = 6). Только для человекочитаемых логов.
let decimals = 6;
try {
decimals = (await getMint(connection, cfg.tokenMint)).decimals;
} catch {
// mint ещё не создан (плейсхолдер) — дефолт из ТЗ контракта
}
const global = await readGlobal(program, program.programId, cfg.tokenMint);
if (!global) {
if (!process.argv.includes("--init")) {
throw new Error(
@@ -79,21 +91,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 +113,33 @@ 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 не пройдёт.
// На 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, 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 g0 = await readGlobal(program, program.programId, cfg.tokenMint);
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(
+166
View File
@@ -0,0 +1,166 @@
/**
* NATS-источник цены SOL.
*
* Подписывается на subject вида `market.price.solusdt` (payload — JSON
* `{"symbol":"solusdt","type":"price","price":"101.89000000",...}`),
* хранит последнее валидное значение и рассылает его всем подписчикам.
*
* Reconnect/autoreconnect включены по умолчанию (пакет `nats`); в Core NATS
* история не сохраняется — новые значения приходят только живой подписке.
*
* Публикаций наружу нет — только чтение.
*/
import { connect, StringCodec, NatsConnection } from "nats";
export interface PriceTick {
/** Сырая строка цены из payload (например, "101.89000000"). */
price: string;
/** Unix ms: берётся из поля `ts` payload'а, иначе Date.now(). */
ts: number;
}
let nc: NatsConnection | null = null;
let last: PriceTick | null = null;
const listeners: Array<(p: PriceTick) => void> = [];
let firstLogged = false;
function notify(p: PriceTick): void {
last = p;
for (const cb of listeners) {
try {
cb(p);
} catch (e) {
console.error(`natsPrice: listener threw: ${e instanceof Error ? e.message : String(e)}`);
}
}
}
function isValidPriceString(s: unknown): s is string {
if (typeof s !== "string") return false;
if (s.length === 0) return false;
// Только цифры и одна точка. Иначе — мусор.
if (!/^[0-9]+(?:\.[0-9]+)?$/.test(s)) return false;
return true;
}
/**
* Подключиться к NATS и подписаться на subject. Возвращает управление
* сразу после установки подписки, не дожидаясь первой цены. Сообщения с
* невалидным JSON / невалидным `price` молча пропускаются (с логом).
*/
export async function connectNats(url: string, subject: string): Promise<void> {
if (nc) {
console.log(`natsPrice: already connected, ignoring reconnect request to ${url}`);
return;
}
const conn = await connect({ servers: [url] });
nc = conn;
console.log(`nats: connected ${url}`);
const sc = StringCodec();
const sub = conn.subscribe(subject, {
callback: (err, msg) => {
if (err) {
console.error(`natsPrice: subscription error: ${err.message}`);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(sc.decode(msg.data));
} catch (e) {
console.log(
`natsPrice: malformed JSON on ${subject}: ${e instanceof Error ? e.message : String(e)}`
);
return;
}
if (!parsed || typeof parsed !== "object") {
console.log(`natsPrice: non-object payload on ${subject}`);
return;
}
const obj = parsed as Record<string, unknown>;
const priceRaw = obj.price;
if (!isValidPriceString(priceRaw)) {
console.log(`natsPrice: invalid price on ${subject}: ${String(priceRaw)}`);
return;
}
const tsNum =
typeof obj.ts === "number" && Number.isFinite(obj.ts)
? (obj.ts as number)
: Date.now();
const tick: PriceTick = { price: priceRaw, ts: tsNum };
if (!firstLogged) {
console.log(`natsPrice: first price ${priceRaw} (ts=${tsNum})`);
firstLogged = true;
}
notify(tick);
},
});
// keep reference so it isn't GC'd
void sub;
}
/** Текущая последняя цена (или null, если ещё не приходила). */
export function currentPrice(): PriceTick | null {
return last;
}
/** Регистрирует слушателя на КАЖДОЕ новое значение. */
export function onPrice(cb: (p: PriceTick) => void): void {
listeners.push(cb);
}
/**
* Ждёт первую цену из NATS и возвращает её в ЦЕЛЫХ ЕДИНИЦАХ
* (price × 1e8, через строковое преобразование — без потери точности).
* При таймауте — throw.
*/
export async function waitForPrice(timeoutMs: number): Promise<number> {
if (last !== null) {
return priceStringToInt(last.price);
}
return new Promise<number>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`price timeout: no data from NATS after ${timeoutMs}ms`));
}, timeoutMs);
onPrice((p) => {
clearTimeout(timer);
resolve(priceStringToInt(p.price));
});
});
}
/**
* Чистая конверсия: сырая строка из NATS → целое (price × 1e8).
* "101.89000000" → 10189000000
* "0.00000001" → 1
* "1234567.12345678" → 123456712345678
* "100" → 10000000000
* Без float — только string/BigInt.
*/
export function priceStringToInt(raw: string): number {
if (typeof raw !== "string" || raw.length === 0) {
throw new Error(`priceStringToInt: empty/invalid input`);
}
if (!/^[0-9]+(?:\.[0-9]+)?$/.test(raw)) {
throw new Error(`priceStringToInt: malformed price string: ${raw}`);
}
const dot = raw.indexOf(".");
let intPart: string;
let fracPart: string;
if (dot === -1) {
intPart = raw;
fracPart = "";
} else {
intPart = raw.slice(0, dot);
fracPart = raw.slice(dot + 1);
}
if (fracPart.length > 8) {
fracPart = fracPart.slice(0, 8);
} else if (fracPart.length < 8) {
fracPart = fracPart.padEnd(8, "0");
}
const combined = `${intPart}${fracPart}`;
// strip leading zeros
const stripped = combined.replace(/^0+(?=\d)/, "");
return Number(stripped);
}
+139 -30
View File
@@ -1,17 +1,35 @@
import { Connection, Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import {
Connection,
Keypair,
LAMPORTS_PER_SOL,
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
programId: PublicKey,
mint: PublicKey
): Promise<{
counter: BN;
minAmount: BN;
@@ -21,12 +39,20 @@ export async function readGlobal(
paused: BN;
admin: PublicKey;
relayer: PublicKey;
lamports: number;
/** Баланс vault ATA (owner = 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 vaultAta = await getAssociatedTokenAddress(mint, vaultPda(programId), true);
const bal = await program.provider.connection.getTokenAccountBalance(vaultAta);
vaultBalance = bal.value.amount || "0";
} catch {
vaultBalance = "0";
}
return {
counter: raw.counter,
minAmount: raw.minAmount,
@@ -36,7 +62,7 @@ export async function readGlobal(
paused: raw.paused,
admin: raw.admin,
relayer: raw.relayer,
lamports,
vaultBalance,
};
}
@@ -58,7 +84,7 @@ export async function initialize(
new BN(args.multiplierBps),
new BN(args.expirySeconds)
)
.accounts({
.accountsPartial({
admin: admin.publicKey,
})
.signers([admin])
@@ -66,27 +92,40 @@ export async function initialize(
return sig;
}
/** Создаёт ставку от имени пользователя. Возвращает фактический id (== counter). */
/**
* Создаёт ставку от имени пользователя. Возвращает {id (== counter), tx}.
*/
export async function createBet(
program: Program<SmartUpdown>,
mint: PublicKey,
bettor: Keypair,
args: { side: number; amount: number; entryPrice: number }
): Promise<BN> {
const global = await readGlobal(program, program.programId);
): Promise<{ id: BN; tx: string }> {
const global = await readGlobal(program, program.programId, mint);
if (!global) throw new Error("Contract not initialized");
const id = global.counter;
const sig = await program.methods
.createBet(new BN(id), new BN(args.side), new BN(args.amount), new BN(args.entryPrice))
.accounts({
.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: await getAssociatedTokenAddress(mint, vaultPda(program.programId), true),
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}`);
return id;
console.log(
`createBet #${id} side=${args.side === SIDE_UP ? "UP" : "DOWN"} ` +
`amount=${args.amount} (token base units) entry=${args.entryPrice} tx=${sig}`
);
return { id, tx: sig };
}
/**
@@ -99,33 +138,103 @@ 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: await getAssociatedTokenAddress(mint, vaultPda(program.programId), true),
tokenProgram: TOKEN_PROGRAM_ID,
})
.signers([relayer])
.rpc();
}
/**
* Создаёт 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,
startBalance?: bigint
): 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()}`);
}
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})`
);
}
}
}
/**
* SOL всё ещё нужен беттору: рент PDA-аккаунта ставки + комиссия за tx.
* Саму ставку (фантики) он закрывает из своего токен-аккаунта (ATA).
* Догружает до 5 SOL airdrop'ом, если лампортов меньше 2 SOL.
*/
export async function fundBettor(
connection: Connection,
bettorPubkey: PublicKey
): Promise<void> {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const bal = await connection.getBalance(bettorPubkey);
if (bal < 2 * LAMPORTS_PER_SOL) {
await connection.requestAirdrop(bettorPubkey, 5 * LAMPORTS_PER_SOL);
await sleep(800);
console.log(`bettor funded ${bettorPubkey.toBase58()} (+5 SOL)`);
}
}
/**
* Догружает ваулт (house bankroll), чтобы хватило на выплаты 1.3x.
* На localnet используем airDrop на PDA ваулта; на mainnet воздух не накапает —
* там нужно переводить SOL с админа (off-chain, вне этого CLI).
* Теперь это ТОКЕН, не SOL: на localnet mint-авторитетом выступает admin
* (см. state.rs: decimals = 6), так что наливается через mintTo в ATA-ваулт
* (токен-счёт с owner = PDA([global, vault])). На 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 vault = vaultPda(program.programId);
const vaultAta = await getAssociatedTokenAddress(mint, vault, true);
// ATA-ваулт (owner = PDA([global, vault])) может не существовать —
// контракт его не создаёт. ATA не требует подписи owner (allowOwnerOffCurve),
// поэтому создаём её от имени admin.
await getOrCreateAssociatedTokenAccount(connection, admin, mint, vault, true);
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, vaultAta, 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). */
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];
}
+34 -13
View File
@@ -1,22 +1,43 @@
/**
* STUB — SOL price source.
*
* TODO(пользователь): замени `getPrice` на реальный запрос к своей БД.
* Договоримся, что функция возвращает цену SOL как целое число в «центах»
* (то же юнит-пространство, что у веса entry/exit на контракте). Контракт
* сравнивает тол ько «больше/меньше/равно», поэтому само юнит-пространство
* не критично — главное, чтобы оно было согласовано между entry и exit.
* Цена SOL. Источник — NATS (`relayer/src/natsPrice.ts`). `getPrice()`
* возвращает цену как ЦЕЛОЕ (price × 1e8, без float — точность нужна для
* сравнения на контракте). Override из старой заглушки сохранён для
* тестов «equal price» / offline-сценариев.
*/
let simulated = 100_000_000;
import { currentPrice, priceStringToInt, waitForPrice } from "./natsPrice";
/** Цена SOL сейчас (заглушка — детерминированно «гуляет»). */
const PRICE_WAIT_TIMEOUT_MS = Number(process.env.PRICE_WAIT_TIMEOUT_MS ?? "15000");
let override: number | undefined = undefined;
/**
* Вернуть целое число = price × 1e8. Конверсия — через строковый парсер
* (`priceStringToInt`), БЕЗ `Number(price) * 1e8` (float теряет точность
* на 8 знаках).
*
* Override выставлен → возвращаем его и НЕ ходим в NATS.
* NATS ещё не прислал цену → ждём первую до PRICE_WAIT_TIMEOUT_MS.
*/
export async function getPrice(): Promise<number> {
const drift = Math.round(Math.random() * 200_000); // ±0.2% движка
simulated += Math.random() < 0.5 ? drift : -drift;
return simulated;
if (override !== undefined) return override;
// waitForPrice сам разруливает "уже есть" vs "ещё ждём".
return waitForPrice(PRICE_WAIT_TIMEOUT_MS);
}
/** Управляемая заглушка: вернуть ровно `value` (полезна для теста «equal price»). */
export function setPriceOverride(value: number | undefined): void {
simulated = value ?? 100_000_000;
override = value;
}
/**
* Последняя СЫРАЯ строка цены из NATS (например, `"101.89000000"`) —
* для UI. `null`, если цены ещё не было. При выставленном override
* возвращает его строковую форму с 8 знаками (UI-привычно).
*/
export function currentPriceHuman(): string | null {
if (override !== undefined) {
return override.toString().padStart(9, "0"); // минимум 1 знак слева
}
const p = currentPrice();
return p ? p.price : null;
}