Compare commits
22 Commits
015df5949d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dd4f16a930 | |||
| 42dfa58582 | |||
| a73e44de9a | |||
| 0b136c6502 | |||
| 5ab6c9def5 | |||
| a3178cfa6e | |||
| 92cf2f61b4 | |||
| bf9d05f2f2 | |||
| f426f58778 | |||
| f937c8993d | |||
| fb9a7f4e8b | |||
| 45d64d45a7 | |||
| ff2289e68d | |||
| a51fb3ada2 | |||
| 4cf583f497 | |||
| 32faec7515 | |||
| 515adb25c9 | |||
| 9f100ee122 | |||
| 2133f65e21 | |||
| c8a95b1f0e | |||
| 2081c6cb0b | |||
| 5fdd6902bd |
@@ -0,0 +1,3 @@
|
|||||||
|
# >>> cortexkit:magic-context
|
||||||
|
magic-context/
|
||||||
|
# <<< cortexkit:magic-context
|
||||||
@@ -43,13 +43,13 @@ jobs:
|
|||||||
/tmp/rustup-init.sh -y --profile minimal --default-toolchain 1.91.0
|
/tmp/rustup-init.sh -y --profile minimal --default-toolchain 1.91.0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Setup Solana CLI (GitHub, минуя release.solana.com)
|
- name: Setup Solana CLI (Agave 4.2.2 — builder умеет SBPFv3, как прод mainnet)
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
SOL_VERSION=v1.18.26
|
SOL_VERSION=v4.2.2
|
||||||
cd /tmp
|
cd /tmp
|
||||||
curl -sSfL \
|
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
|
-o solana-release.tar.bz2
|
||||||
tar xjf solana-release.tar.bz2
|
tar xjf solana-release.tar.bz2
|
||||||
mkdir -p "$HOME/solana-local"
|
mkdir -p "$HOME/solana-local"
|
||||||
@@ -72,8 +72,10 @@ jobs:
|
|||||||
if [ -d "$HOME/solana-local/sdk/sbf/dependencies/platform-tools/rust" ]; then
|
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
|
rustup toolchain link solana "$HOME/solana-local/sdk/sbf/dependencies/platform-tools/rust" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
echo "### sbf-sdk на месте:"
|
echo "### доустановка sbpfv3 target (для --arch v3):"
|
||||||
ls "$HOME/solana-local/sdk/sbf/" 2>&1 | head -5
|
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/solana-local" >> "$GITHUB_PATH"
|
||||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
export PATH="$HOME/solana-local:$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/smart_updown-keypair.json 2>/dev/null || true
|
||||||
cp ../deploy/dev_smart_updown-keypair.json .anchor/9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf-keypair.json 2>/dev/null || true
|
cp ../deploy/dev_smart_updown-keypair.json .anchor/9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf-keypair.json 2>/dev/null || true
|
||||||
ls -la .anchor/
|
ls -la .anchor/
|
||||||
echo "### построение:"
|
echo "### построение (явно --arch v3, как прод mainnet):"
|
||||||
anchor build --ignore-keys
|
anchor build --ignore-keys -- --arch v3 2>&1 | tail -8
|
||||||
|
|
||||||
- name: Test program
|
- name: Test program
|
||||||
working-directory: program
|
working-directory: program
|
||||||
@@ -128,14 +130,8 @@ jobs:
|
|||||||
export PATH="$HOME/solana-local:$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/solana-local:$HOME/.cargo/bin:$PATH"
|
||||||
RPC=http://192.168.76.181:8899
|
RPC=http://192.168.76.181:8899
|
||||||
echo ">>> Деплой в НАШУ сеть solana-net (RPC $RPC)"
|
echo ">>> Деплой в НАШУ сеть solana-net (RPC $RPC)"
|
||||||
echo "### sbpf-версия артефакта:"
|
echo "### собранный артефакт:"
|
||||||
SO=target/deploy/smart_updown.so
|
ls -la 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)"
|
|
||||||
# проверка, что наша сеть жива
|
# проверка, что наша сеть жива
|
||||||
for i in $(seq 1 20); do
|
for i in $(seq 1 20); do
|
||||||
curl -s $RPC -H 'Content-Type: application/json' \
|
curl -s $RPC -H 'Content-Type: application/json' \
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.cortexkit/
|
||||||
|
|
||||||
|
# ключи игроков/секрет — НЕ коммитить (имеют SOL-баланс)
|
||||||
|
deploy/player.json
|
||||||
|
deploy/*player*.json
|
||||||
|
test-ledger/
|
||||||
|
*.idl_build_err.log
|
||||||
@@ -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 без причины).
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
на 15 секунд; при выигрыше получает ставку + 30% (1.3x). Дом (контракт) — оппонент.
|
на 15 секунд; при выигрыше получает ставку + 30% (1.3x). Дом (контракт) — оппонент.
|
||||||
Цена берётся off-chain релеером (он же админ) с вашей БД; SOL хранится в ваулте (PDA `global`).
|
Цена берётся 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**
|
> **https://git.binom.pw/subochev/fun-game-front2**
|
||||||
@@ -51,11 +52,13 @@ smart-updown/
|
|||||||
`cargo build-sbf`, `solana-keygen`. Установка:
|
`cargo build-sbf`, `solana-keygen`. Установка:
|
||||||
`sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"`
|
`sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"`
|
||||||
- **Node.js 18+ / npm** — для relayer.
|
- **Node.js 18+ / npm** — для relayer.
|
||||||
- (опционально) **Rust 1.89.0** через rustup — только если пересобираете программу.
|
- (опционально) **Rust 1.85+** через rustup — только если пересобираете программу.
|
||||||
|
|
||||||
> **Важно про `.so`**: это SBF-байткод и он не зависит от архитектуры хоста,
|
> **Важно про `.so` и SBPF-версию**: контракт собирается под конкретный SBPF-архитектуру
|
||||||
> поэтому `deploy/smart_updown.so`, собранный на macOS, **работает на Linux-валидаторе**
|
> (здесь — **SBPFv3**, под агave 4.2.x). Собранный `.so` кроссплатформенный
|
||||||
> как есть — пересобирать не нужно.
|
> по CPU хоста (работает и с macOS-сборкой), но **должен совпадать по SBPF-версии
|
||||||
|
> с валидатором**, иначе деплой падает (`Detected sbpf_version ... not enabled`).
|
||||||
|
> При сборке через `cargo build-sbf --tools-version v1.57` — артефакт SBPFv3.
|
||||||
|
|
||||||
## 2. Развёртывание (localnet)
|
## 2. Развёртывание (localnet)
|
||||||
|
|
||||||
@@ -110,22 +113,26 @@ solana program deploy --program-id deploy/smart_updown-keypair.json deploy/smart
|
|||||||
|
|
||||||
## 6. (Опционально) Пересборка программы из исходников + тесты
|
## 6. (Опционально) Пересборка программы из исходников + тесты
|
||||||
|
|
||||||
Та же команда, что использовалась для сборки:
|
Сборка контракта под **SBPFv3** (для агave 4.2.x):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd program
|
cd program
|
||||||
rustup toolchain install 1.89.0 # если нет
|
# собрать .so (SBPFv3, с entrypoint):
|
||||||
# собрать .so:
|
cargo build-sbf --tools-version v1.57 --manifest-path programs/smart-updown/Cargo.toml
|
||||||
cargo build-sbf --manifest-path programs/smart-updown/Cargo.toml
|
|
||||||
# артефакт появится в program/target/deploy/smart_updown.so
|
# артефакт появится в program/target/deploy/smart_updown.so
|
||||||
# интеграционные тесты (LiteSVM), без валидатора:
|
# интеграционные тесты (solana-program-test 2.x, 10 кейсов):
|
||||||
cargo test -p smart-updown
|
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 аккаунтов/инструкций.
|
Если пересобираете, IDL не меняется, пока не меняете layout аккаунтов/инструкций.
|
||||||
После изменения контракта: пересоберите `.so`, перегенерируйте IDL
|
При смене дискриминаторов (напр. уход от `#[discrim]` при миграции на anchor 0.31.1)
|
||||||
(`anchor build`, при наличии anchor-cli 2.0.0-rc.1) и обновите `relayer/idl/`
|
обязательно перегенерируйте IDL и обновите `relayer/idl/` и `idl/`.
|
||||||
и `idl/`.
|
|
||||||
|
|
||||||
## 7. Переменные окружения relayer (см. .env.example)
|
## 7. Переменные окружения relayer (см. .env.example)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# TASK — Выдавать фантики бетторам на localnet (как SOL airdrop)
|
||||||
|
|
||||||
|
Репозиторий: `smart-updown-token`, файл `relayer/src/index.ts` (+ возможно `ops.ts`).
|
||||||
|
|
||||||
|
## Проблема (обнаружено на localnet E2E)
|
||||||
|
`createBet` падает: `Tokenkeg... Transfer: insufficient funds`.
|
||||||
|
Причина: беттор имеет SOL (ренту/комиссии, `fundBettor` airdrop'ит SOL), но НЕ имеет
|
||||||
|
наших фантиков в своём ATA. Контракт верно требует средства игрока. Для localnet
|
||||||
|
нужно наливать игроку фантики (по аналогии с SOL-airdrop), потому что swap ещё не подключён.
|
||||||
|
|
||||||
|
## Что исправить (релейер)
|
||||||
|
1. В `relayer/src/ops.ts`: в `ensureBettorTokenAccount` (или рядом) при
|
||||||
|
`cfg.allowAirdrop === true` — после создания ATA наливать токен:
|
||||||
|
`mintTo(connection, admin, mint, ata, admin, startBalance)`.
|
||||||
|
- `startBalance` — например, `10_000_000_000` (10 тыс. фантиков, по модулю
|
||||||
|
`minAmount`..`maxAmount` достаточно). Логировать.
|
||||||
|
- Учесть, что `admin` — mint authority (на localnet таковым является админская keypair).
|
||||||
|
2. `relayer/src/index.ts` фандинг беттора: вызывать новую логику наливания токенов
|
||||||
|
(удобно чтобы работало через `ensureBettorTokenAccount`).
|
||||||
|
|
||||||
|
## Гейт
|
||||||
|
- `npm run typecheck` — 0 ошибок.
|
||||||
|
- `npm test` — зелёное.
|
||||||
|
- На localnet: createBet проходит (беттор имеет фантики), closeBet выплачивает.
|
||||||
|
|
||||||
|
## НЕ делать
|
||||||
|
- Не трогать контракт (`program/`).
|
||||||
|
- Не менять логику цен/ставок.
|
||||||
|
- Не коммитить.
|
||||||
@@ -0,0 +1,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.
|
||||||
|
- Логи/цикл работают с токен-единицами.
|
||||||
@@ -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, ваулт).
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# TASK — Багфикс: vault_token в релейере должен быть ATA, а не PDA
|
||||||
|
|
||||||
|
Репозиторий: `smart-updown-token`, файл `relayer/src/ops.ts`.
|
||||||
|
|
||||||
|
## Проблема (обнаружена на localnet E2E)
|
||||||
|
Контракт переведён на SPL-токен (Фаза 1). В инструкциях `create_bet`/`close_bet`
|
||||||
|
аккаунт `vault_token` — это **ATA-аккаунт токена, который принадлежит владельцу
|
||||||
|
vaultPda** (PDA `[b"global", b"vault"]`). Правильный адрес:
|
||||||
|
`getAssociatedTokenAddress(mint, vaultPda(programId))` (allowOwnerOffCurve по PDA-владельцу).
|
||||||
|
|
||||||
|
Однако в релейере `createBet`/`closeBet` используется:
|
||||||
|
```ts
|
||||||
|
vaultToken: vaultPda(program.programId),
|
||||||
|
```
|
||||||
|
Это передаёт адрес **самого PDA-владельца** (не токен-аккаунт) — контракт
|
||||||
|
отвергнет транзакцию (wrong account), а `ensureVaultFunded` зовёт
|
||||||
|
`getTokenAccountBalance(vaultToken)` где vaultToken = vaultPda → «could not find account».
|
||||||
|
Т.е. ваулт ATA даже не создаётся в нужном месте.
|
||||||
|
|
||||||
|
## Что исправить (только `relayer/src/ops.ts`)
|
||||||
|
|
||||||
|
1. **`ensureVaultFunded`**:
|
||||||
|
- Получать ATA ваулта: `const vaultAta = await getAssociatedTokenAddress(mint, vaultPda, true);`
|
||||||
|
(третий аргумент `true` — allowOwnerOffCurve, иначе TokenOwnerOffCurveError).
|
||||||
|
- Создавать/получать: `getOrCreateAssociatedTokenAccount(connection, admin, mint, vaultPda, true)`.
|
||||||
|
- `getTokenAccountBalance` звать по **ATA-адресу** (`vaultAta`), не по vaultPda.
|
||||||
|
|
||||||
|
2. **`createBet`**:
|
||||||
|
- `vaultToken: await getAssociatedTokenAddress(mint, vaultPda(program.programId), true)`
|
||||||
|
|
||||||
|
3. **`closeBet`**:
|
||||||
|
- `vaultToken: await getAssociatedTokenAddress(mint, vaultPda(program.programId), true)`
|
||||||
|
|
||||||
|
## Гейты
|
||||||
|
- `npm run typecheck` — 0 ошибок.
|
||||||
|
- `npm run test` (check.ts) — зелёные проверки.
|
||||||
|
- (Если можно) быстрый локальный smoke: создать ваулт ATA, налить токен, create/close.
|
||||||
|
|
||||||
|
## НЕ делать
|
||||||
|
- Не трогать `program/` (контракт), не менять IDL.
|
||||||
|
- Не трогать логику цен/ставок.
|
||||||
|
- Не коммитить.
|
||||||
|
|
||||||
|
## Суть одным абзацем
|
||||||
|
`vault_token` в контракте — токен-счёт (ATA), всегда считаемый через
|
||||||
|
`getAssociatedTokenAddress(mint, vaultPda, true)`. Заменить все прямые
|
||||||
|
использования `vaultPda(...)` как адреса acкаунта на ATA-адрес.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# TASK — Фикс: сделать vault_token обычным ATA-токен-счётом (убрать seeds = PDA)
|
||||||
|
|
||||||
|
Репозиторий: `smart-updown-token`. Файлы: `program/programs/smart-updown/src/instructions/create_bet.rs`
|
||||||
|
и `.../close_bet.rs`.
|
||||||
|
|
||||||
|
## Проблема (обнаружено на localnet E2E)
|
||||||
|
Токен-аккаунт ваулта объявлен так:
|
||||||
|
```rust
|
||||||
|
#[account(
|
||||||
|
mut,
|
||||||
|
seeds = [GLOBAL_SEED, VAULT_SEED], // ← требует адрес == PDA([global,vault])
|
||||||
|
)]
|
||||||
|
pub vault_token: Account<'info, TokenAccount>
|
||||||
|
```
|
||||||
|
Anchor проверяет, что **адрес `vault_token` == PDA([global,vault])**. Но казённый
|
||||||
|
(escrow) токен-счёт у нас — это ATA от владельца `vaultPda`: адрес
|
||||||
|
`getAssociatedTokenAddress(mint, vaultPda)`, который НЕ равен PDA. Отсюда
|
||||||
|
`Error Code: ConstraintSeeds` — перевод в ваулт невозможен.
|
||||||
|
|
||||||
|
У `bettor_token` такой проблемы нет (там обычный `Account<TokenAccount>`, адрес = ATA игрока).
|
||||||
|
|
||||||
|
## Что исправить
|
||||||
|
|
||||||
|
### 1. `create_bet.rs`
|
||||||
|
У `vault_token` **убрать блок `seeds = [GLOBAL_SEED, VAULT_SEED]`** — оставить просто:
|
||||||
|
```rust
|
||||||
|
#[account(mut)]
|
||||||
|
pub vault_token: Account<'info, TokenAccount>,
|
||||||
|
```
|
||||||
|
(оставить поле `bump` больше не нужно — удалить его из struct, если он был).
|
||||||
|
Это сделает адрес любого токен-счёта (ATA ваулта) приемлемым.
|
||||||
|
|
||||||
|
### 2. `close_bet.rs`
|
||||||
|
То же самое: у `vault_token` убрать `seeds = [...]`. НО: раз адрес больше не фиксируется
|
||||||
|
seeds, передавать/подписывать перевод нужно от **владельца-пользователя (vaultPda)**.
|
||||||
|
В `close_bet` AUTHORITY перевода остаётся владелец аккаунта (поле `owner` токен-счёта).
|
||||||
|
Проверить: signer_seeds `[GLOBAL_SEED, VAULT_SEED, bump]` должны по-прежнему давать
|
||||||
|
vaultPda (это и есть владелец ATA). Так как сигнатура от PDA не меняется, seed-фразы
|
||||||
|
в `close_bet.rs` (для подписи CPI) СТАВЯТСЯ из `ctx.accounts.vault_token` owner.
|
||||||
|
Возможно понадобится отдельный аккаунт `vault_authority: PDA([global,vault])`, чтобы
|
||||||
|
передать в signer seeds — оценить и сделать аккуратно (стандартный Anchor escrow).
|
||||||
|
|
||||||
|
### 3. Пересборка и проверка
|
||||||
|
- `anchor build` зелёный.
|
||||||
|
- IDL перегенерировать (`anchor idl build`), синхронизировать `relayer/idl/smart_updown.json`.
|
||||||
|
- `npm run typecheck` / `npm test` в `relayer/` — зелёные (если тесты проверяют seeds — обновить).
|
||||||
|
|
||||||
|
## Важно (архитектурно)
|
||||||
|
`vault_token` — это ATA от владельца `vaultPda` (адрес `getAssociatedTokenAddress(mint, vaultPda)`).
|
||||||
|
Контракт НЕ должен требовать конкретного адреса — только владельца-подпись для withdraw.
|
||||||
|
Всё, что нужно — чтобы в `close_bet` authority (тот, кто подписывает перевод из ваулта)
|
||||||
|
был **vaultPda** (владелец ATA), а не адрес аккаунта.
|
||||||
|
|
||||||
|
## НЕ делать
|
||||||
|
- Не трогать логику ставок/цен.
|
||||||
|
- Не трогать релейер (он уже передаёт правильный ATA-адрес).
|
||||||
|
- Не коммитить.
|
||||||
|
|
||||||
|
## Проверка успеха
|
||||||
|
- `anchor build` + IDL обновлён.
|
||||||
|
- На localnet: передача `vault_token` = ATA (`getAssociatedTokenAddress(mint, vaultPda)`),
|
||||||
|
create_bet принимает ставку, close_bet выплачивает (signer — vaultPda). E2E проходит.
|
||||||
@@ -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.
+120
-18
@@ -10,7 +10,14 @@
|
|||||||
{
|
{
|
||||||
"name": "close_bet",
|
"name": "close_bet",
|
||||||
"discriminator": [
|
"discriminator": [
|
||||||
2
|
185,
|
||||||
|
206,
|
||||||
|
13,
|
||||||
|
184,
|
||||||
|
176,
|
||||||
|
108,
|
||||||
|
140,
|
||||||
|
107
|
||||||
],
|
],
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
@@ -57,15 +64,60 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bettor",
|
"name": "bettor"
|
||||||
"docs": [
|
|
||||||
"CHECK: recipient must be the bet's bettor and is validated in the handler."
|
|
||||||
],
|
|
||||||
"writable": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "clock",
|
"name": "clock",
|
||||||
"address": "SysvarC1ock11111111111111111111111111111111"
|
"address": "SysvarC1ock11111111111111111111111111111111"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "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": [
|
"args": [
|
||||||
@@ -82,7 +134,14 @@
|
|||||||
{
|
{
|
||||||
"name": "create_bet",
|
"name": "create_bet",
|
||||||
"discriminator": [
|
"discriminator": [
|
||||||
1
|
197,
|
||||||
|
42,
|
||||||
|
153,
|
||||||
|
2,
|
||||||
|
59,
|
||||||
|
63,
|
||||||
|
143,
|
||||||
|
246
|
||||||
],
|
],
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
@@ -136,6 +195,24 @@
|
|||||||
{
|
{
|
||||||
"name": "system_program",
|
"name": "system_program",
|
||||||
"address": "11111111111111111111111111111111"
|
"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": [
|
"args": [
|
||||||
@@ -160,7 +237,14 @@
|
|||||||
{
|
{
|
||||||
"name": "initialize",
|
"name": "initialize",
|
||||||
"discriminator": [
|
"discriminator": [
|
||||||
0
|
175,
|
||||||
|
175,
|
||||||
|
109,
|
||||||
|
31,
|
||||||
|
13,
|
||||||
|
152,
|
||||||
|
155,
|
||||||
|
237
|
||||||
],
|
],
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
@@ -214,7 +298,14 @@
|
|||||||
{
|
{
|
||||||
"name": "update",
|
"name": "update",
|
||||||
"discriminator": [
|
"discriminator": [
|
||||||
3
|
219,
|
||||||
|
200,
|
||||||
|
88,
|
||||||
|
176,
|
||||||
|
158,
|
||||||
|
63,
|
||||||
|
253,
|
||||||
|
127
|
||||||
],
|
],
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
@@ -380,10 +471,9 @@
|
|||||||
"types": [
|
"types": [
|
||||||
{
|
{
|
||||||
"name": "Bet",
|
"name": "Bet",
|
||||||
"serialization": "bytemuck",
|
"docs": [
|
||||||
"repr": {
|
"Open bet. `amount` is in token base units (decimals = 6), not lamports."
|
||||||
"kind": "c"
|
],
|
||||||
},
|
|
||||||
"type": {
|
"type": {
|
||||||
"kind": "struct",
|
"kind": "struct",
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -488,10 +578,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Global",
|
"name": "Global",
|
||||||
"serialization": "bytemuck",
|
"docs": [
|
||||||
"repr": {
|
"Global game state.",
|
||||||
"kind": "c"
|
"",
|
||||||
},
|
"All money-related fields are denominated in **SPL-token base units**",
|
||||||
|
"(our \"fanti\" token, decimals = 6), not in lamports:",
|
||||||
|
"`min_amount`/`max_amount` bound a bet amount in token units."
|
||||||
|
],
|
||||||
"type": {
|
"type": {
|
||||||
"kind": "struct",
|
"kind": "struct",
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -541,6 +634,15 @@
|
|||||||
"name": "GLOBAL_SEED",
|
"name": "GLOBAL_SEED",
|
||||||
"type": "bytes",
|
"type": "bytes",
|
||||||
"value": "[103, 108, 111, 98, 97, 108]"
|
"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]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+5782
-1179
File diff suppressed because it is too large
Load Diff
@@ -14,20 +14,25 @@ default = []
|
|||||||
cpi = ["no-entrypoint"]
|
cpi = ["no-entrypoint"]
|
||||||
no-entrypoint = []
|
no-entrypoint = []
|
||||||
no-log-ix-name = []
|
no-log-ix-name = []
|
||||||
idl-build = []
|
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
|
||||||
profile = ["anchor-v2-testing/profile"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Once anchor-lang is published to crates.io, swap to: anchor-lang = "2.0.0-rc.1"
|
anchor-lang = "0.31.1"
|
||||||
anchor-lang = { git = "https://github.com/otter-sec/anchor.git", branch = "anchor-next" }
|
anchor-spl = "0.31.1"
|
||||||
solana-program-log = { version = "1.1", features = ["macro"] }
|
|
||||||
wincode = { version = "0.5", features = ["derive"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
anchor-v2-testing = { git = "https://github.com/otter-sec/anchor.git", branch = "anchor-next" }
|
anchor-lang = "0.31.1"
|
||||||
solana-clock = "3.0.1"
|
solana-program-test = "2.1"
|
||||||
solana-pubkey = "3.0.0"
|
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]
|
[lints.rust]
|
||||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
|
||||||
|
|||||||
@@ -6,11 +6,19 @@ pub const GLOBAL_SEED: &[u8] = b"global";
|
|||||||
#[constant]
|
#[constant]
|
||||||
pub const BET_SEED: &[u8] = b"bet";
|
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_UP: u64 = 0;
|
||||||
pub const SIDE_DOWN: u64 = 1;
|
pub const SIDE_DOWN: u64 = 1;
|
||||||
|
|
||||||
pub const STATUS_OPEN: u64 = 0;
|
pub const STATUS_OPEN: u64 = 0;
|
||||||
pub const STATUS_PAYOUT_DONE: u64 = 1;
|
pub const STATUS_PAYOUT_DONE: u64 = 1;
|
||||||
pub const STATUS_HOUSE_WON: u64 = 2;
|
pub const STATUS_HOUSE_WON: u64 = 2;
|
||||||
|
/// Возврат залога при ничьей (`exit_price == entry_price`).
|
||||||
|
/// Добавлен позже остальных — 0/1/2 уже зашиты в шлюз и в живые аккаунты цепи.
|
||||||
|
pub const STATUS_REFUNDED: u64 = 3;
|
||||||
|
|
||||||
pub const BPS_DIVISOR: u64 = 10_000;
|
pub const BPS_DIVISOR: u64 = 10_000;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use anchor_lang::prelude::*;
|
|||||||
|
|
||||||
#[event]
|
#[event]
|
||||||
pub struct BetCreated {
|
pub struct BetCreated {
|
||||||
pub bet: Address,
|
pub bet: Pubkey,
|
||||||
pub bettor: Address,
|
pub bettor: Pubkey,
|
||||||
pub side: u64,
|
pub side: u64,
|
||||||
pub amount: u64,
|
pub amount: u64,
|
||||||
pub bet_time: i64,
|
pub bet_time: i64,
|
||||||
@@ -13,7 +13,7 @@ pub struct BetCreated {
|
|||||||
|
|
||||||
#[event]
|
#[event]
|
||||||
pub struct BetClosed {
|
pub struct BetClosed {
|
||||||
pub bet: Address,
|
pub bet: Pubkey,
|
||||||
pub exit_price: u64,
|
pub exit_price: u64,
|
||||||
pub user_won: bool,
|
pub user_won: bool,
|
||||||
pub payout: u64,
|
pub payout: u64,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use anchor_lang::{prelude::*, Lamports};
|
use anchor_lang::prelude::*;
|
||||||
|
use anchor_spl::token::{Token, TokenAccount};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
constants::{
|
constants::{
|
||||||
BPS_DIVISOR, BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_HOUSE_WON,
|
BPS_DIVISOR, BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_HOUSE_WON,
|
||||||
STATUS_PAYOUT_DONE, STATUS_OPEN,
|
STATUS_PAYOUT_DONE, STATUS_OPEN, STATUS_REFUNDED, VAULT_SEED,
|
||||||
},
|
},
|
||||||
error::SmartUpdownError,
|
error::SmartUpdownError,
|
||||||
events::BetClosed,
|
events::BetClosed,
|
||||||
@@ -12,33 +13,43 @@ use crate::{
|
|||||||
|
|
||||||
#[derive(Accounts)]
|
#[derive(Accounts)]
|
||||||
#[instruction(id: u64, exit_price: u64)]
|
#[instruction(id: u64, exit_price: u64)]
|
||||||
pub struct CloseBet {
|
pub struct CloseBet<'info> {
|
||||||
pub relayer: Signer,
|
pub relayer: Signer<'info>,
|
||||||
#[account(
|
#[account(
|
||||||
mut,
|
mut,
|
||||||
seeds = [BET_SEED, id.to_le_bytes()],
|
seeds = [BET_SEED, &id.to_le_bytes()],
|
||||||
bump
|
bump
|
||||||
)]
|
)]
|
||||||
pub bet: Account<Bet>,
|
pub bet: Account<'info, Bet>,
|
||||||
#[account(
|
#[account(
|
||||||
mut,
|
mut,
|
||||||
seeds = [GLOBAL_SEED],
|
seeds = [GLOBAL_SEED],
|
||||||
bump
|
bump
|
||||||
)]
|
)]
|
||||||
pub global: Account<Global>,
|
pub global: Account<'info, Global>,
|
||||||
/// CHECK: recipient must be the bet's bettor and is validated in the handler.
|
/// CHECK: recipient must be the bet's bettor and is validated in the handler.
|
||||||
|
pub bettor: SystemAccount<'info>,
|
||||||
|
pub clock: Sysvar<'info, Clock>,
|
||||||
|
/// Token vault ATA (owner = PDA([GLOBAL_SEED, VAULT_SEED])); payouts are paid out from here.
|
||||||
#[account(mut)]
|
#[account(mut)]
|
||||||
pub bettor: SystemAccount,
|
pub vault_token: Account<'info, TokenAccount>,
|
||||||
pub clock: Sysvar<Clock>,
|
/// 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<()> {
|
pub fn handler(ctx: &mut Context<CloseBet>, id: u64, exit_price: u64) -> Result<()> {
|
||||||
require!(
|
require!(
|
||||||
anchor_lang::address_eq(ctx.accounts.relayer.address(), &ctx.accounts.global.relayer),
|
ctx.accounts.relayer.key() == ctx.accounts.global.relayer,
|
||||||
SmartUpdownError::NotRelayer
|
SmartUpdownError::NotRelayer
|
||||||
);
|
);
|
||||||
require!(
|
require!(
|
||||||
anchor_lang::address_eq(ctx.accounts.bettor.address(), &ctx.accounts.bet.bettor),
|
ctx.accounts.bettor.key() == ctx.accounts.bet.bettor,
|
||||||
SmartUpdownError::NotRelayer
|
SmartUpdownError::NotRelayer
|
||||||
);
|
);
|
||||||
require!(
|
require!(
|
||||||
@@ -53,38 +64,91 @@ pub fn handler(ctx: &mut Context<CloseBet>, id: u64, exit_price: u64) -> Result<
|
|||||||
let amount = ctx.accounts.bet.amount;
|
let amount = ctx.accounts.bet.amount;
|
||||||
|
|
||||||
if exit_price == entry {
|
if exit_price == entry {
|
||||||
|
require!(
|
||||||
|
ctx.accounts.vault_token.amount >= amount,
|
||||||
|
SmartUpdownError::InsufficientVault
|
||||||
|
);
|
||||||
|
{
|
||||||
|
// Возврат залога: переводим amount (БЕЗ множителя) из ваулта беттору.
|
||||||
|
// Подпись — сидами ваулт-PDA (как в ветке выигрыша ниже).
|
||||||
|
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, amount)?;
|
||||||
|
}
|
||||||
|
ctx.accounts.bet.exit_price = exit_price;
|
||||||
|
ctx.accounts.bet.status = STATUS_REFUNDED;
|
||||||
|
emit!(BetClosed {
|
||||||
|
bet: ctx.accounts.bet.key(),
|
||||||
|
exit_price,
|
||||||
|
user_won: false,
|
||||||
|
payout: amount,
|
||||||
|
status: STATUS_REFUNDED,
|
||||||
|
});
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_won = (side == SIDE_UP && exit_price > entry)
|
let user_won = (side == SIDE_UP && exit_price > entry)
|
||||||
|| (side == SIDE_DOWN && exit_price < entry);
|
|| (side == SIDE_DOWN && exit_price < entry);
|
||||||
|
|
||||||
let payout;
|
let payout;
|
||||||
if user_won {
|
if user_won {
|
||||||
let multiplier = ctx.accounts.global.multiplier_bps;
|
let multiplier = ctx.accounts.global.multiplier_bps;
|
||||||
let payout_amount =
|
let payout_amount =
|
||||||
(amount as u128)
|
(amount as u128)
|
||||||
.checked_mul(multiplier as u128)
|
.checked_mul(multiplier as u128)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.checked_div(BPS_DIVISOR as u128)
|
.checked_div(BPS_DIVISOR as u128)
|
||||||
.unwrap() as u64;
|
.unwrap() as u64;
|
||||||
require!(
|
require!(
|
||||||
ctx.accounts.global.view().lamports() >= payout_amount,
|
ctx.accounts.vault_token.amount >= payout_amount,
|
||||||
SmartUpdownError::InsufficientVault
|
SmartUpdownError::InsufficientVault
|
||||||
);
|
);
|
||||||
payout = payout_amount;
|
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.
|
||||||
ctx.accounts.bet.status = STATUS_PAYOUT_DONE;
|
// The vault ATA is owned by the vault PDA, so the CPI is signed with its seeds.
|
||||||
} else {
|
let cpi_accounts = anchor_spl::token::Transfer {
|
||||||
payout = 0;
|
from: ctx.accounts.vault_token.to_account_info(),
|
||||||
ctx.accounts.bet.status = STATUS_HOUSE_WON;
|
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;
|
||||||
|
ctx.accounts.bet.status = STATUS_HOUSE_WON;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.accounts.bet.exit_price = exit_price;
|
ctx.accounts.bet.exit_price = exit_price;
|
||||||
|
|
||||||
emit!(BetClosed {
|
emit!(BetClosed {
|
||||||
bet: *ctx.accounts.bet.address(),
|
bet: ctx.accounts.bet.key(),
|
||||||
exit_price,
|
exit_price,
|
||||||
user_won,
|
user_won,
|
||||||
payout,
|
payout,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use anchor_lang::prelude::*;
|
use anchor_lang::prelude::*;
|
||||||
|
use anchor_spl::token::{Token, TokenAccount};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
constants::{BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_OPEN},
|
constants::{BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_OPEN},
|
||||||
@@ -9,24 +10,32 @@ use crate::{
|
|||||||
|
|
||||||
#[derive(Accounts)]
|
#[derive(Accounts)]
|
||||||
#[instruction(id: u64, side: u64, amount: u64, entry_price: u64)]
|
#[instruction(id: u64, side: u64, amount: u64, entry_price: u64)]
|
||||||
pub struct CreateBet {
|
pub struct CreateBet<'info> {
|
||||||
#[account(mut)]
|
#[account(mut)]
|
||||||
pub bettor: Signer,
|
pub bettor: Signer<'info>,
|
||||||
#[account(
|
#[account(
|
||||||
mut,
|
mut,
|
||||||
seeds = [GLOBAL_SEED],
|
seeds = [GLOBAL_SEED],
|
||||||
bump
|
bump
|
||||||
)]
|
)]
|
||||||
pub global: Account<Global>,
|
pub global: Account<'info, Global>,
|
||||||
#[account(
|
#[account(
|
||||||
init,
|
init,
|
||||||
payer = bettor,
|
payer = bettor,
|
||||||
seeds = [BET_SEED, id.to_le_bytes()],
|
space = 8 + Bet::INIT_SPACE,
|
||||||
|
seeds = [BET_SEED, &id.to_le_bytes()],
|
||||||
bump
|
bump
|
||||||
)]
|
)]
|
||||||
pub bet: Account<Bet>,
|
pub bet: Account<'info, Bet>,
|
||||||
pub clock: Sysvar<Clock>,
|
pub clock: Sysvar<'info, Clock>,
|
||||||
pub system_program: Program<System>,
|
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(
|
pub fn handler(
|
||||||
@@ -52,8 +61,8 @@ pub fn handler(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let now = ctx.accounts.clock.unix_timestamp;
|
let now = ctx.accounts.clock.unix_timestamp;
|
||||||
let bettor_address = *ctx.accounts.bettor.address();
|
let bettor_address = ctx.accounts.bettor.key();
|
||||||
let bet_address = *ctx.accounts.bet.address();
|
let bet_address = ctx.accounts.bet.key();
|
||||||
let expiry_seconds = ctx.accounts.global.expiry_seconds;
|
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();
|
ctx.accounts.global.counter = ctx.accounts.global.counter.checked_add(1).unwrap();
|
||||||
|
|
||||||
{
|
{
|
||||||
let cpi_accounts = system_program::Transfer {
|
// Escrow: move the bet amount (token units) from the bettor's account to the vault.
|
||||||
from: ctx.accounts.bettor.cpi_handle_mut(),
|
// The SPL token program enforces that `bettor_token` is owned by `bettor` (authority).
|
||||||
to: ctx.accounts.global.cpi_handle_mut(),
|
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);
|
let cpi_ctx = CpiContext::new(
|
||||||
system_program::transfer(cpi_ctx, amount)?;
|
ctx.accounts.token_program.to_account_info(),
|
||||||
|
cpi_accounts,
|
||||||
|
);
|
||||||
|
anchor_spl::token::transfer(cpi_ctx, amount)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
emit!(BetCreated {
|
emit!(BetCreated {
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ use anchor_lang::prelude::*;
|
|||||||
use crate::{constants::GLOBAL_SEED, state::Global};
|
use crate::{constants::GLOBAL_SEED, state::Global};
|
||||||
|
|
||||||
#[derive(Accounts)]
|
#[derive(Accounts)]
|
||||||
pub struct Initialize {
|
pub struct Initialize<'info> {
|
||||||
#[account(mut)]
|
#[account(mut)]
|
||||||
pub admin: Signer,
|
pub admin: Signer<'info>,
|
||||||
#[account(
|
#[account(
|
||||||
init,
|
init,
|
||||||
payer = admin,
|
payer = admin,
|
||||||
|
space = 8 + Global::INIT_SPACE,
|
||||||
seeds = [GLOBAL_SEED],
|
seeds = [GLOBAL_SEED],
|
||||||
bump
|
bump
|
||||||
)]
|
)]
|
||||||
pub global: Account<Global>,
|
pub global: Account<'info, Global>,
|
||||||
pub system_program: Program<System>,
|
pub system_program: Program<'info, System>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handler(
|
pub fn handler(
|
||||||
@@ -24,8 +25,8 @@ pub fn handler(
|
|||||||
expiry_seconds: i64,
|
expiry_seconds: i64,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let global = &mut ctx.accounts.global;
|
let global = &mut ctx.accounts.global;
|
||||||
global.admin = *ctx.accounts.admin.address();
|
global.admin = ctx.accounts.admin.key();
|
||||||
global.relayer = *ctx.accounts.admin.address();
|
global.relayer = ctx.accounts.admin.key();
|
||||||
global.counter = 0;
|
global.counter = 0;
|
||||||
global.min_amount = min_amount;
|
global.min_amount = min_amount;
|
||||||
global.max_amount = max_amount;
|
global.max_amount = max_amount;
|
||||||
|
|||||||
@@ -7,19 +7,19 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Accounts)]
|
#[derive(Accounts)]
|
||||||
pub struct Update {
|
pub struct Update<'info> {
|
||||||
pub admin: Signer,
|
pub admin: Signer<'info>,
|
||||||
#[account(
|
#[account(
|
||||||
mut,
|
mut,
|
||||||
seeds = [GLOBAL_SEED],
|
seeds = [GLOBAL_SEED],
|
||||||
bump
|
bump
|
||||||
)]
|
)]
|
||||||
pub global: Account<Global>,
|
pub global: Account<'info, Global>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handler(
|
pub fn handler(
|
||||||
ctx: &mut Context<Update>,
|
ctx: &mut Context<Update>,
|
||||||
new_relayer: Address,
|
new_relayer: Pubkey,
|
||||||
min_amount: u64,
|
min_amount: u64,
|
||||||
max_amount: u64,
|
max_amount: u64,
|
||||||
multiplier_bps: u64,
|
multiplier_bps: u64,
|
||||||
@@ -27,7 +27,7 @@ pub fn handler(
|
|||||||
expiry_seconds: i64,
|
expiry_seconds: i64,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
require!(
|
require!(
|
||||||
anchor_lang::address_eq(ctx.accounts.admin.address(), &ctx.accounts.global.admin),
|
ctx.accounts.admin.key() == ctx.accounts.global.admin,
|
||||||
SmartUpdownError::NotAdmin
|
SmartUpdownError::NotAdmin
|
||||||
);
|
);
|
||||||
let global = &mut ctx.accounts.global;
|
let global = &mut ctx.accounts.global;
|
||||||
|
|||||||
@@ -14,41 +14,37 @@ declare_id!("9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf");
|
|||||||
pub mod smart_updown {
|
pub mod smart_updown {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[discrim = 0]
|
|
||||||
pub fn initialize(
|
pub fn initialize(
|
||||||
ctx: &mut Context<Initialize>,
|
mut ctx: Context<Initialize>,
|
||||||
min_amount: u64,
|
min_amount: u64,
|
||||||
max_amount: u64,
|
max_amount: u64,
|
||||||
multiplier_bps: u64,
|
multiplier_bps: u64,
|
||||||
expiry_seconds: i64,
|
expiry_seconds: i64,
|
||||||
) -> Result<()> {
|
) -> 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(
|
pub fn create_bet(
|
||||||
ctx: &mut Context<CreateBet>,
|
mut ctx: Context<CreateBet>,
|
||||||
id: u64,
|
id: u64,
|
||||||
side: u64,
|
side: u64,
|
||||||
amount: u64,
|
amount: u64,
|
||||||
entry_price: u64,
|
entry_price: u64,
|
||||||
) -> Result<()> {
|
) -> 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(
|
pub fn close_bet(
|
||||||
ctx: &mut Context<CloseBet>,
|
mut ctx: Context<CloseBet>,
|
||||||
id: u64,
|
id: u64,
|
||||||
exit_price: u64,
|
exit_price: u64,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
close_bet::handler(ctx, id, exit_price)
|
close_bet::handler(&mut ctx, id, exit_price)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[discrim = 3]
|
|
||||||
pub fn update(
|
pub fn update(
|
||||||
ctx: &mut Context<Update>,
|
mut ctx: Context<Update>,
|
||||||
new_relayer: Address,
|
new_relayer: Pubkey,
|
||||||
min_amount: u64,
|
min_amount: u64,
|
||||||
max_amount: u64,
|
max_amount: u64,
|
||||||
multiplier_bps: u64,
|
multiplier_bps: u64,
|
||||||
@@ -56,7 +52,7 @@ pub mod smart_updown {
|
|||||||
expiry_seconds: i64,
|
expiry_seconds: i64,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
update::handler(
|
update::handler(
|
||||||
ctx,
|
&mut ctx,
|
||||||
new_relayer,
|
new_relayer,
|
||||||
min_amount,
|
min_amount,
|
||||||
max_amount,
|
max_amount,
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
use anchor_lang::prelude::*;
|
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]
|
#[account]
|
||||||
|
#[derive(InitSpace)]
|
||||||
pub struct Global {
|
pub struct Global {
|
||||||
pub counter: u64,
|
pub counter: u64,
|
||||||
pub min_amount: u64,
|
pub min_amount: u64,
|
||||||
@@ -8,11 +14,13 @@ pub struct Global {
|
|||||||
pub multiplier_bps: u64,
|
pub multiplier_bps: u64,
|
||||||
pub expiry_seconds: i64,
|
pub expiry_seconds: i64,
|
||||||
pub paused: u64,
|
pub paused: u64,
|
||||||
pub admin: Address,
|
pub admin: Pubkey,
|
||||||
pub relayer: Address,
|
pub relayer: Pubkey,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open bet. `amount` is in token base units (decimals = 6), not lamports.
|
||||||
#[account]
|
#[account]
|
||||||
|
#[derive(InitSpace)]
|
||||||
pub struct Bet {
|
pub struct Bet {
|
||||||
pub amount: u64,
|
pub amount: u64,
|
||||||
pub bet_time: i64,
|
pub bet_time: i64,
|
||||||
@@ -21,5 +29,5 @@ pub struct Bet {
|
|||||||
pub exit_price: u64,
|
pub exit_price: u64,
|
||||||
pub side: u64,
|
pub side: u64,
|
||||||
pub status: u64,
|
pub status: u64,
|
||||||
pub bettor: Address,
|
pub bettor: Pubkey,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,155 @@
|
|||||||
use {
|
use {
|
||||||
anchor_lang::{
|
anchor_lang::{
|
||||||
bytemuck, cpi::find_program_address, solana_program::instruction::Instruction, Id,
|
solana_program::{
|
||||||
InstructionData, ToAccountMetas,
|
instruction::Instruction,
|
||||||
|
program_option::COption,
|
||||||
|
program_pack::Pack,
|
||||||
|
pubkey::Pubkey,
|
||||||
|
rent::Rent,
|
||||||
|
system_instruction,
|
||||||
|
},
|
||||||
|
AccountDeserialize, InstructionData, ToAccountMetas,
|
||||||
},
|
},
|
||||||
anchor_v2_testing::{Keypair, LiteSVM, Message, Signer, VersionedMessage, VersionedTransaction},
|
smart_updown::{constants::VAULT_SEED, state::{Bet, Global}},
|
||||||
smart_updown::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 CLOCK_ADDR: &str = "SysvarC1ock11111111111111111111111111111111";
|
||||||
const SYSTEM_ADDR: &str = "11111111111111111111111111111111";
|
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 MULTIPLIER_BPS: u64 = 13_000; // 1.3x
|
||||||
const EXPIRY_SECONDS: i64 = 15;
|
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()
|
smart_updown::id()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn global_pda() -> Addr {
|
fn global_pda() -> Pubkey {
|
||||||
find_program_address(&[b"global"], &pid()).0
|
Pubkey::find_program_address(&[b"global"], &pid()).0
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bet_pda(id: u64) -> Addr {
|
fn vault_pda() -> Pubkey {
|
||||||
find_program_address(&[b"bet", &id.to_le_bytes()], &pid()).0
|
Pubkey::find_program_address(&[b"global", VAULT_SEED], &pid()).0
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup() -> (LiteSVM, Keypair) {
|
fn bet_pda(id: u64) -> Pubkey {
|
||||||
let mut svm = LiteSVM::new();
|
Pubkey::find_program_address(&[b"bet", &id.to_le_bytes()], &pid()).0
|
||||||
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 send(svm: &mut LiteSVM, signers: &[&Keypair], instruction: Instruction) -> Result<(), String> {
|
/// Boot the program-test context with the фантики mint and the program's
|
||||||
let blockhash = svm.latest_blockhash();
|
/// token vault (PDA([global, vault])) pre-loaded into genesis. The vault is a
|
||||||
let msg = Message::new_with_blockhash(&[instruction], Some(&signers[0].pubkey()), &blockhash);
|
/// PDA owned by itself, so it can never sign a top-level create_account; the
|
||||||
let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), signers).unwrap();
|
/// account data below matches what the token program would have created.
|
||||||
let res = svm.send_transaction(tx);
|
async fn setup() -> (ProgramTestContext, Keypair) {
|
||||||
res.map(|_| ()).map_err(|e| format!("{:?}", e))
|
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) {
|
async fn set_clock(ctx: &mut ProgramTestContext, unix: i64) {
|
||||||
let ix = Instruction::new_with_bytes(
|
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(),
|
pid(),
|
||||||
&smart_updown::instruction::Initialize {
|
&smart_updown::instruction::Initialize {
|
||||||
min_amount: MIN_AMOUNT,
|
min_amount: MIN_AMOUNT,
|
||||||
@@ -55,37 +159,22 @@ fn initialize(svm: &mut LiteSVM, admin: &Keypair) {
|
|||||||
}
|
}
|
||||||
.data(),
|
.data(),
|
||||||
smart_updown::accounts::Initialize {
|
smart_updown::accounts::Initialize {
|
||||||
admin: admin.pubkey(),
|
admin,
|
||||||
global: global_pda(),
|
global: global_pda(),
|
||||||
system_program: SYSTEM_ADDR.parse().unwrap(),
|
system_program: SYSTEM_ADDR.parse().unwrap(),
|
||||||
}
|
}
|
||||||
.to_account_metas(None),
|
.to_account_metas(None),
|
||||||
);
|
)
|
||||||
send(svm, &[admin], ix).expect("initialize");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_clock(svm: &mut LiteSVM, unix: i64) {
|
fn make_create_ix(
|
||||||
use solana_clock::Clock;
|
id: u64,
|
||||||
let mut clk = svm.get_sysvar::<Clock>();
|
side: u64,
|
||||||
clk.unix_timestamp = unix;
|
amount: u64,
|
||||||
svm.set_sysvar(&clk);
|
entry_price: u64,
|
||||||
}
|
bettor: Pubkey,
|
||||||
|
bettor_ata: &Pubkey,
|
||||||
fn fund_vault(svm: &mut LiteSVM, lamports: u64) {
|
) -> Instruction {
|
||||||
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 {
|
|
||||||
Instruction::new_with_bytes(
|
Instruction::new_with_bytes(
|
||||||
pid(),
|
pid(),
|
||||||
&smart_updown::instruction::CreateBet {
|
&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),
|
bet: bet_pda(id),
|
||||||
clock: CLOCK_ADDR.parse().unwrap(),
|
clock: CLOCK_ADDR.parse().unwrap(),
|
||||||
system_program: SYSTEM_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),
|
.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(
|
Instruction::new_with_bytes(
|
||||||
pid(),
|
pid(),
|
||||||
&smart_updown::instruction::CloseBet { id, exit_price }.data(),
|
&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(),
|
global: global_pda(),
|
||||||
bettor,
|
bettor,
|
||||||
clock: CLOCK_ADDR.parse().unwrap(),
|
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),
|
.to_account_metas(None),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_up_bet(svm: &mut LiteSVM, admin: &Keypair, side: u64) -> (Keypair, u64, u64) {
|
async fn run_ix(
|
||||||
let bettor = Keypair::new();
|
ctx: &mut ProgramTestContext,
|
||||||
svm.airdrop(&bettor.pubkey(), 100_000_000_000).unwrap();
|
signer: &Keypair,
|
||||||
set_clock(svm, 1_700_000_000);
|
ix: Instruction,
|
||||||
let amount = MIN_AMOUNT;
|
) -> Result<(), String> {
|
||||||
let entry_price = 100_000_000u64;
|
let tx = Transaction::new_signed_with_payer(
|
||||||
let create_ix = make_create_ix(0, side, amount, entry_price, bettor.pubkey());
|
&[ix],
|
||||||
send(svm, &[&bettor], create_ix).expect("create_bet");
|
Some(&signer.pubkey()),
|
||||||
(bettor, amount, entry_price)
|
&[signer],
|
||||||
|
ctx.last_blockhash,
|
||||||
|
);
|
||||||
|
ctx.banks_client
|
||||||
|
.process_transaction(tx)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
async fn initialize(ctx: &mut ProgramTestContext, admin: &Keypair) {
|
||||||
fn initialize_creates_global() {
|
run_ix(ctx, admin, make_initialize_ix(admin.pubkey()))
|
||||||
let (mut svm, admin) = setup();
|
.await
|
||||||
initialize(&mut svm, &admin);
|
.expect("initialize");
|
||||||
let g = read_global(&svm);
|
}
|
||||||
|
|
||||||
|
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.admin, admin.pubkey());
|
||||||
assert_eq!(g.relayer, admin.pubkey());
|
assert_eq!(g.relayer, admin.pubkey());
|
||||||
assert_eq!(g.counter, 0);
|
assert_eq!(g.counter, 0);
|
||||||
@@ -145,139 +361,215 @@ fn initialize_creates_global() {
|
|||||||
assert_eq!(g.paused, 0);
|
assert_eq!(g.paused, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn create_bet_escrows_sol() {
|
async fn create_bet_escrows_tokens() {
|
||||||
let (mut svm, admin) = setup();
|
let (mut ctx, admin) = setup().await;
|
||||||
initialize(&mut svm, &admin);
|
initialize(&mut ctx, &admin).await;
|
||||||
let (bettor, amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
|
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_eq!(
|
||||||
assert!(vault_balance >= amount);
|
read_token_balance(&mut ctx, &vault_pda()).await,
|
||||||
let b = read_bet(&svm, 0);
|
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.bettor, bettor.pubkey());
|
||||||
assert_eq!(b.side, 0);
|
assert_eq!(b.side, 0);
|
||||||
assert_eq!(b.amount, amount);
|
assert_eq!(b.amount, amount);
|
||||||
assert_eq!(b.entry_price, entry_price);
|
assert_eq!(b.entry_price, entry_price);
|
||||||
assert_eq!(b.bet_time, 1_700_000_000);
|
assert_eq!(b.bet_time, START_TIME);
|
||||||
assert_eq!(b.expire_time, 1_700_000_000 + EXPIRY_SECONDS);
|
assert_eq!(b.expire_time, START_TIME + EXPIRY_SECONDS);
|
||||||
assert_eq!(b.status, 0);
|
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]
|
#[tokio::test]
|
||||||
fn close_bet_user_wins_up() {
|
async fn close_bet_user_wins_up() {
|
||||||
let (mut svm, admin) = setup();
|
let (mut ctx, admin) = setup().await;
|
||||||
initialize(&mut svm, &admin);
|
initialize(&mut ctx, &admin).await;
|
||||||
let (bettor, amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
|
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 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;
|
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());
|
run_close_tx(
|
||||||
send(&mut svm, &[&admin], close_ix).expect("close_bet");
|
&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);
|
assert_eq!(read_token_balance(&mut ctx, &ata).await, before + payout);
|
||||||
let b = read_bet(&svm, 0);
|
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.status, 1);
|
||||||
assert_eq!(b.exit_price, exit_price);
|
assert_eq!(b.exit_price, exit_price);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn close_bet_house_wins_down() {
|
async fn close_bet_house_wins_down() {
|
||||||
let (mut svm, admin) = setup();
|
let (mut ctx, admin) = setup().await;
|
||||||
initialize(&mut svm, &admin);
|
initialize(&mut ctx, &admin).await;
|
||||||
let (bettor, _amount, entry_price) = create_up_bet(&mut svm, &admin, 1);
|
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 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());
|
run_close_tx(
|
||||||
send(&mut svm, &[&admin], close_ix).expect("close_bet");
|
&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_token_balance(&mut ctx, &ata).await, before);
|
||||||
assert_eq!(read_bet(&svm, 0).status, 2);
|
assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn close_bet_equal_price_stays_open() {
|
async fn close_bet_equal_price_refunds() {
|
||||||
let (mut svm, admin) = setup();
|
let (mut ctx, admin) = setup().await;
|
||||||
initialize(&mut svm, &admin);
|
initialize(&mut ctx, &admin).await;
|
||||||
let (bettor, amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
|
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 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 equal_ix = make_close_ix(0, entry_price, admin.pubkey(), bettor.pubkey());
|
run_close_tx(
|
||||||
send(&mut svm, &[&admin], equal_ix).expect("equal close");
|
&mut ctx,
|
||||||
assert_eq!(read_bet(&svm, 0).status, 0);
|
&admin,
|
||||||
assert_eq!(svm.get_balance(&bettor.pubkey()).unwrap(), before);
|
&make_close_ix(0, entry_price, admin.pubkey(), bettor.pubkey(), &ata),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("equal close");
|
||||||
|
|
||||||
let exit_price = entry_price + 1_000;
|
let b = read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await);
|
||||||
let second_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey());
|
assert_eq!(b.status, 3, "expected status REFUNDED=3");
|
||||||
send(&mut svm, &[&admin], second_ix).expect("second close");
|
assert_eq!(b.exit_price, entry_price, "exit_price must be recorded as entry_price");
|
||||||
|
assert_eq!(read_token_balance(&mut ctx, &ata).await, before + amount, "bettor +amount");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
svm.get_balance(&bettor.pubkey()).unwrap(),
|
read_token_balance(&mut ctx, &vault_pda()).await,
|
||||||
before + payout
|
VAULT_TOKEN_SUPPLY,
|
||||||
|
"vault -amount"
|
||||||
);
|
);
|
||||||
assert_eq!(read_bet(&svm, 0).status, 1);
|
|
||||||
|
// Повторный close по той же ставке → ошибка BetAlreadyFinalized.
|
||||||
|
// exit_price чуть другой, иначе banks дедуплицирует транзакцию
|
||||||
|
// (тот же signer + тот же blockhash + те же данные икса → Ok без перезапуска).
|
||||||
|
let res = run_close_tx(
|
||||||
|
&mut ctx,
|
||||||
|
&admin,
|
||||||
|
&make_close_ix(0, entry_price + 1, admin.pubkey(), bettor.pubkey(), &ata),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(res.is_err(), "expected second close after refund to be rejected");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn close_bet_before_expiry_rejected() {
|
async fn close_bet_already_finalized() {
|
||||||
let (mut svm, admin) = setup();
|
let (mut ctx, admin) = setup().await;
|
||||||
initialize(&mut svm, &admin);
|
initialize(&mut ctx, &admin).await;
|
||||||
let (bettor, _amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
|
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());
|
set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await;
|
||||||
let res = send(&mut svm, &[&admin], close_ix);
|
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!(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]
|
#[tokio::test]
|
||||||
fn close_bet_wrong_relayer_rejected() {
|
async fn close_bet_wrong_relayer_rejected() {
|
||||||
let (mut svm, admin) = setup();
|
let (mut ctx, admin) = setup().await;
|
||||||
initialize(&mut svm, &admin);
|
initialize(&mut ctx, &admin).await;
|
||||||
let (bettor, _amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
|
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();
|
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);
|
set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await;
|
||||||
let close_ix = make_close_ix(0, entry_price + 500, attacker.pubkey(), bettor.pubkey());
|
let close_ix = make_close_ix(0, entry_price + 500, attacker.pubkey(), bettor.pubkey(), &ata);
|
||||||
let res = send(&mut svm, &[&attacker], close_ix);
|
let res = run_close_tx(&mut ctx, &attacker, &close_ix).await;
|
||||||
assert!(res.is_err(), "expected wrong relayer to be rejected");
|
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]
|
#[tokio::test]
|
||||||
fn close_bet_replay_rejected() {
|
async fn close_bet_replay_rejected() {
|
||||||
let (mut svm, admin) = setup();
|
let (mut ctx, admin) = setup().await;
|
||||||
initialize(&mut svm, &admin);
|
initialize(&mut ctx, &admin).await;
|
||||||
let (bettor, _amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
|
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;
|
||||||
fund_vault(&mut svm, 10 * MIN_AMOUNT);
|
let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey(), &ata);
|
||||||
let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey());
|
run_close_tx(&mut ctx, &admin, &close_ix).await.expect("first close");
|
||||||
send(&mut svm, &[&admin], close_ix).expect("first close");
|
let before = read_token_balance(&mut ctx, &ata).await;
|
||||||
let res = send(&mut svm, &[&admin], make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey()));
|
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!(res.is_err(), "expected replay close to be rejected");
|
||||||
|
assert_eq!(read_token_balance(&mut ctx, &ata).await, before);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn create_bet_rejects_out_of_range() {
|
async fn create_bet_rejects_out_of_range() {
|
||||||
let (mut svm, admin) = setup();
|
let (mut ctx, admin) = setup().await;
|
||||||
initialize(&mut svm, &admin);
|
initialize(&mut ctx, &admin).await;
|
||||||
let bettor = Keypair::new();
|
let mint = create_token_env(&mut ctx, &admin).await;
|
||||||
svm.airdrop(&bettor.pubkey(), 100_000_000_000).unwrap();
|
let (bettor, ata) = create_bettor(&mut ctx, &admin, &mint).await;
|
||||||
set_clock(&mut svm, 1_700_000_000);
|
set_clock(&mut ctx, START_TIME).await;
|
||||||
|
|
||||||
let ix = make_create_ix(0, 0, 1, 100_000_000u64, bettor.pubkey());
|
let vault_before = read_token_balance(&mut ctx, &vault_pda()).await;
|
||||||
let res = send(&mut svm, &[&bettor], ix);
|
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!(res.is_err(), "expected below-min bet to be rejected");
|
||||||
}
|
assert_eq!(read_token_balance(&mut ctx, &vault_pda()).await, vault_before);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.gateway-bettors.json
|
||||||
|
*.md
|
||||||
+13
-5
@@ -1,14 +1,22 @@
|
|||||||
# RPC эндпоинт кластера (localnet = запущенный solana-test-validator)
|
# RPC эндпоинт кластера (localnet = запущенный solana-test-validator)
|
||||||
SMART_RPC_URL=http://127.0.0.1:8899
|
SMART_RPC_URL=http://127.0.0.1:8899
|
||||||
|
|
||||||
# Разрешить airDrop на localnet (bettor'ам и ваулту). На mainnet выключить.
|
# Mint наших фантиков (SPL-токен, decimals = 6; см. state.rs контракта).
|
||||||
|
# На localnet — адрес mint, созданный до старта релейера (mint_authority = админ).
|
||||||
|
# Обязателен: без него ATA/ваулт нельзя привязать к нужному токену.
|
||||||
|
SMART_TOKEN_MINT=
|
||||||
|
|
||||||
|
# Разрешить «наивное» финансирование на localnet: SOL-airDrop бетторам
|
||||||
|
# (ренты/комиссии) + налив фантиков в ваулт через mint authority (ensureVaultFunded).
|
||||||
|
# На mainnet выключить — ваулт там финансируется off-chain.
|
||||||
SMART_ALLOW_AIRDROP=1
|
SMART_ALLOW_AIRDROP=1
|
||||||
|
|
||||||
# Путь к keypair админа/релеера (тот же ключ = admin и relayer)
|
# Путь к keypair админа/релеера (тот же ключ = admin и relayer; на localnet ещё и mint authority)
|
||||||
SMART_ADMIN_KEYPAIR=~/.config/solana/id.json
|
SMART_ADMIN_KEYPAIR=~/.config/solana/id.json
|
||||||
|
|
||||||
# Параметры контракта (должны совпадать со значениями при initialize)
|
# Параметры контракта (должны совпадать со значениями при initialize).
|
||||||
SMART_MIN_AMOUNT=100000000 # 0.1 SOL в лампортах
|
# ВАЖНО: теперь это ТОКЕН-юниты (base units фантиков, decimals = 6), а не лампорты!
|
||||||
SMART_MAX_AMOUNT=10000000000 # 10 SOL в лампортах
|
SMART_MIN_AMOUNT=100000000 # 100 фантиков
|
||||||
|
SMART_MAX_AMOUNT=10000000000 # 10 000 фантиков
|
||||||
SMART_MULTIPLIER_BPS=13000 # 1.3x выплата в базисных пунктах
|
SMART_MULTIPLIER_BPS=13000 # 1.3x выплата в базисных пунктах
|
||||||
SMART_EXPIRY_SECONDS=15 # экспирация ставки (секунды)
|
SMART_EXPIRY_SECONDS=15 # экспирация ставки (секунды)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# custodial-ключи бетторов HTTP-шлюза (gateway.ts) — НЕ коммитить
|
||||||
|
.gateway-bettors.json
|
||||||
+378
@@ -0,0 +1,378 @@
|
|||||||
|
# API шлюза `smart-updown-relayer`
|
||||||
|
|
||||||
|
Шлюз — это HTTP/WebSocket-прослойка поверх Solana-контракта. Клиент фронта
|
||||||
|
**не держит свой keypair**: все ставки подписывает шлюз от имени **захардкоженного
|
||||||
|
беттора** (`GW_FIXED_BETTOR`, дефолт `6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6`).
|
||||||
|
Сумму ставки шлюз тоже считает сам = `GW_BET_PERCENT%` (дефолт `30%`) от баланса
|
||||||
|
фантиков беттора с клампом в `[minAmount, maxAmount]` из `Global`.
|
||||||
|
|
||||||
|
## База клиента и форматы
|
||||||
|
|
||||||
|
- **База**: `https://smart-updown.binom.pw/api` — все POST-ручки требуют префикс `/api`.
|
||||||
|
- **WebSocket**: `wss://smart-updown.binom.pw/api/ws`.
|
||||||
|
- GET-ручки работают и без `/api` (`/state`, `/price`, `/balance/...`, `/faucet/...`).
|
||||||
|
- **CORS**: `Access-Control-Allow-Origin: *` — кросс-доменные запросы разрешены.
|
||||||
|
- **Цены SOL** — целое `price × 1e8` (например `101.89000000` = 101.89 USD), строка.
|
||||||
|
- **Фантики** — SPL-токен `decimals = 6`. Все суммы в API — в **базовых юнитах**
|
||||||
|
(целое число, `1 фантик = 1_000_000` юнитов). `humanize` форматирует их в
|
||||||
|
строку с 6 знаками (`"9800000000"` → `"9800.000000"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `GET /state`
|
||||||
|
|
||||||
|
Снимок состояния контракта + фиксированного беттора. Клиент фронта опрашивает
|
||||||
|
эту ручку перед каждой ставкой.
|
||||||
|
|
||||||
|
### Параметры
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
### Ответ (HTTP 200)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
// Прежние поля (НЕ убирать — тестовый фронт их читает).
|
||||||
|
"counter": "81",
|
||||||
|
"nextBetId": "81",
|
||||||
|
"minAmount": "100000000",
|
||||||
|
"maxAmount": "10000000000",
|
||||||
|
"minAmountHuman": "100.000000",
|
||||||
|
"maxAmountHuman": "10000.000000",
|
||||||
|
"multiplierBps": "13000",
|
||||||
|
"expirySeconds": "15",
|
||||||
|
"paused": 0,
|
||||||
|
"admin": "...",
|
||||||
|
"relayer": "...",
|
||||||
|
"vaultBalance": "30020000000",
|
||||||
|
"vaultBalanceHuman": "30020.000000",
|
||||||
|
|
||||||
|
// Новые поля (v2).
|
||||||
|
"bettor": "6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6",
|
||||||
|
"betPercent": 30,
|
||||||
|
"betAmountUnits": "2940000000",
|
||||||
|
"betAmountHuman": "2940.000000",
|
||||||
|
|
||||||
|
"canBet": true,
|
||||||
|
"canBetReasonCode": null,
|
||||||
|
"canBetReason": null,
|
||||||
|
|
||||||
|
"balance": {
|
||||||
|
"sol": 4.99064576,
|
||||||
|
"solLamports": 4990645760,
|
||||||
|
"tokenUnits": "9800000000",
|
||||||
|
"tokenHuman": "9800.000000"
|
||||||
|
},
|
||||||
|
|
||||||
|
"openBet": null,
|
||||||
|
"lastBet": {
|
||||||
|
"id": "80",
|
||||||
|
"status": "closed",
|
||||||
|
"statusCode": 1,
|
||||||
|
"result": "payout_done",
|
||||||
|
"side": "UP",
|
||||||
|
"amountUnits": "2940000000",
|
||||||
|
"amountHuman": "2940.000000",
|
||||||
|
"entryPrice": 10077000000,
|
||||||
|
"exitPrice": 10120000000,
|
||||||
|
"betTime": 1789589360,
|
||||||
|
"expireTime": 1789589375,
|
||||||
|
"retryAfterSeconds": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Коды ошибок
|
||||||
|
- **409** — контракт не инициализирован (`error: "Contract not initialized (relayer: npm run init)"`).
|
||||||
|
Все остальные поля в этом случае НЕ возвращаются.
|
||||||
|
|
||||||
|
### Форма объекта ставки (`openBet` / `lastBet`)
|
||||||
|
|
||||||
|
| поле | тип | описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | string | ID аккаунта (десятичная строка) |
|
||||||
|
| `status` | `"open"` \| `"closed"` | ключевое поле для UI |
|
||||||
|
| `statusCode` | number (0..3) | сырое значение из контракта |
|
||||||
|
| `result` | `null` \| `"payout_done"` \| `"house_won"` \| `"refunded"` | результат закрытой ставки; `null` для открытой |
|
||||||
|
| `side` | `"UP"` \| `"DOWN"` | |
|
||||||
|
| `amountUnits` | string | ставка в базовых юнитах |
|
||||||
|
| `amountHuman` | string | форматированная сумма |
|
||||||
|
| `entryPrice` | number | цена SOL при создании (`×1e8`) |
|
||||||
|
| `exitPrice` | number | цена при закрытии (`×1e8`, 0 для открытой) |
|
||||||
|
| `betTime` | number | unix-time создания (секунды) |
|
||||||
|
| `expireTime` | number | unix-time экспирации (секунды) |
|
||||||
|
| `retryAfterSeconds` | number \| null | для открытой: `ceil((expireTime*1000 - now)/1000)`, мин. 0; для закрытой: `null` |
|
||||||
|
|
||||||
|
### Логика выбора `openBet` / `lastBet`
|
||||||
|
- Если у фиксированного беттора есть ставка со `status === 0` → `openBet = эта ставка`, `lastBet = null`.
|
||||||
|
- Иначе, если есть любые ставки этого беттора → `openBet = null`, `lastBet = ставка с максимальным id`.
|
||||||
|
- Иначе (ставок нет) → `openBet = null`, `lastBet = null`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `GET /price`
|
||||||
|
|
||||||
|
Текущая сырая цена SOL из NATS (`market.price.solusdt`). Для отладки и прикидки UI.
|
||||||
|
|
||||||
|
### Ответ (HTTP 200)
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"symbol": "solusdt",
|
||||||
|
"price": "101.89000000",
|
||||||
|
"ts": 1789589385123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Коды ошибок
|
||||||
|
- **503** — NATS ещё не прислал ни одного тика (`error: "price not available yet"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `POST /wallet`
|
||||||
|
|
||||||
|
Создаёт новый custodial-кошелёк беттора. **НИЧЕГО не наливает** — деньги через `/faucet/{address}`.
|
||||||
|
|
||||||
|
### Тело запроса
|
||||||
|
Пустое.
|
||||||
|
|
||||||
|
### Ответ (HTTP 200)
|
||||||
|
```jsonc
|
||||||
|
{ "address": "7xK9...AbCd" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Адрес автоматически регистрируется в keystore шлюза.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `POST /bet`
|
||||||
|
|
||||||
|
Ставка **за захардкоженного беттора** (`GW_FIXED_BETTOR`). Сумма НЕ передаётся —
|
||||||
|
шлюз считает её сам. Адрес беттора тоже НЕ передаётся.
|
||||||
|
|
||||||
|
### Тело запроса
|
||||||
|
```jsonc
|
||||||
|
{ "side": "up" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Допустимые значения `side`: `"UP"`, `"up"`, `"Up"`, `"0"`, `0` (UP);
|
||||||
|
`"DOWN"`, `"down"`, `"Down"`, `"1"`, `1` (DOWN). Регистр не важен.
|
||||||
|
|
||||||
|
`address` и `amountUnits` в теле **молча игнорируются** (обратная совместимость
|
||||||
|
со старым фронтом) — ставка всегда идёт за `GW_FIXED_BETTOR` и на `GW_BET_PERCENT%`.
|
||||||
|
|
||||||
|
### Ответ (HTTP 200, всегда кроме плохого `side` / не-JSON)
|
||||||
|
|
||||||
|
**Принята** (`accepted: true`):
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"accepted": true,
|
||||||
|
"reasonCode": null,
|
||||||
|
"reason": null,
|
||||||
|
"retryAfterSeconds": 15,
|
||||||
|
|
||||||
|
"betId": "81",
|
||||||
|
"txSig": "5xY...",
|
||||||
|
"amountUnits": "2940000000",
|
||||||
|
"amountHuman": "2940.000000",
|
||||||
|
"side": "UP",
|
||||||
|
"entryPrice": 9856000000,
|
||||||
|
"expireTime": 1789589400,
|
||||||
|
|
||||||
|
// Обратная совместимость со старым фронтом: j.id / j.tx / j.bettor.
|
||||||
|
"id": "81",
|
||||||
|
"tx": "5xY...",
|
||||||
|
"bettor": "6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Отклонена** (`accepted: false`):
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"accepted": false,
|
||||||
|
"reasonCode": "OPEN_BET",
|
||||||
|
"reason": "У вас уже есть открытая ставка — дождитесь её расчёта",
|
||||||
|
"retryAfterSeconds": 12,
|
||||||
|
"betId": null,
|
||||||
|
"txSig": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Коды ошибок
|
||||||
|
- **400** — `side` отсутствует или не распознан (`error: 'side must be 0/"UP" or 1/"DOWN"'`).
|
||||||
|
- **400** — тело не JSON (`error: "invalid JSON body"`).
|
||||||
|
|
||||||
|
Все остальные причины отказа приходят с **HTTP 200** и `accepted: false`.
|
||||||
|
|
||||||
|
### Правила `retryAfterSeconds`
|
||||||
|
| ситуация | значение |
|
||||||
|
|---|---|
|
||||||
|
| принятая ставка | `expirySeconds` из `Global` (15) |
|
||||||
|
| `OPEN_BET` | `ceil((expireTime*1000 - Date.now())/1000)`, минимум `0` |
|
||||||
|
| прочие отказы (`NO_PRICE`, `INSUFFICIENT_SOL`, `INSUFFICIENT_FUNDS`, `PAUSED`, `WALLET_UNKNOWN`, `NOT_INITIALIZED`) | `null` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `GET /balance/{address}`
|
||||||
|
|
||||||
|
Реальные балансы адреса: SOL + фантики в ATA по нашему mint.
|
||||||
|
|
||||||
|
### Параметры пути
|
||||||
|
- `address` — Solana pubkey в base58.
|
||||||
|
|
||||||
|
### Ответ (HTTP 200)
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"sol": 4.99064576,
|
||||||
|
"solLamports": 4990645760,
|
||||||
|
"tokenUnits": "9800000000",
|
||||||
|
"tokenHuman": "9800.000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Коды ошибок
|
||||||
|
- **400** — `address` не валидный Solana pubkey.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `GET /faucet/{address}`
|
||||||
|
|
||||||
|
Налив SOL + фантиков на ПРОИЗВОЛЬНЫЙ адрес (Phantom и т.п.). **Только в test-режиме**:
|
||||||
|
`SMART_MODE=test` И кластер не mainnet/devnet/testnet (проверка по genesis hash).
|
||||||
|
Иначе `403`.
|
||||||
|
|
||||||
|
### Параметры пути
|
||||||
|
- `address` — Solana pubkey в base58.
|
||||||
|
|
||||||
|
### Ответ (HTTP 200)
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"address": "7xK9...AbCd",
|
||||||
|
"sol": 5.0,
|
||||||
|
"solLamports": 5000000000,
|
||||||
|
"tokenUnits": "10000000000",
|
||||||
|
"tokenHuman": "10000.000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Коды ошибок
|
||||||
|
- **403** — не test-режим / публичный кластер / genesis hash неизвестен.
|
||||||
|
- **400** — `address` не валидный Solana pubkey.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `GET /ws` (WebSocket)
|
||||||
|
|
||||||
|
Push-канал цены SOL + жизненного цикла ставки. На подключение сервер шлёт
|
||||||
|
**снапшот текущей цены** (если NATS уже прислал хоть один тик), дальше —
|
||||||
|
каждый тик/событие.
|
||||||
|
|
||||||
|
### Протокол
|
||||||
|
Каждое сообщение — JSON-объект одного из трёх типов (см. ниже). Клиент различает
|
||||||
|
их по полю `type`.
|
||||||
|
|
||||||
|
### Событие `price` — каждый NATS-тик цены
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"type": "price",
|
||||||
|
"symbol": "solusdt",
|
||||||
|
"price": "101.89000000",
|
||||||
|
"ts": 1789589385123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Событие `bet_open` — после успешного `POST /bet`
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"type": "bet_open",
|
||||||
|
"id": "81",
|
||||||
|
"bettor": "6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6",
|
||||||
|
"side": "UP",
|
||||||
|
"amountUnits": "2940000000",
|
||||||
|
"entryPrice": 9856000000,
|
||||||
|
"expireTime": 1789589400
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Событие `bet_closed` — после авто-закрытия планировщиком (`settle.ts`)
|
||||||
|
Приходит, когда сработал `close_bet` (по экспирации + наличию цены в истории).
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"type": "bet_closed",
|
||||||
|
"id": "81",
|
||||||
|
"bettor": "6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6",
|
||||||
|
"exitPrice": 10120000000,
|
||||||
|
"status": 1,
|
||||||
|
"statusName": "payout_done"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| `statusName` | значение `status` |
|
||||||
|
|---|---|
|
||||||
|
| `payout_done` | `1` (игрок выиграл — выплата 1.3× от ставки) |
|
||||||
|
| `house_won` | `2` (казино выиграло — ставка остаётся в ваулте) |
|
||||||
|
| `refunded` | `3` (ставка возвращена — особый случай) |
|
||||||
|
|
||||||
|
### Событие `bet_replay` — п.8 ТЗ: ничья не показывается игроку, раунд перезапускается
|
||||||
|
Если `close_bet` зафиксировал ничью (`exit_price == entry_price`, статус `3`/`refunded`),
|
||||||
|
планировщик НЕ шлёт `bet_closed` — вместо этого он сразу пересоздаёт ставку тем же
|
||||||
|
направлением и размером (цена берётся на момент пересоздания через `getPrice()`)
|
||||||
|
и отправляет это событие.
|
||||||
|
Игрок видит новый раунд с новым `id` и `expireTime` и не узнаёт, что предыдущая
|
||||||
|
ставка была «ничья». Если цена «заморожена» (фид стоит)
|
||||||
|
и ничьи идут подряд, цепочка обрывается после `GW_MAX_REPLAYS` (дефолт `10`) —
|
||||||
|
тогда уходит обычный `bet_closed` со статусом `refunded`.
|
||||||
|
|
||||||
|
> Совместимость: клиент, который знает только `bet_open`/`bet_closed` (например
|
||||||
|
> текущий `test-front`), на `bet_replay` не отреагирует — сообщение просто
|
||||||
|
> игнорируется. Для «тихой» переигровки этого достаточно; чтобы клиент
|
||||||
|
> перерисовал таймер, ему нужно добавить обработку `bet_replay`.
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"type": "bet_replay",
|
||||||
|
"id": "82", // новая ставка в цепочке
|
||||||
|
"prevId": "81", // id ставки, по которой была ничья
|
||||||
|
"bettor": "6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6",
|
||||||
|
"side": "UP",
|
||||||
|
"amountUnits": "2940000000",
|
||||||
|
"entryPrice": 10077000000, // цена уже новая (на момент пересоздания)
|
||||||
|
"expireTime": 1789589390, // свежий expireTime (+15 с от создания)
|
||||||
|
"replayCount": 1 // 1 = первая переигровка, 2 = вторая, ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Поле `replayCount` показывает позицию в цепочке: `1` — первая ничья→переигровка,
|
||||||
|
`2` — вторая подряд, и т. д. После `GW_MAX_REPLAYS` переигровок в той же
|
||||||
|
цепочке — отправляется `bet_closed` со статусом `refunded`, цепочка обрывается.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Коды отказов `canBetReasonCode` (для `/state.canBetReasonCode` и `/bet.reasonCode`)
|
||||||
|
|
||||||
|
Таблица — в **порядке приоритета** проверки: первая сработавшая даёт результат.
|
||||||
|
|
||||||
|
| Код | Условие | Текст по-русски | Когда возникает |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `NOT_INITIALIZED` | контракт не инициализирован (`readGlobal` вернул null) | Контракт не инициализирован | первый запуск без `npm run init` |
|
||||||
|
| `PAUSED` | `paused !== 0` в Global | Ставки приостановлены администратором | админ нажал pause |
|
||||||
|
| `NO_PRICE` | NATS не прислал цену | Нет актуальной цены SOL | NATS недоступен / первый тик не пришёл |
|
||||||
|
| `OPEN_BET` | у фиксированного беттора уже есть открытая ставка | У вас уже есть открытая ставка — дождитесь её расчёта | есть Bet со `status === 0` |
|
||||||
|
| `WALLET_UNKNOWN` | `GW_FIXED_BETTOR` НЕ в keystore шлюза | Кошелёк не зарегистрирован в шлюзе | keystore повреждён / адрес не тот |
|
||||||
|
| `INSUFFICIENT_SOL` | `solLamports < 10_000_000` (0.01 SOL) | Недостаточно SOL на комиссию и ренту | нужен `/faucet/{address}` для пополнения |
|
||||||
|
| `INSUFFICIENT_FUNDS` | `BigInt(tokenUnits) < BigInt(minAmount)` | Недостаточно фантиков для минимальной ставки | нужен `/faucet/{address}` или меньший процент |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Размер ставки
|
||||||
|
|
||||||
|
`betAmountUnits = floor(tokenBalance × GW_BET_PERCENT / 100)` с клампом в
|
||||||
|
`[minAmount, maxAmount]` из Global. Если баланс ≤ 0 или не парсится → `minAmount`
|
||||||
|
(но решение всё равно отвергнет по `INSUFFICIENT_FUNDS`).
|
||||||
|
|
||||||
|
**Пример.** Баланс = `9_800_000_000` юнитов (9800 фантиков), `GW_BET_PERCENT = 30`,
|
||||||
|
`minAmount = 100_000_000`, `maxAmount = 10_000_000_000`:
|
||||||
|
|
||||||
|
```
|
||||||
|
floor(9_800_000_000 × 30 / 100) = floor(2_940_000_000.0) = 2_940_000_000 ← попадает в диапазон
|
||||||
|
betAmountUnits = "2940000000" (2940.000000 фантиков)
|
||||||
|
```
|
||||||
|
|
||||||
|
Считается в `BigInt` (см. `betRules.computeBetAmountUnits`), без float-ошибок.
|
||||||
@@ -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"]
|
||||||
@@ -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-теста.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: smart-updown-relayer
|
||||||
|
type: application
|
||||||
|
version: "9.9.9"
|
||||||
|
appVersion: "9.9.9"
|
||||||
@@ -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 }}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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:
|
||||||
|
# PVC (local-path) создаётся root-владельцем, а контейнер бежит под node (uid 1000).
|
||||||
|
# fsGroup делает смонтированный volume записываемым для группы node — иначе
|
||||||
|
# gateway падает с EACCES при записи keystore новых бетторов.
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 1000
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
{{- 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; chmod 0644 /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_MODE
|
||||||
|
value: {{ .Values.smart.mode | 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: SMART_PRICE_HISTORY_PATH
|
||||||
|
value: {{ .Values.smart.priceHistory | quote }}
|
||||||
|
- name: SMART_PRICE_RETENTION_HOURS
|
||||||
|
value: {{ .Values.smart.priceRetentionHours | quote }}
|
||||||
|
- name: SMART_SETTLE_INTERVAL_MS
|
||||||
|
value: {{ .Values.smart.settleIntervalMs | quote }}
|
||||||
|
- name: GW_PORT
|
||||||
|
value: {{ .Values.port | quote }}
|
||||||
|
- name: GW_BETTOR_KEYSTORE
|
||||||
|
value: {{ .Values.keystorePath | quote }}
|
||||||
|
- name: GW_FIXED_BETTOR
|
||||||
|
value: {{ .Values.smart.fixedBettor | quote }}
|
||||||
|
- name: GW_BET_PERCENT
|
||||||
|
value: {{ .Values.smart.betPercent | quote }}
|
||||||
|
- name: GW_MAX_REPLAYS
|
||||||
|
value: {{ .Values.smart.maxReplays | 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
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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: '6fxySAjTQTkzyyZ7u8EQqv3SyJtv2Ydr6hphquHbkGS9'
|
||||||
|
adminKeypair: '/app/secrets/admin-keypair.json'
|
||||||
|
mode: test
|
||||||
|
allowAirdrop: true
|
||||||
|
minAmount: 100000000
|
||||||
|
maxAmount: 10000000000
|
||||||
|
multiplierBps: 13000
|
||||||
|
expirySeconds: 15
|
||||||
|
startBalance: 10000000000
|
||||||
|
priceHistory: '/app/data/price-history.db'
|
||||||
|
priceRetentionHours: 24
|
||||||
|
settleIntervalMs: 2000
|
||||||
|
# Захардкоженный беттор шлюза: шлюз ставит от его имени на 30% от баланса.
|
||||||
|
# Адрес «bettor-1» из тестового фронта, уже зарегистрирован в keystore
|
||||||
|
# и пополнен (~9800 фантиков, ~5 SOL).
|
||||||
|
fixedBettor: '6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6'
|
||||||
|
betPercent: 30
|
||||||
|
# п.8 ТЗ: лимит переигровок ничьих на одну цепочку ставок (10 по умолчанию).
|
||||||
|
# Цена «заморожена» → каждая ставка рефандится → без лимита цепочка
|
||||||
|
# крутится бесконечно.
|
||||||
|
maxReplays: 10
|
||||||
|
|
||||||
|
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: {}
|
||||||
+120
-18
@@ -10,7 +10,14 @@
|
|||||||
{
|
{
|
||||||
"name": "close_bet",
|
"name": "close_bet",
|
||||||
"discriminator": [
|
"discriminator": [
|
||||||
2
|
185,
|
||||||
|
206,
|
||||||
|
13,
|
||||||
|
184,
|
||||||
|
176,
|
||||||
|
108,
|
||||||
|
140,
|
||||||
|
107
|
||||||
],
|
],
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
@@ -57,15 +64,60 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bettor",
|
"name": "bettor"
|
||||||
"docs": [
|
|
||||||
"CHECK: recipient must be the bet's bettor and is validated in the handler."
|
|
||||||
],
|
|
||||||
"writable": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "clock",
|
"name": "clock",
|
||||||
"address": "SysvarC1ock11111111111111111111111111111111"
|
"address": "SysvarC1ock11111111111111111111111111111111"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "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": [
|
"args": [
|
||||||
@@ -82,7 +134,14 @@
|
|||||||
{
|
{
|
||||||
"name": "create_bet",
|
"name": "create_bet",
|
||||||
"discriminator": [
|
"discriminator": [
|
||||||
1
|
197,
|
||||||
|
42,
|
||||||
|
153,
|
||||||
|
2,
|
||||||
|
59,
|
||||||
|
63,
|
||||||
|
143,
|
||||||
|
246
|
||||||
],
|
],
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
@@ -136,6 +195,24 @@
|
|||||||
{
|
{
|
||||||
"name": "system_program",
|
"name": "system_program",
|
||||||
"address": "11111111111111111111111111111111"
|
"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": [
|
"args": [
|
||||||
@@ -160,7 +237,14 @@
|
|||||||
{
|
{
|
||||||
"name": "initialize",
|
"name": "initialize",
|
||||||
"discriminator": [
|
"discriminator": [
|
||||||
0
|
175,
|
||||||
|
175,
|
||||||
|
109,
|
||||||
|
31,
|
||||||
|
13,
|
||||||
|
152,
|
||||||
|
155,
|
||||||
|
237
|
||||||
],
|
],
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
@@ -214,7 +298,14 @@
|
|||||||
{
|
{
|
||||||
"name": "update",
|
"name": "update",
|
||||||
"discriminator": [
|
"discriminator": [
|
||||||
3
|
219,
|
||||||
|
200,
|
||||||
|
88,
|
||||||
|
176,
|
||||||
|
158,
|
||||||
|
63,
|
||||||
|
253,
|
||||||
|
127
|
||||||
],
|
],
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
@@ -380,10 +471,9 @@
|
|||||||
"types": [
|
"types": [
|
||||||
{
|
{
|
||||||
"name": "Bet",
|
"name": "Bet",
|
||||||
"serialization": "bytemuck",
|
"docs": [
|
||||||
"repr": {
|
"Open bet. `amount` is in token base units (decimals = 6), not lamports."
|
||||||
"kind": "c"
|
],
|
||||||
},
|
|
||||||
"type": {
|
"type": {
|
||||||
"kind": "struct",
|
"kind": "struct",
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -488,10 +578,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Global",
|
"name": "Global",
|
||||||
"serialization": "bytemuck",
|
"docs": [
|
||||||
"repr": {
|
"Global game state.",
|
||||||
"kind": "c"
|
"",
|
||||||
},
|
"All money-related fields are denominated in **SPL-token base units**",
|
||||||
|
"(our \"fanti\" token, decimals = 6), not in lamports:",
|
||||||
|
"`min_amount`/`max_amount` bound a bet amount in token units."
|
||||||
|
],
|
||||||
"type": {
|
"type": {
|
||||||
"kind": "struct",
|
"kind": "struct",
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -541,6 +634,15 @@
|
|||||||
"name": "GLOBAL_SEED",
|
"name": "GLOBAL_SEED",
|
||||||
"type": "bytes",
|
"type": "bytes",
|
||||||
"value": "[103, 108, 111, 98, 97, 108]"
|
"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
@@ -1,8 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Program IDL in camelCase format in order to be used in JS/TS.
|
* Program IDL in camelCase format (see @anchor-lang/core convertIdlToCamelCase).
|
||||||
*
|
* Mirrors relayer/idl/smart_updown.json but with names camelCased, exactly as
|
||||||
* Note that this is only a type helper and is not the actual IDL. The original
|
* the runtime Program instance exposes them (methods/accounts/types).
|
||||||
* IDL can be found at `target/idl/smart_updown.json`.
|
|
||||||
*/
|
*/
|
||||||
export type SmartUpdown = {
|
export type SmartUpdown = {
|
||||||
"address": "9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf",
|
"address": "9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf",
|
||||||
@@ -16,7 +15,14 @@ export type SmartUpdown = {
|
|||||||
{
|
{
|
||||||
"name": "closeBet",
|
"name": "closeBet",
|
||||||
"discriminator": [
|
"discriminator": [
|
||||||
2
|
185,
|
||||||
|
206,
|
||||||
|
13,
|
||||||
|
184,
|
||||||
|
176,
|
||||||
|
108,
|
||||||
|
140,
|
||||||
|
107
|
||||||
],
|
],
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
@@ -63,15 +69,60 @@ export type SmartUpdown = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bettor",
|
"name": "bettor"
|
||||||
"docs": [
|
|
||||||
"CHECK: recipient must be the bet's bettor and is validated in the handler."
|
|
||||||
],
|
|
||||||
"writable": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "clock",
|
"name": "clock",
|
||||||
"address": "SysvarC1ock11111111111111111111111111111111"
|
"address": "SysvarC1ock11111111111111111111111111111111"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vaultToken",
|
||||||
|
"docs": [
|
||||||
|
"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": [
|
"args": [
|
||||||
@@ -88,7 +139,14 @@ export type SmartUpdown = {
|
|||||||
{
|
{
|
||||||
"name": "createBet",
|
"name": "createBet",
|
||||||
"discriminator": [
|
"discriminator": [
|
||||||
1
|
197,
|
||||||
|
42,
|
||||||
|
153,
|
||||||
|
2,
|
||||||
|
59,
|
||||||
|
63,
|
||||||
|
143,
|
||||||
|
246
|
||||||
],
|
],
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
@@ -142,6 +200,24 @@ export type SmartUpdown = {
|
|||||||
{
|
{
|
||||||
"name": "systemProgram",
|
"name": "systemProgram",
|
||||||
"address": "11111111111111111111111111111111"
|
"address": "11111111111111111111111111111111"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bettorToken",
|
||||||
|
"docs": [
|
||||||
|
"Bettor's SPL-token account (our \"fanti\" mint); bet amount is pulled from here."
|
||||||
|
],
|
||||||
|
"writable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vaultToken",
|
||||||
|
"docs": [
|
||||||
|
"Token vault ATA (owner = PDA([GLOBAL_SEED, VAULT_SEED]); bet amounts are escrowed here."
|
||||||
|
],
|
||||||
|
"writable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tokenProgram",
|
||||||
|
"address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"args": [
|
"args": [
|
||||||
@@ -166,7 +242,14 @@ export type SmartUpdown = {
|
|||||||
{
|
{
|
||||||
"name": "initialize",
|
"name": "initialize",
|
||||||
"discriminator": [
|
"discriminator": [
|
||||||
0
|
175,
|
||||||
|
175,
|
||||||
|
109,
|
||||||
|
31,
|
||||||
|
13,
|
||||||
|
152,
|
||||||
|
155,
|
||||||
|
237
|
||||||
],
|
],
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
@@ -220,7 +303,14 @@ export type SmartUpdown = {
|
|||||||
{
|
{
|
||||||
"name": "update",
|
"name": "update",
|
||||||
"discriminator": [
|
"discriminator": [
|
||||||
3
|
219,
|
||||||
|
200,
|
||||||
|
88,
|
||||||
|
176,
|
||||||
|
158,
|
||||||
|
63,
|
||||||
|
253,
|
||||||
|
127
|
||||||
],
|
],
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
@@ -386,10 +476,9 @@ export type SmartUpdown = {
|
|||||||
"types": [
|
"types": [
|
||||||
{
|
{
|
||||||
"name": "bet",
|
"name": "bet",
|
||||||
"serialization": "bytemuck",
|
"docs": [
|
||||||
"repr": {
|
"Open bet. `amount` is in token base units (decimals = 6), not lamports."
|
||||||
"kind": "c"
|
],
|
||||||
},
|
|
||||||
"type": {
|
"type": {
|
||||||
"kind": "struct",
|
"kind": "struct",
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -494,10 +583,13 @@ export type SmartUpdown = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "global",
|
"name": "global",
|
||||||
"serialization": "bytemuck",
|
"docs": [
|
||||||
"repr": {
|
"Global game state.",
|
||||||
"kind": "c"
|
"",
|
||||||
},
|
"All money-related fields are denominated in **SPL-token base units**",
|
||||||
|
"(our \"fanti\" token, decimals = 6), not in lamports:",
|
||||||
|
"`min_amount`/`max_amount` bound a bet amount in token units."
|
||||||
|
],
|
||||||
"type": {
|
"type": {
|
||||||
"kind": "struct",
|
"kind": "struct",
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -547,6 +639,15 @@ export type SmartUpdown = {
|
|||||||
"name": "globalSeed",
|
"name": "globalSeed",
|
||||||
"type": "bytes",
|
"type": "bytes",
|
||||||
"value": "[103, 108, 111, 98, 97, 108]"
|
"value": "[103, 108, 111, 98, 97, 108]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vaultSeed",
|
||||||
|
"docs": [
|
||||||
|
"Vault PDA seed: the token vault lives at PDA([GLOBAL_SEED, VAULT_SEED]).",
|
||||||
|
"It cannot reuse GLOBAL_SEED alone — that PDA address is the `global` state account itself."
|
||||||
|
],
|
||||||
|
"type": "bytes",
|
||||||
|
"value": "[118, 97, 117, 108, 116]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+1258
File diff suppressed because it is too large
Load Diff
@@ -4,19 +4,26 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "ts-node src/check.ts",
|
||||||
"init": "ts-node src/index.ts --init --once",
|
"init": "ts-node src/index.ts --init --once",
|
||||||
"run": "ts-node src/index.ts --once",
|
"run": "ts-node src/index.ts --once",
|
||||||
"loop": "ts-node src/index.ts"
|
"loop": "ts-node src/index.ts",
|
||||||
|
"gateway": "ts-node src/gateway.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anchor-lang/core": "^1.0.0",
|
"@anchor-lang/core": "^1.0.0",
|
||||||
|
"@solana/spl-token": "^0.4.15",
|
||||||
"@solana/web3.js": "^1.98.0",
|
"@solana/web3.js": "^1.98.0",
|
||||||
"bn.js": "^5.2.0"
|
"bn.js": "^5.2.0",
|
||||||
|
"nats": "^2.29.3",
|
||||||
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bn.js": "^5.2.0",
|
"@types/bn.js": "^5.2.0",
|
||||||
"@types/node": "^25.0.0",
|
"@types/node": "^25.0.0",
|
||||||
|
"@types/ws": "^8.5.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Чистые правила расчёта ставки и принятия решения «можно ли ставить».
|
||||||
|
*
|
||||||
|
* Вынесено в отдельный модуль без сетевого ввода-вывода, чтобы можно было
|
||||||
|
* прогнать оффлайн-тестами в `src/check.ts`. Шлюз (`src/gateway.ts`)
|
||||||
|
* собирает входы из Global/keystore/balances/price и зовёт canBetDecision;
|
||||||
|
* ручка `/state` зовёт computeBetAmountUnits отдельно, чтобы показать
|
||||||
|
* «размер, который шлюз возьмёт прямо сейчас» независимо от canBet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Минимальный баланс SOL на бетторе: рента PDA Bet-аккаунта + комиссия за tx. */
|
||||||
|
export const MIN_SOL_LAMPORTS = 10_000_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Размер ставки в токен-юнитах = floor(balance * percent / 100), затем
|
||||||
|
* клампится в [minAmount, maxAmount] (с `minAmount` если баланс ≤ 0 или
|
||||||
|
* не парсится — UI всё равно увидит отказ по нехватке средств в canBet).
|
||||||
|
*
|
||||||
|
* Считаем в BigInt: `Number(tokenUnits) * percent / 100` теряет точность
|
||||||
|
* на 8+ разрядах (балансы до 1e10). floor даёт целочисленное деление BigInt.
|
||||||
|
*/
|
||||||
|
export function computeBetAmountUnits(
|
||||||
|
tokenUnits: string,
|
||||||
|
percent: number,
|
||||||
|
minAmount: number,
|
||||||
|
maxAmount: number
|
||||||
|
): number {
|
||||||
|
let unitsBig: bigint;
|
||||||
|
try {
|
||||||
|
unitsBig = BigInt(tokenUnits);
|
||||||
|
} catch {
|
||||||
|
return minAmount;
|
||||||
|
}
|
||||||
|
if (unitsBig <= 0n) return minAmount;
|
||||||
|
const computed = (unitsBig * BigInt(percent)) / 100n;
|
||||||
|
if (computed < BigInt(minAmount)) return minAmount;
|
||||||
|
if (computed > BigInt(maxAmount)) return maxAmount;
|
||||||
|
return Number(computed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanBetInput {
|
||||||
|
/** 0/1 из Global: paused. */
|
||||||
|
paused: number;
|
||||||
|
/** Прочитан ли Global (readGlobal вернул не-null). */
|
||||||
|
globalPresent: boolean;
|
||||||
|
/** Есть ли открытая ставка у фиксированного беттора (status === STATUS_OPEN). */
|
||||||
|
hasOpenBet: boolean;
|
||||||
|
/** Зарегистрирован ли кошелёк в keystore шлюза. */
|
||||||
|
walletInKeystore: boolean;
|
||||||
|
/** Баланс SOL на бетторе (лампорты). */
|
||||||
|
solLamports: number;
|
||||||
|
/** Баланс фантиков (сырая строка базовых юнитов). */
|
||||||
|
tokenUnits: string;
|
||||||
|
/** Лимиты из Global. */
|
||||||
|
minAmount: number;
|
||||||
|
maxAmount: number;
|
||||||
|
/** Процент от баланса, идущий в ставку (дефолт 30). */
|
||||||
|
betPercent: number;
|
||||||
|
/** getPrice()/currentPriceHuman() отдают не null. */
|
||||||
|
priceAvailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanBetResult {
|
||||||
|
canBet: boolean;
|
||||||
|
/** Машиночитаемый код (null при canBet=true). */
|
||||||
|
reasonCode: string | null;
|
||||||
|
/** Человекочитаемый текст по-русски (null при canBet=true). */
|
||||||
|
reasonText: string | null;
|
||||||
|
/**
|
||||||
|
* Размер, которым шлюз поставит (30% с клампом). При canBet=true —
|
||||||
|
* всегда заполнено значением из computeBetAmountUnits. При canBet=false —
|
||||||
|
* 0 (шлюз не ставит).
|
||||||
|
*/
|
||||||
|
amountUnits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Решение «можно ли ставить». Проверки идут СТРОГО по таблице ТЗ —
|
||||||
|
* первая сработавшая даёт результат, остальные не проверяются. Порядок:
|
||||||
|
* 1) !globalPresent → NOT_INITIALIZED
|
||||||
|
* 2) paused !== 0 → PAUSED
|
||||||
|
* 3) !priceAvailable → NO_PRICE
|
||||||
|
* 4) hasOpenBet → OPEN_BET
|
||||||
|
* 5) !walletInKeystore → WALLET_UNKNOWN
|
||||||
|
* 6) solLamports < 10M → INSUFFICIENT_SOL
|
||||||
|
* 7) tokenUnits < min → INSUFFICIENT_FUNDS
|
||||||
|
* 8) иначе → canBet=true, amountUnits из computeBetAmountUnits.
|
||||||
|
*/
|
||||||
|
export function canBetDecision(input: CanBetInput): CanBetResult {
|
||||||
|
if (!input.globalPresent) {
|
||||||
|
return denied("NOT_INITIALIZED", "Контракт не инициализирован");
|
||||||
|
}
|
||||||
|
if (input.paused !== 0) {
|
||||||
|
return denied("PAUSED", "Ставки приостановлены администратором");
|
||||||
|
}
|
||||||
|
if (!input.priceAvailable) {
|
||||||
|
return denied("NO_PRICE", "Нет актуальной цены SOL");
|
||||||
|
}
|
||||||
|
if (input.hasOpenBet) {
|
||||||
|
return denied("OPEN_BET", "У вас уже есть открытая ставка — дождитесь её расчёта");
|
||||||
|
}
|
||||||
|
if (!input.walletInKeystore) {
|
||||||
|
return denied("WALLET_UNKNOWN", "Кошелёк не зарегистрирован в шлюзе");
|
||||||
|
}
|
||||||
|
if (input.solLamports < MIN_SOL_LAMPORTS) {
|
||||||
|
return denied("INSUFFICIENT_SOL", "Недостаточно SOL на комиссию и ренту");
|
||||||
|
}
|
||||||
|
let tokenBig: bigint;
|
||||||
|
try {
|
||||||
|
tokenBig = BigInt(input.tokenUnits);
|
||||||
|
} catch {
|
||||||
|
return denied("INSUFFICIENT_FUNDS", "Недостаточно фантиков для минимальной ставки");
|
||||||
|
}
|
||||||
|
if (tokenBig < BigInt(input.minAmount)) {
|
||||||
|
return denied("INSUFFICIENT_FUNDS", "Недостаточно фантиков для минимальной ставки");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
canBet: true,
|
||||||
|
reasonCode: null,
|
||||||
|
reasonText: null,
|
||||||
|
amountUnits: computeBetAmountUnits(
|
||||||
|
input.tokenUnits,
|
||||||
|
input.betPercent,
|
||||||
|
input.minAmount,
|
||||||
|
input.maxAmount
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function denied(code: string, text: string): CanBetResult {
|
||||||
|
return { canBet: false, reasonCode: code, reasonText: text, amountUnits: 0 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,881 @@
|
|||||||
|
/**
|
||||||
|
* Оффлайн-проверка релейера (без сети): компиляция и сигнатуры под новый
|
||||||
|
* токеновый IDL.
|
||||||
|
*
|
||||||
|
* 1. PDA-деривации (global/bet/vault) совпадают с седами IDL.
|
||||||
|
* 2. Инструкции create_bet/close_bet содержат bettor_token / vault_token /
|
||||||
|
* token_program с нужными адресами и writable-флагами.
|
||||||
|
* 3. Гейт faucet-ручки: release запрещает на любом кластере, test разрешает
|
||||||
|
* только на непубличных (genesis-hash не mainnet/devnet/testnet), неизвестный
|
||||||
|
* hash запрещён (fail-closed).
|
||||||
|
* 4. Чистые функции авто-закрытия: decodeBet / openBetOf / dueForSettle /
|
||||||
|
* betOpenEvent / betClosedEvent (settle.ts).
|
||||||
|
*/
|
||||||
|
import { Keypair, PublicKey } from "@solana/web3.js";
|
||||||
|
import BN from "bn.js";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
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";
|
||||||
|
import { faucetDenyReason, parseMode } from "./faucetGate";
|
||||||
|
import { DEFAULT_RETENTION_MS, PriceHistory } from "./priceHistory";
|
||||||
|
import {
|
||||||
|
BET_ACCOUNT_SIZE,
|
||||||
|
betClosedEvent,
|
||||||
|
betOpenEvent,
|
||||||
|
betReplayEvent,
|
||||||
|
decodeBet,
|
||||||
|
DecodedBet,
|
||||||
|
dueForSettle,
|
||||||
|
openBetOf,
|
||||||
|
BetAccountEntry,
|
||||||
|
SchedulerCtx,
|
||||||
|
settleOnce,
|
||||||
|
SIDE_UP,
|
||||||
|
SIDE_DOWN,
|
||||||
|
STATUS_OPEN,
|
||||||
|
STATUS_PAYOUT_DONE,
|
||||||
|
STATUS_HOUSE_WON,
|
||||||
|
STATUS_REFUNDED,
|
||||||
|
} from "./settle";
|
||||||
|
import {
|
||||||
|
computeBetAmountUnits,
|
||||||
|
canBetDecision,
|
||||||
|
CanBetInput,
|
||||||
|
MIN_SOL_LAMPORTS,
|
||||||
|
} from "./betRules";
|
||||||
|
import { pickBetViews } from "./gateway";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строит буфер Bet-аккаунта (96 байт) для оффлайн-тестов settle.ts.
|
||||||
|
* Дискриминатор (0..7) забит нулями — decodeBet его не читает, остальные
|
||||||
|
* поля — по контракту.
|
||||||
|
*/
|
||||||
|
function makeBetBuffer(opts: {
|
||||||
|
amount?: number;
|
||||||
|
betTime?: number;
|
||||||
|
expireTime?: number;
|
||||||
|
entryPrice?: number;
|
||||||
|
exitPrice?: number;
|
||||||
|
side?: number;
|
||||||
|
status?: number;
|
||||||
|
bettor?: PublicKey;
|
||||||
|
}): Buffer {
|
||||||
|
const buf = Buffer.alloc(BET_ACCOUNT_SIZE);
|
||||||
|
// Discriminator 0..7: zeros
|
||||||
|
if (opts.amount !== undefined) buf.writeBigUInt64LE(BigInt(opts.amount), 8);
|
||||||
|
if (opts.betTime !== undefined) buf.writeBigInt64LE(BigInt(opts.betTime), 16);
|
||||||
|
if (opts.expireTime !== undefined) buf.writeBigInt64LE(BigInt(opts.expireTime), 24);
|
||||||
|
if (opts.entryPrice !== undefined) buf.writeBigUInt64LE(BigInt(opts.entryPrice), 32);
|
||||||
|
if (opts.exitPrice !== undefined) buf.writeBigUInt64LE(BigInt(opts.exitPrice), 40);
|
||||||
|
if (opts.side !== undefined) buf.writeBigUInt64LE(BigInt(opts.side), 48);
|
||||||
|
if (opts.status !== undefined) buf.writeBigUInt64LE(BigInt(opts.status), 56);
|
||||||
|
if (opts.bettor !== undefined) {
|
||||||
|
const bytes = opts.bettor.toBytes();
|
||||||
|
Buffer.from(bytes).copy(buf, 64);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Оборачивает сырой буфер в BetAccountEntry с заданным id. */
|
||||||
|
function betEntry(id: number, buf: Buffer): BetAccountEntry {
|
||||||
|
return { pubkey: Keypair.generate().publicKey, account: { data: buf }, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
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-состав)");
|
||||||
|
|
||||||
|
// 3. Гейт faucet-ручки (тест/release + genesis hash) — чистая логика из faucetGate.ts.
|
||||||
|
check(
|
||||||
|
faucetDenyReason({
|
||||||
|
mode: "release",
|
||||||
|
genesisHash: "BDcnjqkaZ1uPQfrEEAYtBzUegVCdvHfGF7n4D2KC5jiG",
|
||||||
|
}) !== null,
|
||||||
|
"faucetGate: release запрещает при localnet genesis"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
faucetDenyReason({
|
||||||
|
mode: "test",
|
||||||
|
genesisHash: "BDcnjqkaZ1uPQfrEEAYtBzUegVCdvHfGF7n4D2KC5jiG",
|
||||||
|
}) === null,
|
||||||
|
"faucetGate: test разрешает при localnet genesis"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
faucetDenyReason({
|
||||||
|
mode: "test",
|
||||||
|
genesisHash: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
|
||||||
|
}) !== null,
|
||||||
|
"faucetGate: test запрещает при mainnet genesis"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
faucetDenyReason({
|
||||||
|
mode: "test",
|
||||||
|
genesisHash: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG",
|
||||||
|
}) !== null,
|
||||||
|
"faucetGate: test запрещает при devnet genesis"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
faucetDenyReason({ mode: "test", genesisHash: null }) !== null,
|
||||||
|
"faucetGate: test запрещает при неизвестном genesis"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
parseMode(undefined) === "release" &&
|
||||||
|
parseMode("prod") === "release" &&
|
||||||
|
parseMode("") === "release",
|
||||||
|
"parseMode: неизвестное значение = release"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. PriceHistory (SQLite): пишется каждый NATS-тик цены, читается в
|
||||||
|
// /close для фиксированной цены на момент expire_time. Тесты гоняются
|
||||||
|
// на РЕАЛЬНОЙ БД в /tmp (файл удаляется до и после).
|
||||||
|
{
|
||||||
|
const dbPath = path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`price-history-check-${process.pid}-${Date.now()}.db`
|
||||||
|
);
|
||||||
|
fs.rmSync(dbPath, { force: true });
|
||||||
|
const h = new PriceHistory(dbPath);
|
||||||
|
try {
|
||||||
|
// Тест 1: пишем точку, читаем её же.
|
||||||
|
h.record(1000, 111);
|
||||||
|
check(
|
||||||
|
h.priceAtOrBefore(1000) === 111,
|
||||||
|
"priceHistory: пишет точку и читает её"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Тест 4: до самой первой точки — пусто.
|
||||||
|
check(
|
||||||
|
h.priceAtOrBefore(999) === null,
|
||||||
|
"priceHistory: до первой точки = null"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Тесты 2/3/5: две точки 1000→111 и 5000→222 — разные режимы выборки.
|
||||||
|
h.record(5000, 222);
|
||||||
|
check(
|
||||||
|
h.priceAtOrBefore(3000) === 111,
|
||||||
|
"priceHistory: отдаёт предыдущую точку между записями"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
h.priceAtOrBefore(4999) === 111,
|
||||||
|
"priceHistory: НЕ отдаёт цену позже момента"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
h.priceAtOrBefore(5000) === 222,
|
||||||
|
"priceHistory: ровно на точке отдаёт эту точку"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Тест 6: повтор той же цены (последняя записанная = 222) — отказ.
|
||||||
|
const before6 = h.count();
|
||||||
|
const r6 = h.record(6000, 222);
|
||||||
|
check(
|
||||||
|
r6 === false && h.count() === before6,
|
||||||
|
"priceHistory: повтор той же цены не пишет строку"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Тест 7: изменившаяся цена — пишется.
|
||||||
|
const before7 = h.count();
|
||||||
|
const r7 = h.record(7000, 333);
|
||||||
|
check(
|
||||||
|
r7 === true && h.count() === before7 + 1,
|
||||||
|
"priceHistory: изменившаяся цена пишет строку"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Тест 8: 3 точки (1000/5000/7000), prune(now=7000, retention=1000).
|
||||||
|
// cutoff = 7000 - 1000 = 6000 → удаляются 1000 и 5000, остаётся 7000
|
||||||
|
// (ровно 2 удалено, ровно сколько старше).
|
||||||
|
const removed = h.prune(7000, 1000);
|
||||||
|
check(
|
||||||
|
removed === 2 && h.priceAtOrBefore(3000) === null,
|
||||||
|
"priceHistory: prune удаляет старше ретеншена"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Тест 9: после prune свежая точка (7000 → 333) всё ещё читается.
|
||||||
|
check(
|
||||||
|
h.priceAtOrBefore(7000) === 333,
|
||||||
|
"priceHistory: prune не трогает свежие"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
h.close();
|
||||||
|
fs.rmSync(dbPath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест 10: дефолтный ретеншен = 24 часа.
|
||||||
|
check(
|
||||||
|
DEFAULT_RETENTION_MS === 24 * 60 * 60 * 1000,
|
||||||
|
"priceHistory: ретеншен по умолчанию = 24 часа"
|
||||||
|
);
|
||||||
|
|
||||||
|
// === 5. Чистые функции авто-закрытия (settle.ts) ===
|
||||||
|
|
||||||
|
// Тест: decodeBet разбирает реальные байты.
|
||||||
|
{
|
||||||
|
const bettor = Keypair.generate().publicKey;
|
||||||
|
const buf = makeBetBuffer({
|
||||||
|
amount: 100_000_000,
|
||||||
|
betTime: 1_700_000_000,
|
||||||
|
expireTime: 1_700_000_015,
|
||||||
|
entryPrice: 10_077_000_000,
|
||||||
|
exitPrice: 0,
|
||||||
|
side: 1, // DOWN
|
||||||
|
status: 0, // OPEN
|
||||||
|
bettor,
|
||||||
|
});
|
||||||
|
const d = decodeBet(buf);
|
||||||
|
check(d.amount === 100_000_000, "decodeBet: amount");
|
||||||
|
check(d.betTime === 1_700_000_000, "decodeBet: betTime");
|
||||||
|
check(d.expireTime === 1_700_000_015, "decodeBet: expireTime");
|
||||||
|
check(d.entryPrice === 10_077_000_000, "decodeBet: entryPrice");
|
||||||
|
check(d.exitPrice === 0, "decodeBet: exitPrice");
|
||||||
|
check(d.side === 1, "decodeBet: side");
|
||||||
|
check(d.status === 0, "decodeBet: status");
|
||||||
|
check(d.bettor.toBase58() === bettor.toBase58(), "decodeBet: bettor (base58 round-trip)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: openBetOf находит открытую.
|
||||||
|
{
|
||||||
|
const target = Keypair.generate().publicKey;
|
||||||
|
const other = Keypair.generate().publicKey;
|
||||||
|
const accounts: BetAccountEntry[] = [
|
||||||
|
betEntry(0, makeBetBuffer({ status: 1, bettor: target })),
|
||||||
|
betEntry(1, makeBetBuffer({ status: 0, bettor: target })), // <-- первая открытая для target
|
||||||
|
betEntry(2, makeBetBuffer({ status: 0, bettor: other })),
|
||||||
|
];
|
||||||
|
const r = openBetOf(target.toBase58(), accounts);
|
||||||
|
check(r !== null && r.id === 1, "openBetOf находит открытую: вернул её id");
|
||||||
|
check(r !== null && r.id !== 0, "openBetOf находит открытую: не нулевую");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: openBetOf игнорирует закрытые.
|
||||||
|
{
|
||||||
|
const target = Keypair.generate().publicKey;
|
||||||
|
const accounts: BetAccountEntry[] = [
|
||||||
|
betEntry(0, makeBetBuffer({ status: 1, bettor: target })),
|
||||||
|
betEntry(1, makeBetBuffer({ status: 2, bettor: target })),
|
||||||
|
betEntry(2, makeBetBuffer({ status: 1, bettor: target })),
|
||||||
|
];
|
||||||
|
const r = openBetOf(target.toBase58(), accounts);
|
||||||
|
check(r === null, "openBetOf игнорирует закрытые: вернул null");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: dueForSettle берёт только просроченные открытые.
|
||||||
|
{
|
||||||
|
const now = 2_000_000_000; // секунды
|
||||||
|
const nowMs = now * 1000;
|
||||||
|
const accounts: BetAccountEntry[] = [
|
||||||
|
// открытый просроченный → должен попасть
|
||||||
|
betEntry(0, makeBetBuffer({ status: 0, expireTime: now - 10 })),
|
||||||
|
// открытый свежий → не должен
|
||||||
|
betEntry(1, makeBetBuffer({ status: 0, expireTime: now + 10 })),
|
||||||
|
// закрытый просроченный → не должен
|
||||||
|
betEntry(2, makeBetBuffer({ status: 1, expireTime: now - 10 })),
|
||||||
|
// закрытый свежий → не должен
|
||||||
|
betEntry(3, makeBetBuffer({ status: 2, expireTime: now + 10 })),
|
||||||
|
];
|
||||||
|
const out = dueForSettle(accounts, nowMs);
|
||||||
|
check(out.length === 1, "dueForSettle берёт только просроченные открытые: ровно 1");
|
||||||
|
check(out.length === 1 && out[0].id === 0, "dueForSettle берёт только просроченные открытые: id=0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: dueForSettle уважает границу expire.
|
||||||
|
{
|
||||||
|
const nowMs = 1_700_000_015_000;
|
||||||
|
const accounts: BetAccountEntry[] = [
|
||||||
|
betEntry(0, makeBetBuffer({ status: 0, expireTime: 1_700_000_015 })), // ровно now
|
||||||
|
];
|
||||||
|
const out = dueForSettle(accounts, nowMs);
|
||||||
|
check(out.length === 1 && out[0].id === 0, "dueForSettle уважает границу expire: ровно на nowMs включён (<=)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: betOpenEvent форма.
|
||||||
|
{
|
||||||
|
const bettor = Keypair.generate().publicKey;
|
||||||
|
const bet = decodeBet(
|
||||||
|
makeBetBuffer({
|
||||||
|
amount: 100_000_000,
|
||||||
|
expireTime: 1_789_299_794,
|
||||||
|
entryPrice: 10_077_000_000,
|
||||||
|
side: 0, // UP
|
||||||
|
status: 0,
|
||||||
|
bettor,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const ev = betOpenEvent(bet, 14) as Record<string, unknown>;
|
||||||
|
check(ev.type === "bet_open", "betOpenEvent форма: type");
|
||||||
|
check(ev.id === "14", "betOpenEvent форма: id (строка)");
|
||||||
|
check(ev.side === "UP", 'betOpenEvent форма: side="UP"');
|
||||||
|
check(ev.amountUnits === "100000000", "betOpenEvent форма: amountUnits (строка)");
|
||||||
|
check(typeof ev.entryPrice === "number" && ev.entryPrice === 10_077_000_000, "betOpenEvent форма: entryPrice (число)");
|
||||||
|
check(typeof ev.expireTime === "number" && ev.expireTime === 1_789_299_794, "betOpenEvent форма: expireTime (число, секунды)");
|
||||||
|
check(typeof ev.bettor === "string" && (ev.bettor as string) === bettor.toBase58(), "betOpenEvent форма: bettor (base58)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: betClosedEvent форма (payout_done).
|
||||||
|
{
|
||||||
|
const bettor = Keypair.generate().publicKey;
|
||||||
|
const bet = decodeBet(makeBetBuffer({ status: STATUS_PAYOUT_DONE, bettor }));
|
||||||
|
const ev = betClosedEvent(bet, 14, 9_977_000_000, STATUS_PAYOUT_DONE) as Record<string, unknown>;
|
||||||
|
check(ev.type === "bet_closed", "betClosedEvent форма: type");
|
||||||
|
check(ev.id === "14", "betClosedEvent форма: id");
|
||||||
|
check(typeof ev.exitPrice === "number" && ev.exitPrice === 9_977_000_000, "betClosedEvent форма: exitPrice (число)");
|
||||||
|
check(ev.status === 1, "betClosedEvent форма: status (число)");
|
||||||
|
check(ev.statusName === "payout_done", 'betClosedEvent форма: statusName==="payout_done"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: betClosedEvent house_won.
|
||||||
|
{
|
||||||
|
const bettor = Keypair.generate().publicKey;
|
||||||
|
const bet = decodeBet(makeBetBuffer({ status: STATUS_HOUSE_WON, bettor }));
|
||||||
|
const ev = betClosedEvent(bet, 7, 9_500_000_000, STATUS_HOUSE_WON) as Record<string, unknown>;
|
||||||
|
check(ev.statusName === "house_won", 'betClosedEvent house_won: statusName==="house_won"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: betClosedEvent refunded.
|
||||||
|
{
|
||||||
|
const bettor = Keypair.generate().publicKey;
|
||||||
|
const bet = decodeBet(makeBetBuffer({ status: STATUS_REFUNDED, bettor }));
|
||||||
|
const ev = betClosedEvent(bet, 21, 10_112_000_000, STATUS_REFUNDED) as Record<string, unknown>;
|
||||||
|
check(ev.status === 3, "betClosedEvent refunded: status (число 3)");
|
||||||
|
check(ev.statusName === "refunded", 'betClosedEvent refunded: statusName==="refunded"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: betReplayEvent форма (п.8 ТЗ — переигровка ничьей).
|
||||||
|
{
|
||||||
|
const bettor = Keypair.generate().publicKey;
|
||||||
|
const bet = decodeBet(
|
||||||
|
makeBetBuffer({
|
||||||
|
amount: 2_940_000_000,
|
||||||
|
expireTime: 1_789_299_794,
|
||||||
|
entryPrice: 10_077_000_000,
|
||||||
|
side: 1, // DOWN
|
||||||
|
status: 0,
|
||||||
|
bettor,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const ev = betReplayEvent(bet, 42, 41, 3) as Record<string, unknown>;
|
||||||
|
check(ev.type === "bet_replay", 'betReplayEvent форма: type="bet_replay"');
|
||||||
|
check(ev.id === "42", "betReplayEvent форма: id (строка)");
|
||||||
|
check(ev.prevId === "41", "betReplayEvent форма: prevId (строка, id исходной ставки цепочки)");
|
||||||
|
check(ev.side === "DOWN", 'betReplayEvent форма: side="DOWN"');
|
||||||
|
check(ev.amountUnits === "2940000000", "betReplayEvent форма: amountUnits (строка)");
|
||||||
|
check(typeof ev.entryPrice === "number" && ev.entryPrice === 10_077_000_000, "betReplayEvent форма: entryPrice (число)");
|
||||||
|
check(typeof ev.expireTime === "number" && ev.expireTime === 1_789_299_794, "betReplayEvent форма: expireTime новой ставки");
|
||||||
|
check(ev.replayCount === 3, "betReplayEvent форма: replayCount (число)");
|
||||||
|
check(typeof ev.bettor === "string" && (ev.bettor as string) === bettor.toBase58(), "betReplayEvent форма: bettor (base58)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock-каркас для settleOnce: один due-bet, close/fetch подменены
|
||||||
|
* заглушками, broadcast/повторные вызовы hook'ов собираются. Используется
|
||||||
|
* для ветвей п.8 (переигровка ничьих).
|
||||||
|
*/
|
||||||
|
async function runSettleOnce(opts: {
|
||||||
|
afterStatus: number;
|
||||||
|
bettor: PublicKey;
|
||||||
|
side?: number;
|
||||||
|
amount?: number;
|
||||||
|
entryPrice?: number;
|
||||||
|
exitPrice?: number;
|
||||||
|
replayNewBet?: DecodedBet | null;
|
||||||
|
replayNewId?: number;
|
||||||
|
replayBetThrow?: Error;
|
||||||
|
withReplay?: boolean;
|
||||||
|
maxReplays?: number;
|
||||||
|
preCounts?: Map<number, number>;
|
||||||
|
}): Promise<{ broadcasts: Array<Record<string, unknown>>; replayCalls: Array<{ side: number; amount: number; bettor: PublicKey }>; counts: Map<number, number> }> {
|
||||||
|
const broadcasts: Array<Record<string, unknown>> = [];
|
||||||
|
const replayCalls: Array<{ side: number; amount: number; bettor: PublicKey }> = [];
|
||||||
|
const counts = opts.preCounts ?? new Map<number, number>();
|
||||||
|
const nowSec = 2_000_000_000;
|
||||||
|
const nowMs = nowSec * 1000;
|
||||||
|
const amount = opts.amount ?? 2_940_000_000;
|
||||||
|
const side = opts.side ?? SIDE_UP;
|
||||||
|
const entryPrice = opts.entryPrice ?? 100_000_000;
|
||||||
|
const exitPrice = opts.exitPrice ?? 100_000_000;
|
||||||
|
const bettor = opts.bettor;
|
||||||
|
const newId = opts.replayNewId ?? 1;
|
||||||
|
const entry = betEntry(0, makeBetBuffer({
|
||||||
|
amount, side, status: 0, expireTime: nowSec, entryPrice, exitPrice: 0, bettor,
|
||||||
|
}));
|
||||||
|
const afterFetch = async (id: number): Promise<DecodedBet | null> => {
|
||||||
|
if (id === 0) {
|
||||||
|
return decodeBet(
|
||||||
|
makeBetBuffer({
|
||||||
|
amount, side, status: opts.afterStatus, expireTime: nowSec,
|
||||||
|
entryPrice, exitPrice, bettor,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (id === newId && opts.replayNewBet !== undefined) {
|
||||||
|
return opts.replayNewBet;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const replayBet = opts.withReplay
|
||||||
|
? async (args: { side: number; amount: number; bettor: PublicKey }) => {
|
||||||
|
replayCalls.push(args);
|
||||||
|
if (opts.replayBetThrow) throw opts.replayBetThrow;
|
||||||
|
return { id: new BN(newId), tx: "mock-replay-tx" };
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const ctx: SchedulerCtx = {
|
||||||
|
priceAtOrBefore: () => exitPrice,
|
||||||
|
closeBet: async () => "mock-close-tx",
|
||||||
|
broadcast: (msg) => broadcasts.push(msg),
|
||||||
|
fetchBet: afterFetch,
|
||||||
|
loadAllBets: async () => ({ entries: [entry], unmapped: 0 }),
|
||||||
|
nowMs: () => nowMs,
|
||||||
|
maxReplays: opts.maxReplays,
|
||||||
|
replayCounts: counts,
|
||||||
|
replayBet,
|
||||||
|
};
|
||||||
|
await settleOnce(ctx);
|
||||||
|
return { broadcasts, replayCalls, counts };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: settleOnce, ничья + хук задан → bet_replay (а не bet_closed).
|
||||||
|
{
|
||||||
|
const bettor = Keypair.generate().publicKey;
|
||||||
|
const replayBetNew = decodeBet(
|
||||||
|
makeBetBuffer({
|
||||||
|
amount: 2_940_000_000,
|
||||||
|
side: 0,
|
||||||
|
status: 0,
|
||||||
|
expireTime: 2_000_000_999, // свежий expireTime новой ставки
|
||||||
|
entryPrice: 10_077_000_000,
|
||||||
|
exitPrice: 0,
|
||||||
|
bettor,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { broadcasts, replayCalls, counts } = await runSettleOnce({
|
||||||
|
afterStatus: STATUS_REFUNDED,
|
||||||
|
bettor,
|
||||||
|
side: SIDE_UP,
|
||||||
|
withReplay: true,
|
||||||
|
replayNewId: 42,
|
||||||
|
replayNewBet: replayBetNew,
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
broadcasts.length === 1 && broadcasts[0].type === "bet_replay",
|
||||||
|
"settleOnce: ничья → bet_replay, не bet_closed"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
broadcasts.length === 1 && broadcasts[0].id === "42",
|
||||||
|
"settleOnce: ничья → bet_replay, id новой ставки"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
broadcasts.length === 1 && broadcasts[0].prevId === "0",
|
||||||
|
"settleOnce: ничья → bet_replay, prevId = id исходной"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
broadcasts.length === 1 && broadcasts[0].replayCount === 1,
|
||||||
|
"settleOnce: ничья → bet_replay, replayCount=1 на первой переигровке"
|
||||||
|
);
|
||||||
|
check(replayCalls.length === 1, "settleOnce: ничья → replayBet вызван 1 раз");
|
||||||
|
check(
|
||||||
|
replayCalls.length === 1 &&
|
||||||
|
replayCalls[0].side === SIDE_UP &&
|
||||||
|
replayCalls[0].amount === 2_940_000_000 &&
|
||||||
|
replayCalls[0].bettor.toBase58() === bettor.toBase58(),
|
||||||
|
"settleOnce: ничья → replayBet вызван с тем же размером/направлением/беттором"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
counts.get(42) === 1 && !counts.has(0),
|
||||||
|
"settleOnce: ничья → счётчик передан с id=0 на id=42 (старый удалён)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: settleOnce, ничья БЕЗ хука → bet_closed (refunded), как раньше.
|
||||||
|
{
|
||||||
|
const bettor = Keypair.generate().publicKey;
|
||||||
|
const { broadcasts, replayCalls } = await runSettleOnce({
|
||||||
|
afterStatus: STATUS_REFUNDED,
|
||||||
|
bettor,
|
||||||
|
withReplay: false,
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
broadcasts.length === 1 && broadcasts[0].type === "bet_closed",
|
||||||
|
"settleOnce: ничья без хука → bet_closed, не bet_replay"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
broadcasts.length === 1 && broadcasts[0].status === STATUS_REFUNDED,
|
||||||
|
"settleOnce: ничья без хука → bet_closed со status=3 (refunded)"
|
||||||
|
);
|
||||||
|
check(replayCalls.length === 0, "settleOnce: ничья без хука → replayBet не вызывался");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: settleOnce, лимит переигровок исчерпан → bet_closed (refunded).
|
||||||
|
{
|
||||||
|
const bettor = Keypair.generate().publicKey;
|
||||||
|
const counts = new Map<number, number>([[0, 10]]); // 10 — лимит
|
||||||
|
const { broadcasts, replayCalls } = await runSettleOnce({
|
||||||
|
afterStatus: STATUS_REFUNDED,
|
||||||
|
bettor,
|
||||||
|
withReplay: true,
|
||||||
|
maxReplays: 10,
|
||||||
|
preCounts: counts,
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
broadcasts.length === 1 && broadcasts[0].type === "bet_closed",
|
||||||
|
"settleOnce: лимит переигровок → bet_closed, не bet_replay"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
broadcasts.length === 1 && broadcasts[0].status === STATUS_REFUNDED,
|
||||||
|
"settleOnce: лимит переигровок → bet_closed со status=3"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
!counts.has(0),
|
||||||
|
"settleOnce: лимит переигровок → счётчик по id=0 удалён"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
replayCalls.length === 0,
|
||||||
|
"settleOnce: лимит переигровок → replayBet НЕ вызван"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: settleOnce, выигрыш → bet_closed (PAYOUT_DONE), без переигровки.
|
||||||
|
{
|
||||||
|
const bettor = Keypair.generate().publicKey;
|
||||||
|
const { broadcasts, replayCalls } = await runSettleOnce({
|
||||||
|
afterStatus: STATUS_PAYOUT_DONE,
|
||||||
|
bettor,
|
||||||
|
withReplay: true,
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
broadcasts.length === 1 && broadcasts[0].type === "bet_closed",
|
||||||
|
"settleOnce: PAYOUT_DONE → bet_closed, не bet_replay"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
broadcasts.length === 1 && broadcasts[0].status === STATUS_PAYOUT_DONE,
|
||||||
|
"settleOnce: PAYOUT_DONE → status=1"
|
||||||
|
);
|
||||||
|
check(replayCalls.length === 0, "settleOnce: PAYOUT_DONE → replayBet не вызван");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест: settleOnce, проигрыш → bet_closed (HOUSE_WON), без переигровки.
|
||||||
|
{
|
||||||
|
const bettor = Keypair.generate().publicKey;
|
||||||
|
const { broadcasts, replayCalls } = await runSettleOnce({
|
||||||
|
afterStatus: STATUS_HOUSE_WON,
|
||||||
|
bettor,
|
||||||
|
withReplay: true,
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
broadcasts.length === 1 && broadcasts[0].type === "bet_closed",
|
||||||
|
"settleOnce: HOUSE_WON → bet_closed, не bet_replay"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
broadcasts.length === 1 && broadcasts[0].status === STATUS_HOUSE_WON,
|
||||||
|
"settleOnce: HOUSE_WON → status=2"
|
||||||
|
);
|
||||||
|
check(replayCalls.length === 0, "settleOnce: HOUSE_WON → replayBet не вызван");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Санити-проверка: STATUS_OPEN == 0 для совместимости с on-chain контрактом.
|
||||||
|
check(STATUS_OPEN === 0, "settle: STATUS_OPEN === 0 (on-chain OPEN)");
|
||||||
|
|
||||||
|
// === 6. Чистые функции правил ставки (betRules.ts) ===
|
||||||
|
|
||||||
|
// Санити: MIN_SOL_LAMPORTS = 10_000_000 (0.01 SOL).
|
||||||
|
check(MIN_SOL_LAMPORTS === 10_000_000, "betRules: MIN_SOL_LAMPORTS = 10_000_000 (0.01 SOL)");
|
||||||
|
|
||||||
|
// computeBetAmountUnits — базовый случай 30% от 9800 фантиков.
|
||||||
|
check(
|
||||||
|
computeBetAmountUnits("9800000000", 30, 100_000_000, 10_000_000_000) === 2_940_000_000,
|
||||||
|
"betAmount: 30% базовый (9800 фантиков → 2940)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// computeBetAmountUnits — кламп снизу (200 фантиков * 30% = 60 < 100 = min).
|
||||||
|
check(
|
||||||
|
computeBetAmountUnits("200000000", 30, 100_000_000, 10_000_000_000) === 100_000_000,
|
||||||
|
"betAmount: кламп снизу (60 < 100 → 100)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// computeBetAmountUnits — кламп сверху (100000 фантиков * 30% = 30000 > 10000 = max).
|
||||||
|
check(
|
||||||
|
computeBetAmountUnits("100000000000", 30, 100_000_000, 10_000_000_000) === 10_000_000_000,
|
||||||
|
"betAmount: кламп сверху (30000 > 10000 → 10000)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// computeBetAmountUnits — ноль и мусор → min.
|
||||||
|
check(
|
||||||
|
computeBetAmountUnits("0", 30, 100_000_000, 10_000_000_000) === 100_000_000,
|
||||||
|
"betAmount: ноль → min"
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
computeBetAmountUnits("abc", 30, 100_000_000, 10_000_000_000) === 100_000_000,
|
||||||
|
"betAmount: мусор → min"
|
||||||
|
);
|
||||||
|
|
||||||
|
// computeBetAmountUnits — без float-ошибок: 333.333333 * 30 = 9999.99999,
|
||||||
|
// integer division даёт floor = 99999999 (НЕ 99999999.9, как дал бы float).
|
||||||
|
check(
|
||||||
|
computeBetAmountUnits("333333333", 30, 1, 10_000_000_000) === 99_999_999,
|
||||||
|
"betAmount: floor без float-ошибок (333333333 * 30 / 100 = 99999999)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// canBet — все поля «хорошие» → canBet=true + amountUnits = 2940000000.
|
||||||
|
{
|
||||||
|
const r = canBetDecision(goodCanBet());
|
||||||
|
check(r.canBet === true, "canBet: всё ок → canBet=true");
|
||||||
|
check(r.reasonCode === null, "canBet: всё ок → reasonCode=null");
|
||||||
|
check(r.reasonText === null, "canBet: всё ок → reasonText=null");
|
||||||
|
check(r.amountUnits === 2_940_000_000, "canBet: всё ок → amountUnits=2940000000");
|
||||||
|
}
|
||||||
|
|
||||||
|
// canBet — пауза.
|
||||||
|
{
|
||||||
|
const input = goodCanBet();
|
||||||
|
input.paused = 1;
|
||||||
|
const r = canBetDecision(input);
|
||||||
|
check(r.canBet === false, "canBet: пауза → canBet=false");
|
||||||
|
check(r.reasonCode === "PAUSED", 'canBet: пауза → reasonCode="PAUSED"');
|
||||||
|
check(r.reasonText === "Ставки приостановлены администратором", "canBet: пауза → reasonText по-русски");
|
||||||
|
}
|
||||||
|
|
||||||
|
// canBet — открытая ставка.
|
||||||
|
{
|
||||||
|
const input = goodCanBet();
|
||||||
|
input.hasOpenBet = true;
|
||||||
|
const r = canBetDecision(input);
|
||||||
|
check(r.canBet === false, "canBet: открытая ставка → canBet=false");
|
||||||
|
check(r.reasonCode === "OPEN_BET", 'canBet: открытая ставка → reasonCode="OPEN_BET"');
|
||||||
|
check(
|
||||||
|
r.reasonText === "У вас уже есть открытая ставка — дождитесь её расчёта",
|
||||||
|
"canBet: открытая ставка → reasonText по-русски"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// canBet — нет SOL.
|
||||||
|
{
|
||||||
|
const input = goodCanBet();
|
||||||
|
input.solLamports = 0;
|
||||||
|
const r = canBetDecision(input);
|
||||||
|
check(r.canBet === false, "canBet: нет SOL → canBet=false");
|
||||||
|
check(r.reasonCode === "INSUFFICIENT_SOL", 'canBet: нет SOL → reasonCode="INSUFFICIENT_SOL"');
|
||||||
|
check(
|
||||||
|
r.reasonText === "Недостаточно SOL на комиссию и ренту",
|
||||||
|
"canBet: нет SOL → reasonText по-русски"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// canBet — нет фантиков (10000000 < min 100000000).
|
||||||
|
{
|
||||||
|
const input = goodCanBet();
|
||||||
|
input.tokenUnits = "10000000";
|
||||||
|
const r = canBetDecision(input);
|
||||||
|
check(r.canBet === false, "canBet: нет фантиков → canBet=false");
|
||||||
|
check(r.reasonCode === "INSUFFICIENT_FUNDS", 'canBet: нет фантиков → reasonCode="INSUFFICIENT_FUNDS"');
|
||||||
|
check(
|
||||||
|
r.reasonText === "Недостаточно фантиков для минимальной ставки",
|
||||||
|
"canBet: нет фантиков → reasonText по-русски"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// canBet — нет цены.
|
||||||
|
{
|
||||||
|
const input = goodCanBet();
|
||||||
|
input.priceAvailable = false;
|
||||||
|
const r = canBetDecision(input);
|
||||||
|
check(r.canBet === false, "canBet: нет цены → canBet=false");
|
||||||
|
check(r.reasonCode === "NO_PRICE", 'canBet: нет цены → reasonCode="NO_PRICE"');
|
||||||
|
check(r.reasonText === "Нет актуальной цены SOL", "canBet: нет цены → reasonText по-русски");
|
||||||
|
}
|
||||||
|
|
||||||
|
// canBet — чужой кошелёк.
|
||||||
|
{
|
||||||
|
const input = goodCanBet();
|
||||||
|
input.walletInKeystore = false;
|
||||||
|
const r = canBetDecision(input);
|
||||||
|
check(r.canBet === false, "canBet: чужой кошелёк → canBet=false");
|
||||||
|
check(r.reasonCode === "WALLET_UNKNOWN", 'canBet: чужой кошелёк → reasonCode="WALLET_UNKNOWN"');
|
||||||
|
check(r.reasonText === "Кошелёк не зарегистрирован в шлюзе", "canBet: чужой кошелёк → reasonText по-русски");
|
||||||
|
}
|
||||||
|
|
||||||
|
// canBet — приоритет: paused=1 И hasOpenBet=true И всё плохо → PAUSED
|
||||||
|
// (а не OPEN_BET и не INSUFFICIENT_SOL).
|
||||||
|
{
|
||||||
|
const input = goodCanBet();
|
||||||
|
input.paused = 1;
|
||||||
|
input.hasOpenBet = true;
|
||||||
|
input.walletInKeystore = false;
|
||||||
|
input.solLamports = 0;
|
||||||
|
input.tokenUnits = "0";
|
||||||
|
const r = canBetDecision(input);
|
||||||
|
check(r.reasonCode === "PAUSED", "canBet: приоритет — PAUSED побеждает всё ниже по таблице");
|
||||||
|
}
|
||||||
|
|
||||||
|
// canBet — приоритет NOT_INITIALIZED побеждает PAUSED.
|
||||||
|
{
|
||||||
|
const input = goodCanBet();
|
||||||
|
input.globalPresent = false;
|
||||||
|
input.paused = 1;
|
||||||
|
input.hasOpenBet = true;
|
||||||
|
const r = canBetDecision(input);
|
||||||
|
check(
|
||||||
|
r.reasonCode === "NOT_INITIALIZED",
|
||||||
|
"canBet: приоритет — NOT_INITIALIZED побеждает PAUSED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 7. pickBetViews: взаимоисключение openBet/lastBet для /state (п.7 ТЗ) ===
|
||||||
|
|
||||||
|
// pickBetViews: открытая приоритетна — lastBet = null.
|
||||||
|
{
|
||||||
|
const r = pickBetViews<string>("open", "last");
|
||||||
|
check(
|
||||||
|
r.openBet === "open" && r.lastBet === null,
|
||||||
|
'pickBetViews: открытая приоритетна → openBet="open", lastBet=null'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickBetViews: только закрытая — openBet = null, lastBet сохраняется.
|
||||||
|
{
|
||||||
|
const r = pickBetViews<string>(null, "last");
|
||||||
|
check(
|
||||||
|
r.openBet === null && r.lastBet === "last",
|
||||||
|
'pickBetViews: только закрытая → openBet=null, lastBet="last"'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickBetViews: пусто — обе null.
|
||||||
|
{
|
||||||
|
const r = pickBetViews<string>(null, null);
|
||||||
|
check(
|
||||||
|
r.openBet === null && r.lastBet === null,
|
||||||
|
"pickBetViews: пусто → обе null"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
console.error(`\ncheck: ${failed} проверок ПРОВАЛЕНО`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("\ncheck: все проверки пройдены — сигнатуры релейера соответствуют новому токеновому IDL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Дефолтный «хороший» вход для canBetDecision во всех позитивных тестах. */
|
||||||
|
function goodCanBet(): CanBetInput {
|
||||||
|
return {
|
||||||
|
paused: 0,
|
||||||
|
globalPresent: true,
|
||||||
|
hasOpenBet: false,
|
||||||
|
walletInKeystore: true,
|
||||||
|
solLamports: 5 * 1_000_000_000, // 5 SOL
|
||||||
|
tokenUnits: "9800000000", // 9800 фантиков
|
||||||
|
minAmount: 100_000_000,
|
||||||
|
maxAmount: 10_000_000_000,
|
||||||
|
betPercent: 30,
|
||||||
|
priceAvailable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+83
-7
@@ -2,6 +2,7 @@ import { Connection, clusterApiUrl, Keypair, PublicKey } from "@solana/web3.js";
|
|||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as os from "os";
|
import * as os from "os";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
import { SmartMode, parseMode } from "./faucetGate";
|
||||||
|
|
||||||
export const SIDE_UP = 0;
|
export const SIDE_UP = 0;
|
||||||
export const SIDE_DOWN = 1;
|
export const SIDE_DOWN = 1;
|
||||||
@@ -13,17 +14,57 @@ export const SIDE_DOWN = 1;
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
/** RPC endpoint of the cluster (localnet by default). */
|
/** RPC endpoint of the cluster (localnet by default). */
|
||||||
rpcUrl: string;
|
rpcUrl: string;
|
||||||
/** Localnet allows naive airdrops (to bettors and the house vault). */
|
/**
|
||||||
|
* Режим работы релейера: "test" разрешает faucet-ручку (налив SOL/фантиков
|
||||||
|
* на произвольный адрес); "release" — запрещает, гейт fail-closed.
|
||||||
|
* Дефолт — "release" (безопаснее забыть, чем вспомнить).
|
||||||
|
*/
|
||||||
|
mode: SmartMode;
|
||||||
|
/**
|
||||||
|
* Localnet allows naive funding: SOL airdrop to bettors (rent/fees) and
|
||||||
|
* minting tokens into the vault via the mint authority. Disable on mainnet.
|
||||||
|
*/
|
||||||
allowAirdrop: boolean;
|
allowAirdrop: boolean;
|
||||||
/** Program id. Defaults to the value embedded in the IDL. */
|
/** Program id. Defaults to the value embedded in the IDL. */
|
||||||
programId: PublicKey;
|
programId: PublicKey;
|
||||||
/** House admin/relayer keypair file path. */
|
/**
|
||||||
|
* Mint of our SPL "фантики" (decimals = 6, per the contract).
|
||||||
|
* All amounts in this relayer are token base units of this mint.
|
||||||
|
*/
|
||||||
|
tokenMint: PublicKey;
|
||||||
|
/** House admin/relayer keypair path (also the localnet mint authority). */
|
||||||
adminKeypairPath: string;
|
adminKeypairPath: string;
|
||||||
/** Initial parameters used by `init` (must match on-chain constants). */
|
/** Bet amount bounds in token base units (NOT lamports); must match the on-chain `initialize` values. */
|
||||||
minAmountLamports: number;
|
minAmount: number;
|
||||||
maxAmountLamports: number;
|
maxAmount: number;
|
||||||
|
/**
|
||||||
|
* How many "фантики" (token base units) the localnet relayer mints into each
|
||||||
|
* bettor's ATA on start (analog of the SOL airdrop). Must cover the max
|
||||||
|
* bet size so a freshly funded bettor can place a full-size bet. Only used
|
||||||
|
* when `allowAirdrop` is true (localnet).
|
||||||
|
*/
|
||||||
|
startBalance: number;
|
||||||
multiplierBps: number;
|
multiplierBps: number;
|
||||||
expirySeconds: number;
|
expirySeconds: number;
|
||||||
|
/** Путь к файлу локальной истории цены (SQLite). */
|
||||||
|
priceHistoryPath: string;
|
||||||
|
/** Сколько миллисекунд хранить историю цены (ретеншен). Дефолт — 24 часа. */
|
||||||
|
priceHistoryRetentionMs: number;
|
||||||
|
/** Как часто планировщик авто-закрытия просроченных ставок просыпается (мс). */
|
||||||
|
settleIntervalMs: number;
|
||||||
|
/**
|
||||||
|
* Захардкоженный беттор шлюза. Шлюз ставит ОТ ЕГО ИМЕНИ (keypair в keystore),
|
||||||
|
* клиент фронта НЕ передаёт адрес. Сумма ставки = betPercent% от баланса
|
||||||
|
* фантиков этого кошелька (с клампом в [minAmount, maxAmount]).
|
||||||
|
*/
|
||||||
|
fixedBettor: PublicKey;
|
||||||
|
/** Процент от баланса фантиков fixedBettor, идущий в ставку (дефолт 30). */
|
||||||
|
betPercent: number;
|
||||||
|
/**
|
||||||
|
* п.8 ТЗ: лимит переигровок на одну цепочку ничьих. По умолчанию 10 —
|
||||||
|
* если цена «заморожена», цепочка обрывается после 10-й ничьей.
|
||||||
|
*/
|
||||||
|
maxReplays: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function env(name: string, fallback: string): string {
|
function env(name: string, fallback: string): string {
|
||||||
@@ -38,21 +79,56 @@ function envNum(name: string, fallback: number): number {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Захардкоженный беттор шлюза — «bettor-1» из тестового фронта (уже
|
||||||
|
* зарегистрирован в keystore и пополнен: ~9800 фантиков, ~5 SOL).
|
||||||
|
* Совпадает с .gateway-bettors.json, инициализированным при деплое пода.
|
||||||
|
*/
|
||||||
|
const FIXED_BETTOR_DEFAULT = "6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6";
|
||||||
|
|
||||||
|
function envPubkey(name: string, fallback: string): PublicKey {
|
||||||
|
return new PublicKey(process.env[name] ?? fallback);
|
||||||
|
}
|
||||||
|
|
||||||
export function loadConfig(): Config {
|
export function loadConfig(): Config {
|
||||||
const rpcUrl = env("SMART_RPC_URL", "http://127.0.0.1:8899");
|
const rpcUrl = env("SMART_RPC_URL", "http://127.0.0.1:8899");
|
||||||
|
const mode = parseMode(env("SMART_MODE", "release"));
|
||||||
const allowAirdrop = env("SMART_ALLOW_AIRDROP", "1") === "1";
|
const allowAirdrop = env("SMART_ALLOW_AIRDROP", "1") === "1";
|
||||||
const adminKeypairPath =
|
const adminKeypairPath =
|
||||||
env("SMART_ADMIN_KEYPAIR", path.join(os.homedir(), ".config/solana/id.json"));
|
env("SMART_ADMIN_KEYPAIR", path.join(os.homedir(), ".config/solana/id.json"));
|
||||||
|
const tokenMintRaw = env("SMART_TOKEN_MINT", "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rpcUrl,
|
rpcUrl,
|
||||||
|
mode,
|
||||||
allowAirdrop,
|
allowAirdrop,
|
||||||
programId: PublicKey.default, // placeholder; real value read from the IDL below
|
programId: PublicKey.default, // placeholder; real value read from the IDL below
|
||||||
|
// Плейсхолдер: настоящий адрес фантичного mint задаётся через SMART_TOKEN_MINT.
|
||||||
|
tokenMint: tokenMintRaw ? new PublicKey(tokenMintRaw) : PublicKey.default,
|
||||||
adminKeypairPath,
|
adminKeypairPath,
|
||||||
minAmountLamports: envNum("SMART_MIN_AMOUNT", 100_000_000), // 0.1 SOL
|
minAmount: envNum("SMART_MIN_AMOUNT", 100_000_000), // 100 фантиков (decimals = 6)
|
||||||
maxAmountLamports: envNum("SMART_MAX_AMOUNT", 10_000_000_000), // 10 SOL
|
maxAmount: envNum("SMART_MAX_AMOUNT", 10_000_000_000), // 10 000 фантиков
|
||||||
multiplierBps: envNum("SMART_MULTIPLIER_BPS", 13_000), // 1.3x
|
multiplierBps: envNum("SMART_MULTIPLIER_BPS", 13_000), // 1.3x
|
||||||
expirySeconds: envNum("SMART_EXPIRY_SECONDS", 15),
|
expirySeconds: envNum("SMART_EXPIRY_SECONDS", 15),
|
||||||
|
// Локальная история цены (SQLite): цена пишется на каждом тике при
|
||||||
|
// изменении, чтобы при /close отдавать цену на момент expire_time ставки,
|
||||||
|
// а не текущую (иначе игрок ждёт выгодного момента и кликает тогда).
|
||||||
|
priceHistoryPath: env("SMART_PRICE_HISTORY_PATH", "/app/data/price-history.db"),
|
||||||
|
priceHistoryRetentionMs: envNum("SMART_PRICE_RETENTION_HOURS", 24) * 60 * 60 * 1000,
|
||||||
|
// Стартовый баланс фантиков, который локальный релейер mintTo'ит беттору
|
||||||
|
// (аналог SOL-airdrop). По умолчанию равен maxAmount — хватает на максимум ставки.
|
||||||
|
startBalance: envNum("SMART_START_BALANCE", 10_000_000_000),
|
||||||
|
// Интервал планировщика авто-закрытия просроченных ставок (мс). Дефолт 2с.
|
||||||
|
settleIntervalMs: envNum("SMART_SETTLE_INTERVAL_MS", 2000),
|
||||||
|
// Захардкоженный беттор шлюза (GW_FIXED_BETTOR) и процент от его баланса,
|
||||||
|
// идущий в ставку (GW_BET_PERCENT). Шлюз ставит за него — клиент фронта
|
||||||
|
// не передаёт ни адрес, ни сумму.
|
||||||
|
fixedBettor: envPubkey("GW_FIXED_BETTOR", FIXED_BETTOR_DEFAULT),
|
||||||
|
betPercent: envNum("GW_BET_PERCENT", 30),
|
||||||
|
// п.8 ТЗ: лимит переигровок ничьих на одну цепочку ставок. Цена
|
||||||
|
// «заморожена» → каждая ставка рефандится → без лимита цепочка
|
||||||
|
// крутится бесконечно. Дефолт 10.
|
||||||
|
maxReplays: envNum("GW_MAX_REPLAYS", 10),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Гейт доступа к тестовым ручкам шлюза (/faucet, /wallet-финансирование и т.п.).
|
||||||
|
*
|
||||||
|
* Чистая логика, без сети — нужна для оффлайн-проверок в `npm test`
|
||||||
|
* (см. src/check.ts). Все сетевые обращения (getGenesisHash и т.п.)
|
||||||
|
* живут в gateway.ts и используют эти функции как финальный арбитр.
|
||||||
|
*
|
||||||
|
* Политика: fail-closed. Любое отклонение от разрешённого режима — отказ.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SmartMode = "test" | "release";
|
||||||
|
|
||||||
|
/** Genesis-hash публичных кластеров: на них тестовая ручка запрещена всегда. */
|
||||||
|
export const PUBLIC_CLUSTER_GENESIS: Record<string, string> = {
|
||||||
|
"mainnet-beta": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
|
||||||
|
devnet: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG",
|
||||||
|
testnet: "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Разбор режима: всё, что не "test", считается "release" (fail-closed). */
|
||||||
|
export function parseMode(raw: string | undefined): SmartMode {
|
||||||
|
return raw === "test" ? "test" : "release";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Причина отказа в доступе к тестовой ручке, либо null если доступ разрешён.
|
||||||
|
* Отказ (fail-closed) если: режим не test; genesisHash неизвестен (null/пусто);
|
||||||
|
* genesisHash принадлежит публичному кластеру.
|
||||||
|
*/
|
||||||
|
export function faucetDenyReason(opts: {
|
||||||
|
mode: SmartMode;
|
||||||
|
genesisHash: string | null | undefined;
|
||||||
|
}): string | null {
|
||||||
|
if (opts.mode !== "test") {
|
||||||
|
return `SMART_MODE=${opts.mode} (only "test" allows the faucet)`;
|
||||||
|
}
|
||||||
|
if (!opts.genesisHash) {
|
||||||
|
return "genesis hash unavailable: cannot prove non-public cluster";
|
||||||
|
}
|
||||||
|
for (const [cluster, hash] of Object.entries(PUBLIC_CLUSTER_GENESIS)) {
|
||||||
|
if (opts.genesisHash === hash) {
|
||||||
|
return `cluster ${cluster} is public — faucet disabled`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ import { SIDE_DOWN, SIDE_UP } from "./config";
|
|||||||
export interface BetRequest {
|
export interface BetRequest {
|
||||||
bettor: Keypair;
|
bettor: Keypair;
|
||||||
side: number; // SIDE_UP | SIDE_DOWN
|
side: number; // SIDE_UP | SIDE_DOWN
|
||||||
amountLamports: number;
|
/** Сумма ставки в базовых юнитах SPL-токена (не лампорты). */
|
||||||
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Очередь входящих ставок. В заглушке наполняем случайными значениями. */
|
/** Очередь входящих ставок. В заглушке наполняем случайными значениями. */
|
||||||
@@ -20,5 +21,5 @@ export function nextBetRequest(minAmount: number, maxAmount: number): BetRequest
|
|||||||
const side = Math.random() < 0.5 ? SIDE_UP : SIDE_DOWN;
|
const side = Math.random() < 0.5 ? SIDE_UP : SIDE_DOWN;
|
||||||
const amount =
|
const amount =
|
||||||
minAmount + Math.floor(Math.random() * (maxAmount - minAmount + 1));
|
minAmount + Math.floor(Math.random() * (maxAmount - minAmount + 1));
|
||||||
return { bettor, side, amountLamports: amount };
|
return { bettor, side, amount };
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+44
-23
@@ -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 { Program } from "@anchor-lang/core";
|
||||||
import { loadConfig, loadKeypair, SIDE_UP } from "./config";
|
import { loadConfig, loadKeypair, SIDE_UP } from "./config";
|
||||||
import { getPrice } from "./priceSource";
|
import { getPrice } from "./priceSource";
|
||||||
@@ -9,24 +10,25 @@ import {
|
|||||||
closeBet,
|
closeBet,
|
||||||
readGlobal,
|
readGlobal,
|
||||||
ensureVaultFunded,
|
ensureVaultFunded,
|
||||||
|
ensureBettorTokenAccount,
|
||||||
|
fundBettor,
|
||||||
} from "./ops";
|
} from "./ops";
|
||||||
import { betPda } from "./pda";
|
import { betPda } from "./pda";
|
||||||
import { makeConnection, makeProgram } from "./program";
|
import { makeConnection, makeProgram } from "./program";
|
||||||
|
|
||||||
import type { SmartUpdown } from "../idl/smart_updown";
|
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 sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
const STATUS_OPEN = 0;
|
const STATUS_OPEN = 0;
|
||||||
async function fundBettor(connection: any, bettorPubkey: PublicKey): Promise<void> {
|
|
||||||
const bal = await connection.getBalance(bettorPubkey);
|
/** Человекочитаемо-формат: токен-юниты -> "N.NNNNNN" по decimals. */
|
||||||
if (bal < 2 * LAMPORTS_PER_SOL_NUM) {
|
function humanize(units: string | number | bigint, decimals: number): string {
|
||||||
await connection.requestAirdrop(bettorPubkey, 5 * LAMPORTS_PER_SOL_NUM);
|
const n = BigInt(units);
|
||||||
await sleep(800);
|
const sign = n < 0n ? "-" : "";
|
||||||
console.log(`bettor funded ${bettorPubkey.toBase58()} (+5 SOL)`);
|
const abs = n < 0n ? -n : n;
|
||||||
}
|
const pow = 10n ** BigInt(decimals);
|
||||||
|
return `${sign}${abs / pow}.${(abs % pow).toString().padStart(decimals, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Закрывает ставку, повторяя при «equal price» (exit==entry) пока не зафиналится. */
|
/** Закрывает ставку, повторяя при «equal price» (exit==entry) пока не зафиналится. */
|
||||||
@@ -35,7 +37,8 @@ async function settleBet(
|
|||||||
id: any,
|
id: any,
|
||||||
entryPrice: number,
|
entryPrice: number,
|
||||||
relayer: any,
|
relayer: any,
|
||||||
bettor: PublicKey
|
bettor: PublicKey,
|
||||||
|
mint: PublicKey
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (let attempt = 1; ; attempt++) {
|
for (let attempt = 1; ; attempt++) {
|
||||||
const exitPrice = await getPrice();
|
const exitPrice = await getPrice();
|
||||||
@@ -44,7 +47,7 @@ async function settleBet(
|
|||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const sig = await closeBet(program, id, exitPrice, relayer, bettor);
|
const sig = await closeBet(program, id, exitPrice, relayer, bettor, mint);
|
||||||
console.log(`close_bet #${id} exit=${exitPrice} (attempt ${attempt}) tx=${sig}`);
|
console.log(`close_bet #${id} exit=${exitPrice} (attempt ${attempt}) tx=${sig}`);
|
||||||
|
|
||||||
const status = await getStatus(program, id);
|
const status = await getStatus(program, id);
|
||||||
@@ -70,8 +73,17 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
console.log(`RPC: ${cfg.rpcUrl}`);
|
console.log(`RPC: ${cfg.rpcUrl}`);
|
||||||
console.log(`Admin/relayer: ${admin.publicKey.toBase58()}`);
|
console.log(`Admin/relayer: ${admin.publicKey.toBase58()}`);
|
||||||
|
console.log(`Token mint: ${cfg.tokenMint.toBase58()}`);
|
||||||
|
|
||||||
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 (!global) {
|
||||||
if (!process.argv.includes("--init")) {
|
if (!process.argv.includes("--init")) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -79,21 +91,21 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await initialize(program, {
|
await initialize(program, {
|
||||||
minAmount: cfg.minAmountLamports,
|
minAmount: cfg.minAmount,
|
||||||
maxAmount: cfg.maxAmountLamports,
|
maxAmount: cfg.maxAmount,
|
||||||
multiplierBps: cfg.multiplierBps,
|
multiplierBps: cfg.multiplierBps,
|
||||||
expirySeconds: cfg.expirySeconds,
|
expirySeconds: cfg.expirySeconds,
|
||||||
}, admin);
|
}, admin);
|
||||||
console.log("Контракт инициализирован.");
|
console.log("Контракт инициализирован.");
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
`Global: counter=${global.counter} vault=${(global.lamports / LAMPORTS_PER_SOL_NUM).toFixed(2)} SOL ` +
|
`Global: counter=${global.counter} vault=${humanize(global.vaultBalance, decimals)} fant ` +
|
||||||
`expiry=${global.expirySeconds}s paused=${global.paused}`
|
`expiry=${global.expirySeconds}s paused=${global.paused}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cfg.allowAirdrop) {
|
if (cfg.allowAirdrop) {
|
||||||
await ensureVaultFunded(connection, program, 10 * LAMPORTS_PER_SOL_NUM);
|
await ensureVaultFunded(connection, program, admin, cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
const loops = process.argv.includes("--once") ? 1 : Infinity;
|
const loops = process.argv.includes("--once") ? 1 : Infinity;
|
||||||
@@ -101,24 +113,33 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
while (n < loops) {
|
while (n < loops) {
|
||||||
n++;
|
n++;
|
||||||
const req = nextBetRequest(cfg.minAmountLamports, cfg.maxAmountLamports);
|
const req = nextBetRequest(cfg.minAmount, cfg.maxAmount);
|
||||||
await fundBettor(connection, req.bettor.publicKey);
|
await fundBettor(connection, req.bettor.publicKey);
|
||||||
|
// Беттору нужен токен-аккаунт (ATA) — без него create_bet не пройдёт.
|
||||||
|
// На localnet (allowAirdrop) после создания ATA наливает фантики (аналог SOL-airdrop).
|
||||||
|
await ensureBettorTokenAccount(
|
||||||
|
connection,
|
||||||
|
admin,
|
||||||
|
cfg.tokenMint,
|
||||||
|
req.bettor.publicKey,
|
||||||
|
cfg.allowAirdrop ? BigInt(cfg.startBalance) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
const entryPrice = await getPrice();
|
const entryPrice = await getPrice();
|
||||||
await createBet(program, req.bettor, {
|
await createBet(program, cfg.tokenMint, req.bettor, {
|
||||||
side: req.side,
|
side: req.side,
|
||||||
amount: req.amountLamports,
|
amount: req.amount,
|
||||||
entryPrice,
|
entryPrice,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`ставка ${req.side === SIDE_UP ? "UP" : "DOWN"} на ${(req.amountLamports / LAMPORTS_PER_SOL_NUM).toFixed(3)} SOL, жду ${cfg.expirySeconds}s...`
|
`ставка ${req.side === SIDE_UP ? "UP" : "DOWN"} на ${humanize(req.amount, decimals)} fant, жду ${cfg.expirySeconds}s...`
|
||||||
);
|
);
|
||||||
await sleep((cfg.expirySeconds + 1) * 1000);
|
await sleep((cfg.expirySeconds + 1) * 1000);
|
||||||
|
|
||||||
const g0 = await readGlobal(program, program.programId);
|
const g0 = await readGlobal(program, program.programId, cfg.tokenMint);
|
||||||
const id = g0!.counter.subn(1);
|
const id = g0!.counter.subn(1);
|
||||||
await settleBet(program, id, entryPrice, admin, req.bettor.publicKey);
|
await settleBet(program, id, entryPrice, admin, req.bettor.publicKey, cfg.tokenMint);
|
||||||
|
|
||||||
const outcome = await program.account.bet.fetchNullable(betPda(program.programId, id));
|
const outcome = await program.account.bet.fetchNullable(betPda(program.programId, id));
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
/**
|
||||||
|
* Unix ms: время СДЕЛКИ у Binance (поле `eventTime` payload'а).
|
||||||
|
* Источник истины по времени: ставка в шлюзе закрывается по цене
|
||||||
|
* на момент `expire_time`, а не по текущей — поэтому нужно время
|
||||||
|
* сделки, а не локальная метка приёма. Fallback на `ts`, если
|
||||||
|
* `eventTime` отсутствует или не число.
|
||||||
|
*/
|
||||||
|
eventTime: 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 eventTime =
|
||||||
|
typeof obj.eventTime === "number" && Number.isFinite(obj.eventTime)
|
||||||
|
? (obj.eventTime as number)
|
||||||
|
: tsNum;
|
||||||
|
const tick: PriceTick = { price: priceRaw, ts: tsNum, eventTime };
|
||||||
|
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
@@ -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 BN from "bn.js";
|
||||||
import { Program } from "@anchor-lang/core";
|
import { Program } from "@anchor-lang/core";
|
||||||
import { SIDE_UP } from "./config";
|
import { Config, SIDE_UP } from "./config";
|
||||||
import { globalPda } from "./pda";
|
import { globalPda, vaultPda } from "./pda";
|
||||||
|
|
||||||
import type { SmartUpdown } from "../idl/smart_updown";
|
import type { SmartUpdown } from "../idl/smart_updown";
|
||||||
|
|
||||||
const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL);
|
const BPS_DIVISOR = 10_000n;
|
||||||
|
|
||||||
/** Считывает account Global (ваулт). Возвращает null, если ещё не инициализирован. */
|
/**
|
||||||
|
* Считывает account Global + баланс токеного ваулта.
|
||||||
|
* Возвращает null, если глобальное состояние ещё не инициализировано.
|
||||||
|
* Суммы — в базовых юнитах SPL-токена (decimals = 6), не в лампортах.
|
||||||
|
*/
|
||||||
export async function readGlobal(
|
export async function readGlobal(
|
||||||
program: Program<SmartUpdown>,
|
program: Program<SmartUpdown>,
|
||||||
programId: PublicKey
|
programId: PublicKey,
|
||||||
|
mint: PublicKey
|
||||||
): Promise<{
|
): Promise<{
|
||||||
counter: BN;
|
counter: BN;
|
||||||
minAmount: BN;
|
minAmount: BN;
|
||||||
@@ -21,12 +39,20 @@ export async function readGlobal(
|
|||||||
paused: BN;
|
paused: BN;
|
||||||
admin: PublicKey;
|
admin: PublicKey;
|
||||||
relayer: PublicKey;
|
relayer: PublicKey;
|
||||||
lamports: number;
|
/** Баланс vault ATA (owner = PDA-ваула) в токен-юнитах; "0", если аккаунт ещё не создан. */
|
||||||
|
vaultBalance: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
const global = globalPda(programId);
|
const global = globalPda(programId);
|
||||||
const raw = await program.account.global.fetchNullable(global);
|
const raw = await program.account.global.fetchNullable(global);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const lamports = await program.provider.connection.getBalance(global);
|
let vaultBalance = "0";
|
||||||
|
try {
|
||||||
|
const vaultAta = await getAssociatedTokenAddress(mint, vaultPda(programId), true);
|
||||||
|
const bal = await program.provider.connection.getTokenAccountBalance(vaultAta);
|
||||||
|
vaultBalance = bal.value.amount || "0";
|
||||||
|
} catch {
|
||||||
|
vaultBalance = "0";
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
counter: raw.counter,
|
counter: raw.counter,
|
||||||
minAmount: raw.minAmount,
|
minAmount: raw.minAmount,
|
||||||
@@ -36,7 +62,7 @@ export async function readGlobal(
|
|||||||
paused: raw.paused,
|
paused: raw.paused,
|
||||||
admin: raw.admin,
|
admin: raw.admin,
|
||||||
relayer: raw.relayer,
|
relayer: raw.relayer,
|
||||||
lamports,
|
vaultBalance,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +84,7 @@ export async function initialize(
|
|||||||
new BN(args.multiplierBps),
|
new BN(args.multiplierBps),
|
||||||
new BN(args.expirySeconds)
|
new BN(args.expirySeconds)
|
||||||
)
|
)
|
||||||
.accounts({
|
.accountsPartial({
|
||||||
admin: admin.publicKey,
|
admin: admin.publicKey,
|
||||||
})
|
})
|
||||||
.signers([admin])
|
.signers([admin])
|
||||||
@@ -66,27 +92,40 @@ export async function initialize(
|
|||||||
return sig;
|
return sig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Создаёт ставку от имени пользователя. Возвращает фактический id (== counter). */
|
/**
|
||||||
|
* Создаёт ставку от имени пользователя. Возвращает {id (== counter), tx}.
|
||||||
|
*/
|
||||||
export async function createBet(
|
export async function createBet(
|
||||||
program: Program<SmartUpdown>,
|
program: Program<SmartUpdown>,
|
||||||
|
mint: PublicKey,
|
||||||
bettor: Keypair,
|
bettor: Keypair,
|
||||||
args: { side: number; amount: number; entryPrice: number }
|
args: { side: number; amount: number; entryPrice: number }
|
||||||
): Promise<BN> {
|
): Promise<{ id: BN; tx: string }> {
|
||||||
const global = await readGlobal(program, program.programId);
|
const global = await readGlobal(program, program.programId, mint);
|
||||||
if (!global) throw new Error("Contract not initialized");
|
if (!global) throw new Error("Contract not initialized");
|
||||||
const id = global.counter;
|
const id = global.counter;
|
||||||
|
|
||||||
const sig = await program.methods
|
const sig = await program.methods
|
||||||
.createBet(new BN(id), new BN(args.side), new BN(args.amount), new BN(args.entryPrice))
|
.createBet(
|
||||||
.accounts({
|
new BN(id),
|
||||||
|
new BN(args.side),
|
||||||
|
new BN(args.amount),
|
||||||
|
new BN(args.entryPrice)
|
||||||
|
)
|
||||||
|
.accountsPartial({
|
||||||
bettor: bettor.publicKey,
|
bettor: bettor.publicKey,
|
||||||
|
bettorToken: await getAssociatedTokenAddress(mint, bettor.publicKey),
|
||||||
|
vaultToken: await getAssociatedTokenAddress(mint, vaultPda(program.programId), true),
|
||||||
|
tokenProgram: TOKEN_PROGRAM_ID,
|
||||||
})
|
})
|
||||||
.signers([bettor])
|
.signers([bettor])
|
||||||
.rpc();
|
.rpc();
|
||||||
|
|
||||||
console.log(`create_bet #${id} side=${args.side === SIDE_UP ? "UP" : "DOWN"} ` +
|
console.log(
|
||||||
`amount=${(args.amount / LAMPORTS_PER_SOL_NUM).toFixed(3)} SOL entry=${args.entryPrice} tx=${sig}`);
|
`createBet #${id} side=${args.side === SIDE_UP ? "UP" : "DOWN"} ` +
|
||||||
return id;
|
`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,
|
id: BN,
|
||||||
exitPrice: number,
|
exitPrice: number,
|
||||||
relayer: Keypair,
|
relayer: Keypair,
|
||||||
bettor: PublicKey
|
bettor: PublicKey,
|
||||||
|
mint: PublicKey
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return program.methods
|
return program.methods
|
||||||
.closeBet(new BN(id), new BN(exitPrice))
|
.closeBet(new BN(id), new BN(exitPrice))
|
||||||
.accounts({
|
.accountsPartial({
|
||||||
relayer: relayer.publicKey,
|
relayer: relayer.publicKey,
|
||||||
bettor,
|
bettor,
|
||||||
|
bettorToken: await getAssociatedTokenAddress(mint, bettor),
|
||||||
|
vaultToken: await getAssociatedTokenAddress(mint, vaultPda(program.programId), true),
|
||||||
|
tokenProgram: TOKEN_PROGRAM_ID,
|
||||||
})
|
})
|
||||||
.signers([relayer])
|
.signers([relayer])
|
||||||
.rpc();
|
.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.
|
* Догружает ваулт (house bankroll), чтобы хватило на выплаты 1.3x.
|
||||||
* На localnet используем airDrop на PDA ваулта; на mainnet воздух не накапает —
|
* Теперь это ТОКЕН, не SOL: на localnet mint-авторитетом выступает admin
|
||||||
* там нужно переводить SOL с админа (off-chain, вне этого CLI).
|
* (см. state.rs: decimals = 6), так что наливается через mintTo в ATA-ваулт
|
||||||
|
* (токен-счёт с owner = PDA([global, vault])). На mainnet наливается
|
||||||
|
* off-chain переводом фантиков (вне этого CLI).
|
||||||
*/
|
*/
|
||||||
export async function ensureVaultFunded(
|
export async function ensureVaultFunded(
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
program: Program<SmartUpdown>,
|
program: Program<SmartUpdown>,
|
||||||
amountLamports: number
|
admin: Keypair,
|
||||||
|
cfg: Config
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const global = globalPda(program.programId);
|
const mint = cfg.tokenMint;
|
||||||
const current = await connection.getBalance(global);
|
const vault = vaultPda(program.programId);
|
||||||
const needed = amountLamports; // будущая выплата (до 1.3x), запасаем с запасом
|
const vaultAta = await getAssociatedTokenAddress(mint, vault, true);
|
||||||
if (current >= needed) return;
|
|
||||||
const topUp = needed - current + 5 * LAMPORTS_PER_SOL_NUM; // +5 SOL запаса
|
// ATA-ваулт (owner = PDA([global, vault])) может не существовать —
|
||||||
await connection.requestAirdrop(global, topUp);
|
// контракт его не создаёт. ATA не требует подписи owner (allowOwnerOffCurve),
|
||||||
console.log(`vault funded +${(topUp / LAMPORTS_PER_SOL_NUM).toFixed(2)} SOL`);
|
// поэтому создаём её от имени 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})`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import BN from "bn.js";
|
|||||||
/** Seeds, согласованные с контрактом (см. constants.rs). */
|
/** Seeds, согласованные с контрактом (см. constants.rs). */
|
||||||
export const GLOBAL_SEED = Buffer.from("global");
|
export const GLOBAL_SEED = Buffer.from("global");
|
||||||
export const BET_SEED = Buffer.from("bet");
|
export const BET_SEED = Buffer.from("bet");
|
||||||
|
export const VAULT_SEED = Buffer.from("vault");
|
||||||
|
|
||||||
export function globalPda(programId: PublicKey): PublicKey {
|
export function globalPda(programId: PublicKey): PublicKey {
|
||||||
return PublicKey.findProgramAddressSync([GLOBAL_SEED], programId)[0];
|
return PublicKey.findProgramAddressSync([GLOBAL_SEED], programId)[0];
|
||||||
@@ -14,3 +15,12 @@ export function betPda(programId: PublicKey, id: BN): PublicKey {
|
|||||||
idLE.writeBigUInt64LE(BigInt(id.toString(10)));
|
idLE.writeBigUInt64LE(BigInt(id.toString(10)));
|
||||||
return PublicKey.findProgramAddressSync([BET_SEED, idLE], programId)[0];
|
return PublicKey.findProgramAddressSync([BET_SEED, idLE], programId)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDA токеного ваулта: PDA([GLOBAL_SEED, VAULT_SEED]) — совпадает с контрактом
|
||||||
|
* (vault_token в create_bet/close_bet). Сам по себе global PDA ([GLOBAL_SEED])
|
||||||
|
* ваултом быть не может: это адрес state-аккаунта.
|
||||||
|
*/
|
||||||
|
export function vaultPda(programId: PublicKey): PublicKey {
|
||||||
|
return PublicKey.findProgramAddressSync([GLOBAL_SEED, VAULT_SEED], programId)[0];
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Локальная история цены SOL (SQLite, node:sqlite).
|
||||||
|
*
|
||||||
|
* Используется шлюзом, чтобы при `close_bet` подставлять в транзакцию
|
||||||
|
* **зафиксированную заранее** цену на момент `expire_time` ставки, а не
|
||||||
|
* текущую с биржи. Это закрывает окно «подождать выгодного момента и
|
||||||
|
* кликнуть позже»: цена фиксируется историей, момент клика больше
|
||||||
|
* ничего не значит.
|
||||||
|
*
|
||||||
|
* Схема (одна таблица, PK = `ts` = unix-миллисекунды сделки):
|
||||||
|
*
|
||||||
|
* ts INTEGER PRIMARY KEY -- время СДЕЛКИ (eventTime Binance), unix ms
|
||||||
|
* price INTEGER NOT NULL -- цена × 1e8, целое (без float)
|
||||||
|
*
|
||||||
|
* Запись идёт **только при изменении цены** (≈300 строк/сутки вместо
|
||||||
|
* 92k тиков), ретеншен по умолчанию — 24 часа, чистится периодически
|
||||||
|
* `DELETE FROM price_history WHERE ts < ?` одним запросом.
|
||||||
|
*/
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
/** Ретеншен по умолчанию: 24 часа (в миллисекундах). */
|
||||||
|
export const DEFAULT_RETENTION_MS: number = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export class PriceHistory {
|
||||||
|
private readonly db: DatabaseSync;
|
||||||
|
private readonly stmtLast: ReturnType<DatabaseSync["prepare"]>;
|
||||||
|
private readonly stmtLastPrice: ReturnType<DatabaseSync["prepare"]>;
|
||||||
|
private readonly stmtAtOrBefore: ReturnType<DatabaseSync["prepare"]>;
|
||||||
|
private readonly stmtInsert: ReturnType<DatabaseSync["prepare"]>;
|
||||||
|
private readonly stmtPrune: ReturnType<DatabaseSync["prepare"]>;
|
||||||
|
private readonly stmtCount: ReturnType<DatabaseSync["prepare"]>;
|
||||||
|
|
||||||
|
constructor(filePath: string) {
|
||||||
|
this.db = new DatabaseSync(filePath);
|
||||||
|
// WAL: параллельные читатели не блокируют пишущего тик-цикл.
|
||||||
|
this.db.exec("PRAGMA journal_mode=WAL");
|
||||||
|
// PK(ts) уже индекс для `WHERE ts <= ?` и `ORDER BY ts DESC LIMIT 1`,
|
||||||
|
// отдельный индекс не нужен.
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS price_history (
|
||||||
|
ts INTEGER PRIMARY KEY,
|
||||||
|
price INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.stmtLast = this.db.prepare("SELECT ts FROM price_history ORDER BY ts DESC LIMIT 1");
|
||||||
|
this.stmtLastPrice = this.db.prepare(
|
||||||
|
"SELECT price FROM price_history ORDER BY ts DESC LIMIT 1"
|
||||||
|
);
|
||||||
|
this.stmtAtOrBefore = this.db.prepare(
|
||||||
|
"SELECT price FROM price_history WHERE ts <= ? ORDER BY ts DESC LIMIT 1"
|
||||||
|
);
|
||||||
|
this.stmtInsert = this.db.prepare(
|
||||||
|
"INSERT OR REPLACE INTO price_history (ts, price) VALUES (?, ?)"
|
||||||
|
);
|
||||||
|
this.stmtPrune = this.db.prepare("DELETE FROM price_history WHERE ts < ?");
|
||||||
|
this.stmtCount = this.db.prepare("SELECT COUNT(*) AS c FROM price_history");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Записать точку. Пишем ТОЛЬКО если цена отличается от последней записанной
|
||||||
|
* — иначе возвращаем false и в БД ничего не добавляем (≈300 строк/сутки
|
||||||
|
* вместо 92k тиков). Первая запись всегда пишется. `tsMs` пишется как
|
||||||
|
* есть (PK; INSERT OR REPLACE на случай коллизии `ts`).
|
||||||
|
*
|
||||||
|
* Возвращает true, если строка реально добавлена.
|
||||||
|
*/
|
||||||
|
record(tsMs: number, priceInt: number): boolean {
|
||||||
|
if (!Number.isFinite(tsMs) || !Number.isFinite(priceInt)) return false;
|
||||||
|
const prev = this.stmtLastPrice.get() as { price: number } | undefined;
|
||||||
|
if (prev !== undefined && prev.price === priceInt) return false;
|
||||||
|
this.stmtInsert.run(tsMs, priceInt);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Последняя цена, записанная НЕ ПОЗЖЕ `tsMs` (ts <= tsMs).
|
||||||
|
* Или null, если такой нет.
|
||||||
|
*
|
||||||
|
* Именно этот метод определяет честность расчёта: цена ПОЗЖЕ момента
|
||||||
|
* не должна возвращаться никогда. Поэтому `ORDER BY ts DESC` + `LIMIT 1`
|
||||||
|
* под условием `ts <= ?` — никаких «ближайших».
|
||||||
|
*/
|
||||||
|
priceAtOrBefore(tsMs: number): number | null {
|
||||||
|
if (!Number.isFinite(tsMs)) return null;
|
||||||
|
const row = this.stmtAtOrBefore.get(tsMs) as { price: number } | undefined;
|
||||||
|
return row !== undefined ? row.price : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unix-ms последней записи, или null если истории нет. */
|
||||||
|
lastTs(): number | null {
|
||||||
|
const row = this.stmtLast.get() as { ts: number } | undefined;
|
||||||
|
return row !== undefined ? row.ts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить записи старше `(nowMs - retentionMs)`. Одним запросом
|
||||||
|
* `DELETE FROM price_history WHERE ts < ?`. Возвращает число удалённых строк.
|
||||||
|
*/
|
||||||
|
prune(nowMs: number, retentionMs: number = DEFAULT_RETENTION_MS): number {
|
||||||
|
if (!Number.isFinite(nowMs) || !Number.isFinite(retentionMs)) return 0;
|
||||||
|
const cutoff = nowMs - retentionMs;
|
||||||
|
const res = this.stmtPrune.run(cutoff);
|
||||||
|
return Number(res.changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сколько строк в таблице (для диагностики/тестов). */
|
||||||
|
count(): number {
|
||||||
|
const row = this.stmtCount.get() as { c: number } | undefined;
|
||||||
|
return row !== undefined ? Number(row.c) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Закрыть БД. */
|
||||||
|
close(): void {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-13
@@ -1,22 +1,43 @@
|
|||||||
/**
|
/**
|
||||||
* STUB — SOL price source.
|
* Цена SOL. Источник — NATS (`relayer/src/natsPrice.ts`). `getPrice()`
|
||||||
*
|
* возвращает цену как ЦЕЛОЕ (price × 1e8, без float — точность нужна для
|
||||||
* TODO(пользователь): замени `getPrice` на реальный запрос к своей БД.
|
* сравнения на контракте). Override из старой заглушки сохранён для
|
||||||
* Договоримся, что функция возвращает цену SOL как целое число в «центах»
|
* тестов «equal price» / offline-сценариев.
|
||||||
* (то же юнит-пространство, что у веса entry/exit на контракте). Контракт
|
|
||||||
* сравнивает тол ько «больше/меньше/равно», поэтому само юнит-пространство
|
|
||||||
* не критично — главное, чтобы оно было согласовано между entry и exit.
|
|
||||||
*/
|
*/
|
||||||
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> {
|
export async function getPrice(): Promise<number> {
|
||||||
const drift = Math.round(Math.random() * 200_000); // ±0.2% движка
|
if (override !== undefined) return override;
|
||||||
simulated += Math.random() < 0.5 ? drift : -drift;
|
// waitForPrice сам разруливает "уже есть" vs "ещё ждём".
|
||||||
return simulated;
|
return waitForPrice(PRICE_WAIT_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Управляемая заглушка: вернуть ровно `value` (полезна для теста «equal price»). */
|
/** Управляемая заглушка: вернуть ровно `value` (полезна для теста «equal price»). */
|
||||||
export function setPriceOverride(value: number | undefined): void {
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
/**
|
||||||
|
* Планировщик авто-закрытия просроченных ставок + WS-события по жизненному
|
||||||
|
* циклу ставки (bet_open / bet_closed).
|
||||||
|
*
|
||||||
|
* ЧИСТЫЕ ФУНКЦИИ (decodeBet / openBetOf / dueForSettle / betOpenEvent /
|
||||||
|
* betClosedEvent) экспортированы — на них написаны оффлайн-тесты в check.ts.
|
||||||
|
* Сам планировщик (settleOnce + startScheduler) держится отдельно и
|
||||||
|
* зависит от рантайма (connection, program, history, broadcast).
|
||||||
|
*
|
||||||
|
* Контракт по Bet (8-байтовый дискриминатор уже учтён в смещениях):
|
||||||
|
*
|
||||||
|
* | offset | поле | тип |
|
||||||
|
* |--------|-------------|---------|
|
||||||
|
* | 8 | amount | u64 LE |
|
||||||
|
* | 16 | bet_time | i64 LE |
|
||||||
|
* | 24 | expire_time | i64 LE |
|
||||||
|
* | 32 | entry_price | u64 LE |
|
||||||
|
* | 40 | exit_price | u64 LE |
|
||||||
|
* | 48 | side | u64 LE |
|
||||||
|
* | 56 | status | u64 LE |
|
||||||
|
* | 64 | bettor | Pubkey |
|
||||||
|
*
|
||||||
|
* Стороны: 0 = UP, 1 = DOWN. Статусы: 0 = OPEN, 1 = PAYOUT_DONE, 2 = HOUSE_WON.
|
||||||
|
*/
|
||||||
|
import { PublicKey } from "@solana/web3.js";
|
||||||
|
import BN from "bn.js";
|
||||||
|
import type { Program } from "@anchor-lang/core";
|
||||||
|
import { betPda } from "./pda";
|
||||||
|
import type { SmartUpdown } from "../idl/smart_updown";
|
||||||
|
|
||||||
|
/** Размер Bet-аккаунта в байтах (8 дискриминатор + 32 amount/time + 32 pubkey + ...). */
|
||||||
|
export const BET_ACCOUNT_SIZE = 96;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Значения, которые попадают в `SchedulerCtx.lastState` (Map<number,string>):
|
||||||
|
* последнее залогированное состояние ставки, чтобы повторно не спамить в лог.
|
||||||
|
*/
|
||||||
|
const STATE_NO_PRICE = "noPrice";
|
||||||
|
const STATE_RETRY = "retry";
|
||||||
|
/** Sentinel-ключ в `lastState` под индексом -1 — «предупреждение unmapped уже печаталось». */
|
||||||
|
const SENTINEL_UNMAPPED_LOGGED = -1;
|
||||||
|
|
||||||
|
export const STATUS_OPEN = 0;
|
||||||
|
export const STATUS_PAYOUT_DONE = 1;
|
||||||
|
export const STATUS_HOUSE_WON = 2;
|
||||||
|
export const STATUS_REFUNDED = 3;
|
||||||
|
|
||||||
|
export const SIDE_UP = 0;
|
||||||
|
export const SIDE_DOWN = 1;
|
||||||
|
|
||||||
|
export interface DecodedBet {
|
||||||
|
amount: number;
|
||||||
|
betTime: number;
|
||||||
|
expireTime: number;
|
||||||
|
entryPrice: number;
|
||||||
|
exitPrice: number;
|
||||||
|
side: number;
|
||||||
|
status: number;
|
||||||
|
bettor: PublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Запись аккаунта с уже сопоставленным id — форма, удобная для планировщика. */
|
||||||
|
export interface BetAccountEntry {
|
||||||
|
pubkey: PublicKey;
|
||||||
|
account: { data: Buffer };
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Читает u64 LE по offset в Buffer'е (offset — от 0, включая дискриминатор). */
|
||||||
|
function readU64LE(buf: Buffer, offset: number): number {
|
||||||
|
const v = buf.readBigUInt64LE(offset);
|
||||||
|
// Number-конверсия безопасна для наших величин (amount ≤ 1e10, prices ≤ ~1e11).
|
||||||
|
return Number(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Читает i64 LE по offset. */
|
||||||
|
function readI64LE(buf: Buffer, offset: number): number {
|
||||||
|
const v = buf.readBigInt64LE(offset);
|
||||||
|
return Number(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Приводит Uint8Array | Buffer к Buffer (для единого readU64LE). */
|
||||||
|
function toBuffer(data: Buffer | Uint8Array): Buffer {
|
||||||
|
return Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разбирает сырые байты Bet-аккаунта (включая 8-байтовый дискриминатор).
|
||||||
|
* Бросает, если длина < 96.
|
||||||
|
*/
|
||||||
|
export function decodeBet(data: Buffer | Uint8Array): DecodedBet {
|
||||||
|
const buf = toBuffer(data);
|
||||||
|
if (buf.length < BET_ACCOUNT_SIZE) {
|
||||||
|
throw new Error(
|
||||||
|
`decodeBet: account data too short (${buf.length} < ${BET_ACCOUNT_SIZE})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const bettorBytes = buf.subarray(64, 96);
|
||||||
|
return {
|
||||||
|
amount: readU64LE(buf, 8),
|
||||||
|
betTime: readI64LE(buf, 16),
|
||||||
|
expireTime: readI64LE(buf, 24),
|
||||||
|
entryPrice: readU64LE(buf, 32),
|
||||||
|
exitPrice: readU64LE(buf, 40),
|
||||||
|
side: readU64LE(buf, 48),
|
||||||
|
status: readU64LE(buf, 56),
|
||||||
|
bettor: new PublicKey(bettorBytes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ищет открытую (status === OPEN) ставку по адресу беттора в списке аккаунтов.
|
||||||
|
* Возвращает `{ id }` первой попавшейся или `null`.
|
||||||
|
*
|
||||||
|
* `accounts` ожидается с уже-сопоставленным id (см. BetAccountEntry) — иначе
|
||||||
|
* из pubkey обратно id не вытащить (PDA — хэш). Списком рулит вызывающий код:
|
||||||
|
* планировщик получает id из Global.counter × betPda(0..counter-1), а в /bet
|
||||||
|
* достаточно знать Global.counter и проверять каждый PDA.
|
||||||
|
*/
|
||||||
|
export function openBetOf(
|
||||||
|
address: string | PublicKey,
|
||||||
|
accounts: ReadonlyArray<BetAccountEntry>
|
||||||
|
): { id: number } | null {
|
||||||
|
const target = typeof address === "string" ? address : address.toBase58();
|
||||||
|
for (const entry of accounts) {
|
||||||
|
const bet = decodeBet(entry.account.data);
|
||||||
|
if (bet.status !== STATUS_OPEN) continue;
|
||||||
|
if (bet.bettor.toBase58() !== target) continue;
|
||||||
|
return { id: entry.id };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает id'ы открытых ставок, у которых `expire_time * 1000 <= nowMs`
|
||||||
|
* (включительно). Сортирует по возрастанию id — детерминированный порядок
|
||||||
|
* важен для логов и для воспроизводимости тестов.
|
||||||
|
*/
|
||||||
|
export function dueForSettle(
|
||||||
|
accounts: ReadonlyArray<BetAccountEntry>,
|
||||||
|
nowMs: number
|
||||||
|
): Array<{ id: number }> {
|
||||||
|
const out: Array<{ id: number }> = [];
|
||||||
|
for (const entry of accounts) {
|
||||||
|
let bet: DecodedBet;
|
||||||
|
try {
|
||||||
|
bet = decodeBet(entry.account.data);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (bet.status !== STATUS_OPEN) continue;
|
||||||
|
const expireMs = bet.expireTime * 1000;
|
||||||
|
if (expireMs <= nowMs) {
|
||||||
|
out.push({ id: entry.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort((a, b) => a.id - b.id);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Объект WS-сообщения об открытии ставки. */
|
||||||
|
export function betOpenEvent(bet: DecodedBet, id: number): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
type: "bet_open",
|
||||||
|
id: id.toString(),
|
||||||
|
bettor: bet.bettor.toBase58(),
|
||||||
|
side: bet.side === SIDE_UP ? "UP" : "DOWN",
|
||||||
|
amountUnits: bet.amount.toString(),
|
||||||
|
entryPrice: bet.entryPrice,
|
||||||
|
expireTime: bet.expireTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusName(status: number): string {
|
||||||
|
if (status === STATUS_PAYOUT_DONE) return "payout_done";
|
||||||
|
if (status === STATUS_HOUSE_WON) return "house_won";
|
||||||
|
if (status === STATUS_REFUNDED) return "refunded";
|
||||||
|
return "open";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Объект WS-сообщения о закрытии ставки (status 1 или 2). */
|
||||||
|
export function betClosedEvent(
|
||||||
|
bet: DecodedBet,
|
||||||
|
id: number,
|
||||||
|
exitPrice: number,
|
||||||
|
status: number
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
type: "bet_closed",
|
||||||
|
id: id.toString(),
|
||||||
|
bettor: bet.bettor.toBase58(),
|
||||||
|
exitPrice,
|
||||||
|
status,
|
||||||
|
statusName: statusName(status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* п.8 ТЗ заказчика: при ничьей ставка молча играет заново. Игроку НЕ
|
||||||
|
* сообщаем про ничью — вместо `bet_closed` уходит это событие: новый
|
||||||
|
* раунд с тем же направлением и размером.
|
||||||
|
*/
|
||||||
|
export function betReplayEvent(
|
||||||
|
bet: DecodedBet,
|
||||||
|
id: number,
|
||||||
|
prevId: number,
|
||||||
|
replayCount: number
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
type: "bet_replay",
|
||||||
|
id: id.toString(),
|
||||||
|
prevId: prevId.toString(),
|
||||||
|
bettor: bet.bettor.toBase58(),
|
||||||
|
side: bet.side === SIDE_UP ? "UP" : "DOWN",
|
||||||
|
amountUnits: bet.amount.toString(),
|
||||||
|
entryPrice: bet.entryPrice,
|
||||||
|
expireTime: bet.expireTime,
|
||||||
|
replayCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Контекст для одного тика планировщика. Все побочные эффекты — через колбэки. */
|
||||||
|
export interface SchedulerCtx {
|
||||||
|
/**
|
||||||
|
* Цена на момент `expireMs` (или null, если в истории такой нет).
|
||||||
|
* Контракт: priceAtOrBefore(bet.expireTime * 1000).
|
||||||
|
*/
|
||||||
|
priceAtOrBefore: (expireMs: number) => number | null;
|
||||||
|
/** Закрытие ставки (relayer — admin, беттор — из Bet). */
|
||||||
|
closeBet: (id: number, exitPrice: number, bettor: PublicKey) => Promise<string>;
|
||||||
|
/** Рассылка WS-сообщения всем подключённым клиентам. */
|
||||||
|
broadcast: (msg: Record<string, unknown>) => void;
|
||||||
|
/** Перечитать аккаунт по id. */
|
||||||
|
fetchBet: (id: number) => Promise<DecodedBet | null>;
|
||||||
|
/** Загрузить все Bet-аккаунты программы (для dueForSettle) + число
|
||||||
|
* неcопоставленных (id ≥ counter — гонка с созданием). */
|
||||||
|
loadAllBets: () => Promise<{ entries: BetAccountEntry[]; unmapped: number }>;
|
||||||
|
/** Текущее время в мс (для тестов с детерминированной "сейчас"). */
|
||||||
|
nowMs: () => number;
|
||||||
|
/**
|
||||||
|
* Карта id → последнее залогированное состояние ставки
|
||||||
|
* (`"noPrice"` / `"retry"`). Если задана — `settleOnce` логирует только
|
||||||
|
* смену состояния, а не каждый тик; используется также как одноразовый
|
||||||
|
* флаг для предупреждения `unmapped` (под ключом -1). `startScheduler`
|
||||||
|
* создаёт карту сам; оффлайн-тесты вызывают `settleOnce` без неё —
|
||||||
|
* тогда поведение прежнее (всё логируется каждый тик).
|
||||||
|
*/
|
||||||
|
lastState?: Map<number, string>;
|
||||||
|
/**
|
||||||
|
* Пересоздать ставку тем же размером и направлением (п.8: ничья играет
|
||||||
|
* заново). Возвращает id новой ставки и подпись транзакции. Если хук не
|
||||||
|
* задан — переигровка недоступна, ведём себя как раньше (`bet_closed`).
|
||||||
|
*/
|
||||||
|
replayBet?: (args: {
|
||||||
|
side: number;
|
||||||
|
amount: number;
|
||||||
|
bettor: PublicKey;
|
||||||
|
}) => Promise<{ id: BN; tx: string }>;
|
||||||
|
/**
|
||||||
|
* Лимит переигровок на одну цепочку ничьих. Дефолт 10. Цепочка
|
||||||
|
* обрывается на следующей ничьей после исчерпания лимита.
|
||||||
|
*/
|
||||||
|
maxReplays?: number;
|
||||||
|
/**
|
||||||
|
* Карта id → счётчик переигровок для цепочки ничьих. На первой ничьей
|
||||||
|
* для ставки счётчик = 1, при пересоздании передаётся на новый id.
|
||||||
|
* Зачищается при нормальном закрытии / обрыве лимита / ошибке хука.
|
||||||
|
* Если не задана — лимит всё равно проверяется через локальный
|
||||||
|
* счётчик (в границах одного тика).
|
||||||
|
*/
|
||||||
|
replayCounts?: Map<number, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Один проход планировщика. Возвращает сводку для логов:
|
||||||
|
* { loaded, due, settled, noPrice, retry, errors }
|
||||||
|
*
|
||||||
|
* - loaded — сколько Bet-аккаунтов загружено;
|
||||||
|
* - due — сколько из них подлежат закрытию (status=0 && expired);
|
||||||
|
* - settled — успешно финализированы (статус сменился на 1 или 2);
|
||||||
|
* - noPrice — нет цены в истории на момент expire_time (пропуск);
|
||||||
|
* - retry — close прошёл, но статус остался OPEN (exit == entry, повтор);
|
||||||
|
* - errors — исключения внутри (не считая noPrice/retry).
|
||||||
|
*/
|
||||||
|
export async function settleOnce(ctx: SchedulerCtx): Promise<{
|
||||||
|
loaded: number;
|
||||||
|
due: number;
|
||||||
|
settled: number;
|
||||||
|
noPrice: number;
|
||||||
|
retry: number;
|
||||||
|
errors: number;
|
||||||
|
}> {
|
||||||
|
const stats = { loaded: 0, due: 0, settled: 0, noPrice: 0, retry: 0, errors: 0 };
|
||||||
|
/** Карта последних состояний — если задана, подавляет повторный спам
|
||||||
|
* и включает одноразовую печать «unmapped» (см. SchedulerCtx.lastState). */
|
||||||
|
const lastState = ctx.lastState;
|
||||||
|
|
||||||
|
let entries: BetAccountEntry[];
|
||||||
|
let unmapped = 0;
|
||||||
|
try {
|
||||||
|
const loaded = await ctx.loadAllBets();
|
||||||
|
entries = loaded.entries;
|
||||||
|
unmapped = loaded.unmapped;
|
||||||
|
} catch (e) {
|
||||||
|
stats.errors++;
|
||||||
|
console.error(
|
||||||
|
`settle: loadAllBets failed: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
);
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
stats.loaded = entries.length;
|
||||||
|
if (unmapped > 0) {
|
||||||
|
if (lastState) {
|
||||||
|
// С lastState — печатаем ОДИН раз за всё время работы планировщика,
|
||||||
|
// чтобы не забивать лог одной и той же строкой каждый тик.
|
||||||
|
if (!lastState.has(SENTINEL_UNMAPPED_LOGGED)) {
|
||||||
|
lastState.set(SENTINEL_UNMAPPED_LOGGED, "1");
|
||||||
|
console.log(`settle: ${unmapped} bet account(s) without mapped id (skipped)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Без lastState — прежнее поведение (каждый тик).
|
||||||
|
console.log(`settle: ${unmapped} bet account(s) without mapped id (skipped)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const due = dueForSettle(entries, ctx.nowMs());
|
||||||
|
stats.due = due.length;
|
||||||
|
|
||||||
|
// Чистим lastState от ставок, которые больше не due (закрылись или
|
||||||
|
// исчезли) — иначе карта растёт бесконечно.
|
||||||
|
if (lastState) {
|
||||||
|
const dueIds = new Set(due.map((d) => d.id));
|
||||||
|
for (const id of lastState.keys()) {
|
||||||
|
if (id < 0) continue; // sentinel, не реальный id
|
||||||
|
if (!dueIds.has(id)) lastState.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** За тик был хотя бы один новый лог (смена состояния или успешное закрытие). */
|
||||||
|
let emittedNew = false;
|
||||||
|
|
||||||
|
for (const item of due) {
|
||||||
|
const entry = entries.find((e) => e.id === item.id);
|
||||||
|
if (!entry) continue;
|
||||||
|
let bet: DecodedBet;
|
||||||
|
try {
|
||||||
|
bet = decodeBet(entry.account.data);
|
||||||
|
} catch {
|
||||||
|
stats.errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const expireMs = bet.expireTime * 1000;
|
||||||
|
const exitPrice = ctx.priceAtOrBefore(expireMs);
|
||||||
|
if (exitPrice === null) {
|
||||||
|
stats.noPrice++;
|
||||||
|
if (lastState) {
|
||||||
|
// Логируем только при СМЕНЕ состояния (или если записи ещё нет).
|
||||||
|
if (lastState.get(item.id) !== STATE_NO_PRICE) {
|
||||||
|
lastState.set(item.id, STATE_NO_PRICE);
|
||||||
|
console.log(
|
||||||
|
`settle: skip #${item.id} — no price history for expireTime=${bet.expireTime}`
|
||||||
|
);
|
||||||
|
emittedNew = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`settle: skip #${item.id} — no price history for expireTime=${bet.expireTime}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let txSig: string;
|
||||||
|
try {
|
||||||
|
txSig = await ctx.closeBet(item.id, exitPrice, bet.bettor);
|
||||||
|
} catch (e) {
|
||||||
|
stats.errors++;
|
||||||
|
console.error(
|
||||||
|
`settle: close #${item.id} failed: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = await ctx.fetchBet(item.id).catch(() => null);
|
||||||
|
if (!after) {
|
||||||
|
stats.errors++;
|
||||||
|
if (lastState) lastState.delete(item.id);
|
||||||
|
console.error(
|
||||||
|
`settle: #${item.id} account disappeared after close tx=${txSig}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (after.status === STATUS_OPEN) {
|
||||||
|
stats.retry++;
|
||||||
|
if (lastState) {
|
||||||
|
if (lastState.get(item.id) !== STATE_RETRY) {
|
||||||
|
lastState.set(item.id, STATE_RETRY);
|
||||||
|
console.log(
|
||||||
|
`settle: #${item.id} still open (exit==entry at ${exitPrice}), will retry`
|
||||||
|
);
|
||||||
|
emittedNew = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`settle: #${item.id} still open (exit==entry at ${exitPrice}), will retry`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
after.status === STATUS_PAYOUT_DONE ||
|
||||||
|
after.status === STATUS_HOUSE_WON ||
|
||||||
|
after.status === STATUS_REFUNDED
|
||||||
|
) {
|
||||||
|
stats.settled++;
|
||||||
|
if (lastState) lastState.delete(item.id);
|
||||||
|
|
||||||
|
// п.8: ничья + задан хук переигровки → пересоздаём ставку, иначе
|
||||||
|
// отправляем `bet_closed` (refunded) как раньше.
|
||||||
|
if (after.status === STATUS_REFUNDED && ctx.replayBet) {
|
||||||
|
const max = ctx.maxReplays ?? 10;
|
||||||
|
const counts = ctx.replayCounts;
|
||||||
|
const prevCount = counts?.get(item.id) ?? 0;
|
||||||
|
const nextCount = prevCount + 1;
|
||||||
|
if (nextCount > max) {
|
||||||
|
console.error(
|
||||||
|
`settle: replay chain limit (${max}) reached for #${item.id}, breaking chain`
|
||||||
|
);
|
||||||
|
counts?.delete(item.id);
|
||||||
|
ctx.broadcast(betClosedEvent(after, item.id, exitPrice, after.status));
|
||||||
|
emittedNew = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let replay: { id: BN; tx: string };
|
||||||
|
try {
|
||||||
|
replay = await ctx.replayBet({
|
||||||
|
side: after.side,
|
||||||
|
amount: after.amount,
|
||||||
|
bettor: after.bettor,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`settle: replay #${item.id} failed: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
);
|
||||||
|
counts?.delete(item.id);
|
||||||
|
// Игрок не должен зависнуть без ответа — отправляем bet_closed.
|
||||||
|
ctx.broadcast(betClosedEvent(after, item.id, exitPrice, after.status));
|
||||||
|
emittedNew = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Передаём счётчик на новую ставку — это продлевает цепочку.
|
||||||
|
counts?.delete(item.id);
|
||||||
|
let newBet: DecodedBet | null = null;
|
||||||
|
try {
|
||||||
|
newBet = await ctx.fetchBet(replay.id.toNumber());
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`settle: replay #${item.id} post-create fetch failed: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
);
|
||||||
|
ctx.broadcast(betClosedEvent(after, item.id, exitPrice, after.status));
|
||||||
|
emittedNew = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!newBet) {
|
||||||
|
console.error(
|
||||||
|
`settle: replay #${item.id} post-create bet #${replay.id.toString()} not found`
|
||||||
|
);
|
||||||
|
ctx.broadcast(betClosedEvent(after, item.id, exitPrice, after.status));
|
||||||
|
emittedNew = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (counts) counts.set(replay.id.toNumber(), nextCount);
|
||||||
|
const newId = replay.id.toNumber();
|
||||||
|
ctx.broadcast(betReplayEvent(newBet, newId, item.id, nextCount));
|
||||||
|
console.log(
|
||||||
|
`settle: refunded #${item.id} → replayed as #${newId} (chain count=${nextCount}) tx=${replay.tx}`
|
||||||
|
);
|
||||||
|
emittedNew = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAYOUT_DONE / HOUSE_WON / REFUNDED без хука — прежнее поведение.
|
||||||
|
if (ctx.replayCounts) ctx.replayCounts.delete(item.id);
|
||||||
|
ctx.broadcast(betClosedEvent(after, item.id, exitPrice, after.status));
|
||||||
|
console.log(
|
||||||
|
`settle: closed #${item.id} exit=${exitPrice} status=${after.status} tx=${txSig}`
|
||||||
|
);
|
||||||
|
emittedNew = true;
|
||||||
|
} else {
|
||||||
|
stats.errors++;
|
||||||
|
console.error(
|
||||||
|
`settle: #${item.id} unexpected status ${after.status} after close`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сводная строка тика — только если за тик было новое событие
|
||||||
|
// (смена состояния или успешное закрытие). Без lastState — прежнее
|
||||||
|
// поведение (печатаем при любом due.length > 0).
|
||||||
|
if (lastState) {
|
||||||
|
if (emittedNew) {
|
||||||
|
console.log(`settle: tick ${due.length} due (loaded=${entries.length})`);
|
||||||
|
}
|
||||||
|
} else if (due.length > 0) {
|
||||||
|
console.log(`settle: tick ${due.length} due (loaded=${entries.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запускает периодический планировщик. Возвращает handle для остановки.
|
||||||
|
* Каждый тик целиком в `try/catch` — ошибка внутри НЕ роняет процесс.
|
||||||
|
*/
|
||||||
|
export function startScheduler(
|
||||||
|
ctx: SchedulerCtx,
|
||||||
|
intervalMs: number
|
||||||
|
): { stop: () => void } {
|
||||||
|
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
||||||
|
throw new Error(`startScheduler: invalid intervalMs ${intervalMs}`);
|
||||||
|
}
|
||||||
|
// Карта последних состояний и счётчик переигровок живут в замыкании
|
||||||
|
// планировщика и передаются в каждый тик — см. settleOnce. Без них
|
||||||
|
// поведение прежнее (логируется всё каждый тик / переигровка ничьих
|
||||||
|
// недоступна).
|
||||||
|
const lastState = new Map<number, string>();
|
||||||
|
const replayCounts = new Map<number, number>();
|
||||||
|
const handle = setInterval(() => {
|
||||||
|
settleOnce({ ...ctx, lastState, replayCounts }).catch((e) => {
|
||||||
|
console.error(
|
||||||
|
`settle: tick crashed: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, intervalMs);
|
||||||
|
return {
|
||||||
|
stop: () => clearInterval(handle),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает все Bet-аккаунты программы с уже-сопоставленным id (для
|
||||||
|
* SchedulerCtx.loadAllBets). Делает ОДИН `getProgramAccounts(programId)` и
|
||||||
|
* сопоставляет pubkey → id локально через заранее посчитанную карту PDA.
|
||||||
|
*
|
||||||
|
* Обоснование выбора схемы: вместо N отдельных `getAccountInfo` по каждому
|
||||||
|
* PDA (линейно по сети и по времени, ~80 мс на 15 ставках) — один
|
||||||
|
* `getProgramAccounts`, который отдаёт всё разом (~9 мс на тех же 15
|
||||||
|
* ставках). Карта PDA → id считается локально, без сети. Аккаунты без
|
||||||
|
* пары в карте (id ≥ counter — гонка между чтением counter и этим
|
||||||
|
* запросом) пропускаются и считаются отдельно, чтобы планировщик мог
|
||||||
|
* напечатать об этом одну строку в логе.
|
||||||
|
*/
|
||||||
|
export async function loadAllBetsForProgram(
|
||||||
|
program: Program<SmartUpdown>,
|
||||||
|
programId: PublicKey,
|
||||||
|
counter: number
|
||||||
|
): Promise<{ entries: BetAccountEntry[]; unmapped: number }> {
|
||||||
|
const pdaToId = new Map<string, number>();
|
||||||
|
for (let i = 0; i < counter; i++) {
|
||||||
|
pdaToId.set(betPda(programId, new BN(i)).toBase58(), i);
|
||||||
|
}
|
||||||
|
const accounts = await program.provider.connection.getProgramAccounts(programId, {
|
||||||
|
commitment: "confirmed",
|
||||||
|
});
|
||||||
|
const entries: BetAccountEntry[] = [];
|
||||||
|
let unmapped = 0;
|
||||||
|
for (const { pubkey, account } of accounts) {
|
||||||
|
// Фильтр по размеру ДО сопоставления с картой id: getProgramAccounts
|
||||||
|
// отдаёт ВСЕ аккаунты программы — включая Global PDA (120 байт),
|
||||||
|
// который не Bet. Такие аккаунты НЕ считаются unmapped (это не гонка
|
||||||
|
// id ≥ counter, а просто другой тип аккаунта контракта).
|
||||||
|
if (account.data.length !== BET_ACCOUNT_SIZE) continue;
|
||||||
|
const id = pdaToId.get(pubkey.toBase58());
|
||||||
|
if (id === undefined) {
|
||||||
|
unmapped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entries.push({ pubkey, account: { data: account.data as Buffer }, id });
|
||||||
|
}
|
||||||
|
return { entries, unmapped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка ВСЕХ Bet-аккаунтов через цепочку — нужно для проверки
|
||||||
|
* «у адреса уже есть открытая ставка» в /bet. ТЗ требует читать
|
||||||
|
* из цепочки (а не из памяти процесса), потому что при рестарте пода
|
||||||
|
* процесс теряет состояние. Серверный фильтр Solana по полю `bettor`
|
||||||
|
* (offset 64, 32 байта) — один `getProgramAccounts`, статус проверяем
|
||||||
|
* в коде (`decodeBet(...).status === STATUS_OPEN`).
|
||||||
|
*
|
||||||
|
* ⚠️ Ловушка, проверенная живьём: фильтровать через
|
||||||
|
* `memcmp: { offset: 56, bytes: ... }` НЕЛЬЗЯ — `bytes` это base58
|
||||||
|
* **32-байтового** значения, а поле `status` занимает 8 байт, и сравнение
|
||||||
|
* молча промахивается. Поэтому фильтруем ТОЛЬКО по bettor (offset 64,
|
||||||
|
* 32 байта — как раз совпадает с длиной Pubkey), статус — в коде.
|
||||||
|
*
|
||||||
|
* Возвращает id первой открытой ставки данного беттора или null.
|
||||||
|
*/
|
||||||
|
export async function findOpenBetForBettorViaChain(
|
||||||
|
program: Program<SmartUpdown>,
|
||||||
|
programId: PublicKey,
|
||||||
|
counter: number,
|
||||||
|
bettor: string | PublicKey
|
||||||
|
): Promise<number | null> {
|
||||||
|
const target = typeof bettor === "string" ? bettor : bettor.toBase58();
|
||||||
|
const accounts = await program.provider.connection.getProgramAccounts(programId, {
|
||||||
|
commitment: "confirmed",
|
||||||
|
filters: [{ memcmp: { offset: 64, bytes: target } }],
|
||||||
|
});
|
||||||
|
const pdaToId = new Map<string, number>();
|
||||||
|
for (let i = 0; i < counter; i++) {
|
||||||
|
pdaToId.set(betPda(programId, new BN(i)).toBase58(), i);
|
||||||
|
}
|
||||||
|
for (const { pubkey, account } of accounts) {
|
||||||
|
let decoded: DecodedBet;
|
||||||
|
try {
|
||||||
|
decoded = decodeBet(account.data as Buffer);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (decoded.status !== STATUS_OPEN) continue;
|
||||||
|
const id = pdaToId.get(pubkey.toBase58());
|
||||||
|
if (id === undefined) continue;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user