Распаковать smart-updown.tar.gz: исходники по-человечески, архив удалён

This commit is contained in:
Hermes Agent
2026-09-01 19:41:06 +03:00
parent 5e78a4e36c
commit a9d54feab6
36 changed files with 7696 additions and 0 deletions
Binary file not shown.
+136
View File
@@ -0,0 +1,136 @@
# Smart-Updown — пакет развёртывания (localnet)
Бинарная игра UP/DOWN на цену SOL. Пользователь ставит на направление (UP/DOWN)
на 15 секунд; при выигрыше получает ставку + 30% (1.3x). Дом (контракт) — оппонент.
Цена берётся off-chain релеером (он же админ) с вашей БД; SOL хранится в ваулте (PDA `global`).
Программа: **Anchor v2 (fork `otter-sec/anchor`, ветка `anchor-next`)**, Solana, Rust.
---
## Состав пакета
```
smart-updown/
├── deploy/
│ ├── smart_updown.so # скомпилированная программа (SBF-байткод, кроссплатформенный)
│ └── smart_updown-keypair.json # authority-ключар программы (ID = E7Hg55...)
├── idl/
│ ├── smart_updown.json # IDL (для клиента)
│ └── smart_updown.ts # типы TypeScript для клиента
├── relayer/ # off-chain релеер (админ + закрытие ставок)
│ ├── package.json
│ ├── tsconfig.json
│ ├── .env.example
│ ├── idl/ # копия IDL для клиента
│ └── src/
│ ├── index.ts # главный цикл
│ ├── ops.ts # initialize / createBet / closeBet / fundVault
│ ├── pda.ts # вывод PDA
│ ├── program.ts # клиент из IDL
│ ├── priceSource.ts # ЗАГЛУШКА цены SOL (вставить свой запрос к БД)
│ └── frontStub.ts # ЗАГЛУШКА фронта (откуда приходит выбор пользователя)
├── program/ # исходники контракта (опционально, для пересборки)
│ ├── Anchor.toml
│ ├── Cargo.toml / Cargo.lock
│ ├── rust-toolchain.toml # пинит Rust 1.89.0
│ └── programs/smart-updown/{src,tests}
├── deploy-localnet.sh # развёртывание на localnet
└── README.md
```
---
## 1. Требования (Linux x86_64)
- **Solana CLI (Agave)** — даёт `solana-test-validator`, `solana program deploy`,
`cargo build-sbf`, `solana-keygen`. Установка:
`sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"`
- **Node.js 18+ / npm** — для relayer.
- (опционально) **Rust 1.89.0** через rustup — только если пересобираете программу.
> **Важно про `.so`**: это SBF-байткод и он не зависит от архитектуры хоста,
> поэтому `deploy/smart_updown.so`, собранный на macOS, **работает на Linux-валидаторе**
> как есть — пересобирать не нужно.
## 2. Развёртывание (localnet)
```bash
chmod +x deploy-localnet.sh
./deploy-localnet.sh
```
Скрипт:
1. переключает `solana config` на `http://127.0.0.1:8899`;
2. запускает `solana-test-validator` с программой (`--bpf-program E7Hg55...`);
3. проверяет, что программа поднята под ID `E7Hg55ywRDKRZJ35rDMJGC3YFThQbh6nAK9GDayG4sr3`.
Подытог — программа доступна на localnet под тем же адресом, что зашит в IDL.
## 3. Relayer (админ + закрытие ставок)
```bash
cd relayer
npm install
cp .env.example .env # при желании поправить значения
# инициализация контракта (ваулт/Global):
npm run init
# один прогон ставки:
npm run run
# непрерывный цикл:
npm run loop
```
`init` создаёт `Global` (админ + релеер = keypair из `SMART_ADMIN_KEYPAIR`,
по умолчанию `~/.config/solana/id.json`). На localnet скрипт сам делает airDrop
bettor'ам и догружает ваулт банка. На mainnet airDrop выключите
(`SMART_ALLOW_AIRDROP=0`) и пополняйте ваулт переводами с админа.
> AirDrop на localnet выполняется от keypair, настроенного в `solana config`.
> Убедитесь, что он существует и имеет SOL (в скрипте деплоя это `id.json`,
> см. `solana config get`).
## 4. Заглушки — куда вставить реальное
- `relayer/src/priceSource.ts` — функция `getPrice()`. **Замените** на запрос цены
SOL из вашей БД. Единицы любые, главное согласованность между entry и exit.
- `relayer/src/frontStub.ts` — функция `nextBetRequest()`. **Замените** на приём
со стороны фронта: направление (UP/DOWN) и сумма, которые выбрал пользователь.
## 5. Деплой в новом окружении (если уже есть валидатор, без перезапуска)
```bash
solana config set --url http://127.0.0.1:8899
solana program deploy --program-id deploy/smart_updown-keypair.json deploy/smart_updown.so
```
## 6. (Опционально) Пересборка программы из исходников + тесты
Та же команда, что использовалась для сборки:
```bash
cd program
rustup toolchain install 1.89.0 # если нет
# собрать .so:
cargo build-sbf --manifest-path programs/smart-updown/Cargo.toml
# артефакт появится в program/target/deploy/smart_updown.so
# интеграционные тесты (LiteSVM), без валидатора:
cargo test -p smart-updown
```
Если пересобираете, IDL не меняется, пока не меняете layout аккаунтов/инструкций.
После изменения контракта: пересоберите `.so`, перегенерируйте IDL
(`anchor build`, при наличии anchor-cli 2.0.0-rc.1) и обновите `relayer/idl/`
и `idl/`.
## 7. Переменные окружения relayer (см. .env.example)
| Переменная | По умолчанию | Описание |
|---|---|---|
| `SMART_RPC_URL` | `http://127.0.0.1:8899` | RPC кластера |
| `SMART_ALLOW_AIRDROP` | `1` | airDrop bettors/ваулта (localnet) |
| `SMART_ADMIN_KEYPAIR` | `~/.config/solana/id.json` | keypair админа/релеера |
| `SMART_MIN_AMOUNT` | `100000000` | мин. ставка (лампорты) |
| `SMART_MAX_AMOUNT` | `10000000000` | макс. ставка (лампорты) |
| `SMART_MULTIPLIER_BPS` | `13000` | выплата 1.3x (bps) |
| `SMART_EXPIRY_SECONDS` | `15` | экспирация (сек) |
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
#
# Развёртывание smart-updown на localnet (solana-test-validator).
# Использование: ./deploy-localnet.sh
#
# Требования: Solana CLI (solana-test-validator, solana program deploy).
# Если нужно пересобрать программу из исходников, см. README (опционально).
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
PROGRAM_SO="$HERE/deploy/smart_updown.so"
PROGRAM_KEYPAIR="$HERE/deploy/smart_updown-keypair.json"
PROGRAM_ID="E7Hg55ywRDKRZJ35rDMJGC3YFThQbh6nAK9GDayG4sr3"
RPC_URL="http://127.0.0.1:8899"
# 1. Настройка CLI на localhost
solana config set --url "$RPC_URL"
solana-keygen pubkey "$PROGRAM_KEYPAIR" >/dev/null 2>&1 || {
echo "Ошибка: нет deploy/smart_updown-keypair.json"
exit 1
}
# 2. Запуск валидатора с предзагруженной программой (если ещё не запущен)
if ! solana cluster-version >/dev/null 2>&1; then
echo ">>> Стартую solana-test-validator с программой $PROGRAM_ID"
solana-test-validator --reset \
--bpf-program "$PROGRAM_ID" "$PROGRAM_SO" \
>test-ledger.log 2>&1 &
sleep 6
fi
solana cluster-version
# 3. Убеждаемся, что программа на месте
if ! solana program show "$PROGRAM_ID" >/dev/null 2>&1; then
echo ">>> Деплою программу"
solana program deploy --program-id "$PROGRAM_KEYPAIR" "$PROGRAM_SO"
else
echo ">>> Программа уже задеплоена: $PROGRAM_ID"
fi
solana program show "$PROGRAM_ID" | head -12
echo
echo "Готово. Vault/Global инициализируется первым запуском relayer: npm run init"
echo "Дальше: npm run loop (непрерывно) или npm run run (один прогон)."
echo
@@ -0,0 +1 @@
[39,64,110,209,237,123,147,255,190,74,164,207,160,225,89,109,119,197,167,34,86,233,138,227,63,17,57,104,123,10,58,140,194,196,68,144,108,208,236,142,221,255,163,163,238,139,47,184,50,184,127,42,175,209,36,41,202,28,56,58,98,37,116,204]
Binary file not shown.
+546
View File
@@ -0,0 +1,546 @@
{
"address": "E7Hg55ywRDKRZJ35rDMJGC3YFThQbh6nAK9GDayG4sr3",
"metadata": {
"name": "smart_updown",
"version": "0.1.0",
"spec": "0.1.0",
"description": "Created with Anchor"
},
"instructions": [
{
"name": "close_bet",
"discriminator": [
2
],
"accounts": [
{
"name": "relayer",
"signer": true
},
{
"name": "bet",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
98,
101,
116
]
},
{
"kind": "arg",
"path": "id"
}
]
}
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
},
{
"name": "bettor",
"docs": [
"CHECK: recipient must be the bet's bettor and is validated in the handler."
],
"writable": true
},
{
"name": "clock",
"address": "SysvarC1ock11111111111111111111111111111111"
}
],
"args": [
{
"name": "id",
"type": "u64"
},
{
"name": "exit_price",
"type": "u64"
}
]
},
{
"name": "create_bet",
"discriminator": [
1
],
"accounts": [
{
"name": "bettor",
"writable": true,
"signer": true
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
},
{
"name": "bet",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
98,
101,
116
]
},
{
"kind": "arg",
"path": "id"
}
]
}
},
{
"name": "clock",
"address": "SysvarC1ock11111111111111111111111111111111"
},
{
"name": "system_program",
"address": "11111111111111111111111111111111"
}
],
"args": [
{
"name": "id",
"type": "u64"
},
{
"name": "side",
"type": "u64"
},
{
"name": "amount",
"type": "u64"
},
{
"name": "entry_price",
"type": "u64"
}
]
},
{
"name": "initialize",
"discriminator": [
0
],
"accounts": [
{
"name": "admin",
"writable": true,
"signer": true
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
},
{
"name": "system_program",
"address": "11111111111111111111111111111111"
}
],
"args": [
{
"name": "min_amount",
"type": "u64"
},
{
"name": "max_amount",
"type": "u64"
},
{
"name": "multiplier_bps",
"type": "u64"
},
{
"name": "expiry_seconds",
"type": "i64"
}
]
},
{
"name": "update",
"discriminator": [
3
],
"accounts": [
{
"name": "admin",
"signer": true
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
}
],
"args": [
{
"name": "new_relayer",
"type": "pubkey"
},
{
"name": "min_amount",
"type": "u64"
},
{
"name": "max_amount",
"type": "u64"
},
{
"name": "multiplier_bps",
"type": "u64"
},
{
"name": "paused",
"type": "u64"
},
{
"name": "expiry_seconds",
"type": "i64"
}
]
}
],
"accounts": [
{
"name": "Bet",
"discriminator": [
147,
23,
35,
59,
15,
75,
155,
32
]
},
{
"name": "Global",
"discriminator": [
167,
232,
232,
177,
200,
108,
114,
127
]
}
],
"events": [
{
"name": "BetClosed",
"discriminator": [
122,
20,
157,
254,
221,
0,
39,
129
]
},
{
"name": "BetCreated",
"discriminator": [
32,
153,
105,
71,
188,
72,
107,
114
]
}
],
"errors": [
{
"code": 6000,
"name": "Paused",
"msg": "Operation is paused"
},
{
"code": 6001,
"name": "NotAdmin",
"msg": "Only the admin can perform this action"
},
{
"code": 6002,
"name": "NotRelayer",
"msg": "Only the relayer is allowed to close bets"
},
{
"code": 6003,
"name": "AmountBelowMin",
"msg": "Amount below the allowed minimum"
},
{
"code": 6004,
"name": "AmountAboveMax",
"msg": "Amount above the allowed maximum"
},
{
"code": 6005,
"name": "InvalidSide",
"msg": "Invalid side value"
},
{
"code": 6006,
"name": "BetAlreadyFinalized",
"msg": "Bet is already finalized"
},
{
"code": 6007,
"name": "NotExpired",
"msg": "Bet cannot be closed before expiry"
},
{
"code": 6008,
"name": "BetIdMismatch",
"msg": "Bet id mismatch"
},
{
"code": 6009,
"name": "InsufficientVault",
"msg": "Payout exceeds vault solvency"
}
],
"types": [
{
"name": "Bet",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"type": {
"kind": "struct",
"fields": [
{
"name": "amount",
"type": "u64"
},
{
"name": "bet_time",
"type": "i64"
},
{
"name": "expire_time",
"type": "i64"
},
{
"name": "entry_price",
"type": "u64"
},
{
"name": "exit_price",
"type": "u64"
},
{
"name": "side",
"type": "u64"
},
{
"name": "status",
"type": "u64"
},
{
"name": "bettor",
"type": "pubkey"
}
]
}
},
{
"name": "BetClosed",
"type": {
"kind": "struct",
"fields": [
{
"name": "bet",
"type": "pubkey"
},
{
"name": "exit_price",
"type": "u64"
},
{
"name": "user_won",
"type": "bool"
},
{
"name": "payout",
"type": "u64"
},
{
"name": "status",
"type": "u64"
}
]
}
},
{
"name": "BetCreated",
"type": {
"kind": "struct",
"fields": [
{
"name": "bet",
"type": "pubkey"
},
{
"name": "bettor",
"type": "pubkey"
},
{
"name": "side",
"type": "u64"
},
{
"name": "amount",
"type": "u64"
},
{
"name": "bet_time",
"type": "i64"
},
{
"name": "expire_time",
"type": "i64"
},
{
"name": "entry_price",
"type": "u64"
}
]
}
},
{
"name": "Global",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"type": {
"kind": "struct",
"fields": [
{
"name": "counter",
"type": "u64"
},
{
"name": "min_amount",
"type": "u64"
},
{
"name": "max_amount",
"type": "u64"
},
{
"name": "multiplier_bps",
"type": "u64"
},
{
"name": "expiry_seconds",
"type": "i64"
},
{
"name": "paused",
"type": "u64"
},
{
"name": "admin",
"type": "pubkey"
},
{
"name": "relayer",
"type": "pubkey"
}
]
}
}
],
"constants": [
{
"name": "BET_SEED",
"type": "bytes",
"value": "[98, 101, 116]"
},
{
"name": "GLOBAL_SEED",
"type": "bytes",
"value": "[103, 108, 111, 98, 97, 108]"
}
]
}
+552
View File
@@ -0,0 +1,552 @@
/**
* Program IDL in camelCase format in order to be used in JS/TS.
*
* Note that this is only a type helper and is not the actual IDL. The original
* IDL can be found at `target/idl/smart_updown.json`.
*/
export type SmartUpdown = {
"address": "E7Hg55ywRDKRZJ35rDMJGC3YFThQbh6nAK9GDayG4sr3",
"metadata": {
"name": "smartUpdown",
"version": "0.1.0",
"spec": "0.1.0",
"description": "Created with Anchor"
},
"instructions": [
{
"name": "closeBet",
"discriminator": [
2
],
"accounts": [
{
"name": "relayer",
"signer": true
},
{
"name": "bet",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
98,
101,
116
]
},
{
"kind": "arg",
"path": "id"
}
]
}
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
},
{
"name": "bettor",
"docs": [
"CHECK: recipient must be the bet's bettor and is validated in the handler."
],
"writable": true
},
{
"name": "clock",
"address": "SysvarC1ock11111111111111111111111111111111"
}
],
"args": [
{
"name": "id",
"type": "u64"
},
{
"name": "exitPrice",
"type": "u64"
}
]
},
{
"name": "createBet",
"discriminator": [
1
],
"accounts": [
{
"name": "bettor",
"writable": true,
"signer": true
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
},
{
"name": "bet",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
98,
101,
116
]
},
{
"kind": "arg",
"path": "id"
}
]
}
},
{
"name": "clock",
"address": "SysvarC1ock11111111111111111111111111111111"
},
{
"name": "systemProgram",
"address": "11111111111111111111111111111111"
}
],
"args": [
{
"name": "id",
"type": "u64"
},
{
"name": "side",
"type": "u64"
},
{
"name": "amount",
"type": "u64"
},
{
"name": "entryPrice",
"type": "u64"
}
]
},
{
"name": "initialize",
"discriminator": [
0
],
"accounts": [
{
"name": "admin",
"writable": true,
"signer": true
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
},
{
"name": "systemProgram",
"address": "11111111111111111111111111111111"
}
],
"args": [
{
"name": "minAmount",
"type": "u64"
},
{
"name": "maxAmount",
"type": "u64"
},
{
"name": "multiplierBps",
"type": "u64"
},
{
"name": "expirySeconds",
"type": "i64"
}
]
},
{
"name": "update",
"discriminator": [
3
],
"accounts": [
{
"name": "admin",
"signer": true
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
}
],
"args": [
{
"name": "newRelayer",
"type": "pubkey"
},
{
"name": "minAmount",
"type": "u64"
},
{
"name": "maxAmount",
"type": "u64"
},
{
"name": "multiplierBps",
"type": "u64"
},
{
"name": "paused",
"type": "u64"
},
{
"name": "expirySeconds",
"type": "i64"
}
]
}
],
"accounts": [
{
"name": "bet",
"discriminator": [
147,
23,
35,
59,
15,
75,
155,
32
]
},
{
"name": "global",
"discriminator": [
167,
232,
232,
177,
200,
108,
114,
127
]
}
],
"events": [
{
"name": "betClosed",
"discriminator": [
122,
20,
157,
254,
221,
0,
39,
129
]
},
{
"name": "betCreated",
"discriminator": [
32,
153,
105,
71,
188,
72,
107,
114
]
}
],
"errors": [
{
"code": 6000,
"name": "paused",
"msg": "Operation is paused"
},
{
"code": 6001,
"name": "notAdmin",
"msg": "Only the admin can perform this action"
},
{
"code": 6002,
"name": "notRelayer",
"msg": "Only the relayer is allowed to close bets"
},
{
"code": 6003,
"name": "amountBelowMin",
"msg": "Amount below the allowed minimum"
},
{
"code": 6004,
"name": "amountAboveMax",
"msg": "Amount above the allowed maximum"
},
{
"code": 6005,
"name": "invalidSide",
"msg": "Invalid side value"
},
{
"code": 6006,
"name": "betAlreadyFinalized",
"msg": "Bet is already finalized"
},
{
"code": 6007,
"name": "notExpired",
"msg": "Bet cannot be closed before expiry"
},
{
"code": 6008,
"name": "betIdMismatch",
"msg": "Bet id mismatch"
},
{
"code": 6009,
"name": "insufficientVault",
"msg": "Payout exceeds vault solvency"
}
],
"types": [
{
"name": "bet",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"type": {
"kind": "struct",
"fields": [
{
"name": "amount",
"type": "u64"
},
{
"name": "betTime",
"type": "i64"
},
{
"name": "expireTime",
"type": "i64"
},
{
"name": "entryPrice",
"type": "u64"
},
{
"name": "exitPrice",
"type": "u64"
},
{
"name": "side",
"type": "u64"
},
{
"name": "status",
"type": "u64"
},
{
"name": "bettor",
"type": "pubkey"
}
]
}
},
{
"name": "betClosed",
"type": {
"kind": "struct",
"fields": [
{
"name": "bet",
"type": "pubkey"
},
{
"name": "exitPrice",
"type": "u64"
},
{
"name": "userWon",
"type": "bool"
},
{
"name": "payout",
"type": "u64"
},
{
"name": "status",
"type": "u64"
}
]
}
},
{
"name": "betCreated",
"type": {
"kind": "struct",
"fields": [
{
"name": "bet",
"type": "pubkey"
},
{
"name": "bettor",
"type": "pubkey"
},
{
"name": "side",
"type": "u64"
},
{
"name": "amount",
"type": "u64"
},
{
"name": "betTime",
"type": "i64"
},
{
"name": "expireTime",
"type": "i64"
},
{
"name": "entryPrice",
"type": "u64"
}
]
}
},
{
"name": "global",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"type": {
"kind": "struct",
"fields": [
{
"name": "counter",
"type": "u64"
},
{
"name": "minAmount",
"type": "u64"
},
{
"name": "maxAmount",
"type": "u64"
},
{
"name": "multiplierBps",
"type": "u64"
},
{
"name": "expirySeconds",
"type": "i64"
},
{
"name": "paused",
"type": "u64"
},
{
"name": "admin",
"type": "pubkey"
},
{
"name": "relayer",
"type": "pubkey"
}
]
}
}
],
"constants": [
{
"name": "betSeed",
"type": "bytes",
"value": "[98, 101, 116]"
},
{
"name": "globalSeed",
"type": "bytes",
"value": "[103, 108, 111, 98, 97, 108]"
}
]
};
+8
View File
@@ -0,0 +1,8 @@
.anchor
.DS_Store
target
**/*.rs.bk
node_modules
test-ledger
.yarn
.surfpool
+20
View File
@@ -0,0 +1,20 @@
skip_local_validator = true
[toolchain]
package_manager = "npm"
[features]
resolution = true
skip-lint = false
[programs.localnet]
smart_updown = "E7Hg55ywRDKRZJ35rDMJGC3YFThQbh6nAK9GDayG4sr3"
[provider]
cluster = "localnet"
wallet = "~/.config/solana/id.json"
[scripts]
test = "cargo test"
[hooks]
+4041
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[workspace]
members = [
"programs/*"
]
resolver = "2"
[workspace.package]
edition = "2021"
rust-version = "1.89.0"
[profile.release]
overflow-checks = true
lto = "fat"
codegen-units = 1
[profile.release.build-override]
opt-level = 3
incremental = false
codegen-units = 1
@@ -0,0 +1,33 @@
[package]
name = "smart-updown"
version = "0.1.0"
description = "Created with Anchor"
edition.workspace = true
rust-version.workspace = true
[lib]
crate-type = ["cdylib", "lib"]
name = "smart_updown"
[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-log-ix-name = []
idl-build = []
profile = ["anchor-v2-testing/profile"]
[dependencies]
# Once anchor-lang is published to crates.io, swap to: anchor-lang = "2.0.0-rc.1"
anchor-lang = { git = "https://github.com/otter-sec/anchor.git", branch = "anchor-next" }
solana-program-log = { version = "1.1", features = ["macro"] }
wincode = { version = "0.5", features = ["derive"] }
[dev-dependencies]
anchor-v2-testing = { git = "https://github.com/otter-sec/anchor.git", branch = "anchor-next" }
solana-clock = "3.0.1"
solana-pubkey = "3.0.0"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
@@ -0,0 +1,16 @@
use anchor_lang::prelude::*;
#[constant]
pub const GLOBAL_SEED: &[u8] = b"global";
#[constant]
pub const BET_SEED: &[u8] = b"bet";
pub const SIDE_UP: u64 = 0;
pub const SIDE_DOWN: u64 = 1;
pub const STATUS_OPEN: u64 = 0;
pub const STATUS_PAYOUT_DONE: u64 = 1;
pub const STATUS_HOUSE_WON: u64 = 2;
pub const BPS_DIVISOR: u64 = 10_000;
@@ -0,0 +1,34 @@
use anchor_lang::prelude::*;
#[error_code]
pub enum SmartUpdownError {
#[msg("Operation is paused")]
Paused,
#[msg("Only the admin can perform this action")]
NotAdmin,
#[msg("Only the relayer is allowed to close bets")]
NotRelayer,
#[msg("Amount below the allowed minimum")]
AmountBelowMin,
#[msg("Amount above the allowed maximum")]
AmountAboveMax,
#[msg("Invalid side value")]
InvalidSide,
#[msg("Bet is already finalized")]
BetAlreadyFinalized,
#[msg("Bet cannot be closed before expiry")]
NotExpired,
#[msg("Bet id mismatch")]
BetIdMismatch,
#[msg("Payout exceeds vault solvency")]
InsufficientVault,
}
@@ -0,0 +1,21 @@
use anchor_lang::prelude::*;
#[event]
pub struct BetCreated {
pub bet: Address,
pub bettor: Address,
pub side: u64,
pub amount: u64,
pub bet_time: i64,
pub expire_time: i64,
pub entry_price: u64,
}
#[event]
pub struct BetClosed {
pub bet: Address,
pub exit_price: u64,
pub user_won: bool,
pub payout: u64,
pub status: u64,
}
@@ -0,0 +1,10 @@
pub mod close_bet;
pub mod create_bet;
pub mod initialize;
pub mod update;
#[allow(ambiguous_glob_reexports)]
pub use close_bet::*;
pub use create_bet::*;
pub use initialize::*;
pub use update::*;
@@ -0,0 +1,95 @@
use anchor_lang::{prelude::*, Lamports};
use crate::{
constants::{
BPS_DIVISOR, BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_HOUSE_WON,
STATUS_PAYOUT_DONE, STATUS_OPEN,
},
error::SmartUpdownError,
events::BetClosed,
state::{Bet, Global},
};
#[derive(Accounts)]
#[instruction(id: u64, exit_price: u64)]
pub struct CloseBet {
pub relayer: Signer,
#[account(
mut,
seeds = [BET_SEED, id.to_le_bytes()],
bump
)]
pub bet: Account<Bet>,
#[account(
mut,
seeds = [GLOBAL_SEED],
bump
)]
pub global: Account<Global>,
/// CHECK: recipient must be the bet's bettor and is validated in the handler.
#[account(mut)]
pub bettor: SystemAccount,
pub clock: Sysvar<Clock>,
}
pub fn handler(ctx: &mut Context<CloseBet>, id: u64, exit_price: u64) -> Result<()> {
require!(
anchor_lang::address_eq(ctx.accounts.relayer.address(), &ctx.accounts.global.relayer),
SmartUpdownError::NotRelayer
);
require!(
anchor_lang::address_eq(ctx.accounts.bettor.address(), &ctx.accounts.bet.bettor),
SmartUpdownError::NotRelayer
);
require!(
ctx.accounts.bet.status == STATUS_OPEN,
SmartUpdownError::BetAlreadyFinalized
);
let now = ctx.accounts.clock.unix_timestamp;
require!(now >= ctx.accounts.bet.expire_time, SmartUpdownError::NotExpired);
let entry = ctx.accounts.bet.entry_price;
let side = ctx.accounts.bet.side;
let amount = ctx.accounts.bet.amount;
if exit_price == entry {
return Ok(());
}
let user_won = (side == SIDE_UP && exit_price > entry)
|| (side == SIDE_DOWN && exit_price < entry);
let payout;
if user_won {
let multiplier = ctx.accounts.global.multiplier_bps;
let payout_amount =
(amount as u128)
.checked_mul(multiplier as u128)
.unwrap()
.checked_div(BPS_DIVISOR as u128)
.unwrap() as u64;
require!(
ctx.accounts.global.view().lamports() >= payout_amount,
SmartUpdownError::InsufficientVault
);
payout = payout_amount;
ctx.accounts.global.sub_lamports(payout)?;
ctx.accounts.bettor.add_lamports(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;
emit!(BetClosed {
bet: *ctx.accounts.bet.address(),
exit_price,
user_won,
payout,
status: ctx.accounts.bet.status,
});
Ok(())
}
@@ -0,0 +1,93 @@
use anchor_lang::prelude::*;
use crate::{
constants::{BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_OPEN},
error::SmartUpdownError,
events::BetCreated,
state::{Bet, Global},
};
#[derive(Accounts)]
#[instruction(id: u64, side: u64, amount: u64, entry_price: u64)]
pub struct CreateBet {
#[account(mut)]
pub bettor: Signer,
#[account(
mut,
seeds = [GLOBAL_SEED],
bump
)]
pub global: Account<Global>,
#[account(
init,
payer = bettor,
seeds = [BET_SEED, id.to_le_bytes()],
bump
)]
pub bet: Account<Bet>,
pub clock: Sysvar<Clock>,
pub system_program: Program<System>,
}
pub fn handler(
ctx: &mut Context<CreateBet>,
id: u64,
side: u64,
amount: u64,
entry_price: u64,
) -> Result<()> {
require!(ctx.accounts.global.paused == 0, SmartUpdownError::Paused);
require!(side == SIDE_UP || side == SIDE_DOWN, SmartUpdownError::InvalidSide);
require!(
amount >= ctx.accounts.global.min_amount,
SmartUpdownError::AmountBelowMin
);
require!(
amount <= ctx.accounts.global.max_amount,
SmartUpdownError::AmountAboveMax
);
require!(
id == ctx.accounts.global.counter,
SmartUpdownError::BetIdMismatch
);
let now = ctx.accounts.clock.unix_timestamp;
let bettor_address = *ctx.accounts.bettor.address();
let bet_address = *ctx.accounts.bet.address();
let expiry_seconds = ctx.accounts.global.expiry_seconds;
{
let bet = &mut ctx.accounts.bet;
bet.bettor = bettor_address;
bet.side = side;
bet.amount = amount;
bet.bet_time = now;
bet.expire_time = now + expiry_seconds;
bet.entry_price = entry_price;
bet.exit_price = 0;
bet.status = STATUS_OPEN;
}
ctx.accounts.global.counter = ctx.accounts.global.counter.checked_add(1).unwrap();
{
let cpi_accounts = system_program::Transfer {
from: ctx.accounts.bettor.cpi_handle_mut(),
to: ctx.accounts.global.cpi_handle_mut(),
};
let cpi_ctx = CpiContext::new(ctx.accounts.system_program.address(), cpi_accounts);
system_program::transfer(cpi_ctx, amount)?;
}
emit!(BetCreated {
bet: bet_address,
bettor: bettor_address,
side,
amount,
bet_time: now,
expire_time: now + expiry_seconds,
entry_price,
});
Ok(())
}
@@ -0,0 +1,36 @@
use anchor_lang::prelude::*;
use crate::{constants::GLOBAL_SEED, state::Global};
#[derive(Accounts)]
pub struct Initialize {
#[account(mut)]
pub admin: Signer,
#[account(
init,
payer = admin,
seeds = [GLOBAL_SEED],
bump
)]
pub global: Account<Global>,
pub system_program: Program<System>,
}
pub fn handler(
ctx: &mut Context<Initialize>,
min_amount: u64,
max_amount: u64,
multiplier_bps: u64,
expiry_seconds: i64,
) -> Result<()> {
let global = &mut ctx.accounts.global;
global.admin = *ctx.accounts.admin.address();
global.relayer = *ctx.accounts.admin.address();
global.counter = 0;
global.min_amount = min_amount;
global.max_amount = max_amount;
global.multiplier_bps = multiplier_bps;
global.paused = 0;
global.expiry_seconds = expiry_seconds;
Ok(())
}
@@ -0,0 +1,41 @@
use anchor_lang::prelude::*;
use crate::{
constants::GLOBAL_SEED,
error::SmartUpdownError,
state::Global,
};
#[derive(Accounts)]
pub struct Update {
pub admin: Signer,
#[account(
mut,
seeds = [GLOBAL_SEED],
bump
)]
pub global: Account<Global>,
}
pub fn handler(
ctx: &mut Context<Update>,
new_relayer: Address,
min_amount: u64,
max_amount: u64,
multiplier_bps: u64,
paused: u64,
expiry_seconds: i64,
) -> Result<()> {
require!(
anchor_lang::address_eq(ctx.accounts.admin.address(), &ctx.accounts.global.admin),
SmartUpdownError::NotAdmin
);
let global = &mut ctx.accounts.global;
global.relayer = new_relayer;
global.min_amount = min_amount;
global.max_amount = max_amount;
global.multiplier_bps = multiplier_bps;
global.paused = paused;
global.expiry_seconds = expiry_seconds;
Ok(())
}
@@ -0,0 +1,68 @@
pub mod constants;
pub mod error;
pub mod events;
pub mod instructions;
pub mod state;
use anchor_lang::prelude::*;
pub use instructions::*;
declare_id!("E7Hg55ywRDKRZJ35rDMJGC3YFThQbh6nAK9GDayG4sr3");
#[program]
pub mod smart_updown {
use super::*;
#[discrim = 0]
pub fn initialize(
ctx: &mut Context<Initialize>,
min_amount: u64,
max_amount: u64,
multiplier_bps: u64,
expiry_seconds: i64,
) -> Result<()> {
initialize::handler(ctx, min_amount, max_amount, multiplier_bps, expiry_seconds)
}
#[discrim = 1]
pub fn create_bet(
ctx: &mut Context<CreateBet>,
id: u64,
side: u64,
amount: u64,
entry_price: u64,
) -> Result<()> {
create_bet::handler(ctx, id, side, amount, entry_price)
}
#[discrim = 2]
pub fn close_bet(
ctx: &mut Context<CloseBet>,
id: u64,
exit_price: u64,
) -> Result<()> {
close_bet::handler(ctx, id, exit_price)
}
#[discrim = 3]
pub fn update(
ctx: &mut Context<Update>,
new_relayer: Address,
min_amount: u64,
max_amount: u64,
multiplier_bps: u64,
paused: u64,
expiry_seconds: i64,
) -> Result<()> {
update::handler(
ctx,
new_relayer,
min_amount,
max_amount,
multiplier_bps,
paused,
expiry_seconds,
)
}
}
@@ -0,0 +1,25 @@
use anchor_lang::prelude::*;
#[account]
pub struct Global {
pub counter: u64,
pub min_amount: u64,
pub max_amount: u64,
pub multiplier_bps: u64,
pub expiry_seconds: i64,
pub paused: u64,
pub admin: Address,
pub relayer: Address,
}
#[account]
pub struct Bet {
pub amount: u64,
pub bet_time: i64,
pub expire_time: i64,
pub entry_price: u64,
pub exit_price: u64,
pub side: u64,
pub status: u64,
pub bettor: Address,
}
@@ -0,0 +1,283 @@
use {
anchor_lang::{
bytemuck, cpi::find_program_address, solana_program::instruction::Instruction, Id,
InstructionData, ToAccountMetas,
},
anchor_v2_testing::{Keypair, LiteSVM, Message, Signer, VersionedMessage, VersionedTransaction},
smart_updown::state::{Bet, Global},
};
const CLOCK_ADDR: &str = "SysvarC1ock11111111111111111111111111111111";
const SYSTEM_ADDR: &str = "11111111111111111111111111111111";
const MIN_AMOUNT: u64 = 1_000_000_000; // 1 SOL
const MULTIPLIER_BPS: u64 = 13_000; // 1.3x
const EXPIRY_SECONDS: i64 = 15;
type Addr = anchor_lang::prelude::Address;
fn pid() -> Addr {
smart_updown::id()
}
fn global_pda() -> Addr {
find_program_address(&[b"global"], &pid()).0
}
fn bet_pda(id: u64) -> Addr {
find_program_address(&[b"bet", &id.to_le_bytes()], &pid()).0
}
fn setup() -> (LiteSVM, Keypair) {
let mut svm = LiteSVM::new();
let bytes = include_bytes!("../../../target/deploy/smart_updown.so");
svm.add_program(pid(), bytes).expect("deploy program");
let payer = Keypair::new();
svm.airdrop(&payer.pubkey(), 100_000_000_000).unwrap();
(svm, payer)
}
fn send(svm: &mut LiteSVM, signers: &[&Keypair], instruction: Instruction) -> Result<(), String> {
let blockhash = svm.latest_blockhash();
let msg = Message::new_with_blockhash(&[instruction], Some(&signers[0].pubkey()), &blockhash);
let tx = VersionedTransaction::try_new(VersionedMessage::Legacy(msg), signers).unwrap();
let res = svm.send_transaction(tx);
res.map(|_| ()).map_err(|e| format!("{:?}", e))
}
fn initialize(svm: &mut LiteSVM, admin: &Keypair) {
let ix = Instruction::new_with_bytes(
pid(),
&smart_updown::instruction::Initialize {
min_amount: MIN_AMOUNT,
max_amount: 1_000_000_000_000,
multiplier_bps: MULTIPLIER_BPS,
expiry_seconds: EXPIRY_SECONDS,
}
.data(),
smart_updown::accounts::Initialize {
admin: admin.pubkey(),
global: global_pda(),
system_program: SYSTEM_ADDR.parse().unwrap(),
}
.to_account_metas(None),
);
send(svm, &[admin], ix).expect("initialize");
}
fn set_clock(svm: &mut LiteSVM, unix: i64) {
use solana_clock::Clock;
let mut clk = svm.get_sysvar::<Clock>();
clk.unix_timestamp = unix;
svm.set_sysvar(&clk);
}
fn fund_vault(svm: &mut LiteSVM, lamports: u64) {
svm.airdrop(&global_pda(), lamports).unwrap();
}
fn read_global(svm: &LiteSVM) -> Global {
let acc = svm.get_account(&global_pda()).expect("global account");
*bytemuck::from_bytes::<Global>(&acc.data[8..])
}
fn read_bet(svm: &LiteSVM, id: u64) -> Bet {
let acc = svm.get_account(&bet_pda(id)).expect("bet account");
*bytemuck::from_bytes::<Bet>(&acc.data[8..])
}
fn make_create_ix(id: u64, side: u64, amount: u64, entry_price: u64, bettor: Addr) -> Instruction {
Instruction::new_with_bytes(
pid(),
&smart_updown::instruction::CreateBet {
id,
side,
amount,
entry_price,
}
.data(),
smart_updown::accounts::CreateBet {
bettor,
global: global_pda(),
bet: bet_pda(id),
clock: CLOCK_ADDR.parse().unwrap(),
system_program: SYSTEM_ADDR.parse().unwrap(),
}
.to_account_metas(None),
)
}
fn make_close_ix(id: u64, exit_price: u64, relayer: Addr, bettor: Addr) -> Instruction {
Instruction::new_with_bytes(
pid(),
&smart_updown::instruction::CloseBet { id, exit_price }.data(),
smart_updown::accounts::CloseBet {
relayer,
bet: bet_pda(id),
global: global_pda(),
bettor,
clock: CLOCK_ADDR.parse().unwrap(),
}
.to_account_metas(None),
)
}
fn create_up_bet(svm: &mut LiteSVM, admin: &Keypair, side: u64) -> (Keypair, u64, u64) {
let bettor = Keypair::new();
svm.airdrop(&bettor.pubkey(), 100_000_000_000).unwrap();
set_clock(svm, 1_700_000_000);
let amount = MIN_AMOUNT;
let entry_price = 100_000_000u64;
let create_ix = make_create_ix(0, side, amount, entry_price, bettor.pubkey());
send(svm, &[&bettor], create_ix).expect("create_bet");
(bettor, amount, entry_price)
}
#[test]
fn initialize_creates_global() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let g = read_global(&svm);
assert_eq!(g.admin, admin.pubkey());
assert_eq!(g.relayer, admin.pubkey());
assert_eq!(g.counter, 0);
assert_eq!(g.multiplier_bps, MULTIPLIER_BPS);
assert_eq!(g.expiry_seconds, EXPIRY_SECONDS);
assert_eq!(g.paused, 0);
}
#[test]
fn create_bet_escrows_sol() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
let vault_balance = svm.get_balance(&global_pda()).unwrap();
assert!(vault_balance >= amount);
let b = read_bet(&svm, 0);
assert_eq!(b.bettor, bettor.pubkey());
assert_eq!(b.side, 0);
assert_eq!(b.amount, amount);
assert_eq!(b.entry_price, entry_price);
assert_eq!(b.bet_time, 1_700_000_000);
assert_eq!(b.expire_time, 1_700_000_000 + EXPIRY_SECONDS);
assert_eq!(b.status, 0);
assert_eq!(read_global(&svm).counter, 1);
}
#[test]
fn close_bet_user_wins_up() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
set_clock(&mut svm, 1_700_000_000 + EXPIRY_SECONDS + 1);
let exit_price = entry_price + 1_000;
let before = svm.get_balance(&bettor.pubkey()).unwrap();
let payout = amount * MULTIPLIER_BPS / 10_000;
fund_vault(&mut svm, 10 * amount);
let close_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey());
send(&mut svm, &[&admin], close_ix).expect("close_bet");
assert_eq!(svm.get_balance(&bettor.pubkey()).unwrap(), before + payout);
let b = read_bet(&svm, 0);
assert_eq!(b.status, 1);
assert_eq!(b.exit_price, exit_price);
}
#[test]
fn close_bet_house_wins_down() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, _amount, entry_price) = create_up_bet(&mut svm, &admin, 1);
set_clock(&mut svm, 1_700_000_000 + EXPIRY_SECONDS + 1);
let exit_price = entry_price + 1_000; // side=DOWN but price rose -> lose
let before = svm.get_balance(&bettor.pubkey()).unwrap();
let close_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey());
send(&mut svm, &[&admin], close_ix).expect("close_bet");
assert_eq!(svm.get_balance(&bettor.pubkey()).unwrap(), before);
assert_eq!(read_bet(&svm, 0).status, 2);
}
#[test]
fn close_bet_equal_price_stays_open() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
set_clock(&mut svm, 1_700_000_000 + EXPIRY_SECONDS + 1);
let before = svm.get_balance(&bettor.pubkey()).unwrap();
let payout = amount * MULTIPLIER_BPS / 10_000;
fund_vault(&mut svm, 10 * amount);
let equal_ix = make_close_ix(0, entry_price, admin.pubkey(), bettor.pubkey());
send(&mut svm, &[&admin], equal_ix).expect("equal close");
assert_eq!(read_bet(&svm, 0).status, 0);
assert_eq!(svm.get_balance(&bettor.pubkey()).unwrap(), before);
let exit_price = entry_price + 1_000;
let second_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey());
send(&mut svm, &[&admin], second_ix).expect("second close");
assert_eq!(
svm.get_balance(&bettor.pubkey()).unwrap(),
before + payout
);
assert_eq!(read_bet(&svm, 0).status, 1);
}
#[test]
fn close_bet_before_expiry_rejected() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, _amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey());
let res = send(&mut svm, &[&admin], close_ix);
assert!(res.is_err(), "expected premature close to be rejected");
assert_eq!(read_bet(&svm, 0).status, 0);
}
#[test]
fn close_bet_wrong_relayer_rejected() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, _amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
let attacker = Keypair::new();
svm.airdrop(&attacker.pubkey(), 100_000_000_000).unwrap();
set_clock(&mut svm, 1_700_000_000 + EXPIRY_SECONDS + 1);
let close_ix = make_close_ix(0, entry_price + 500, attacker.pubkey(), bettor.pubkey());
let res = send(&mut svm, &[&attacker], close_ix);
assert!(res.is_err(), "expected wrong relayer to be rejected");
assert_eq!(read_bet(&svm, 0).status, 0);
}
#[test]
fn close_bet_replay_rejected() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let (bettor, _amount, entry_price) = create_up_bet(&mut svm, &admin, 0);
set_clock(&mut svm, 1_700_000_000 + EXPIRY_SECONDS + 1);
fund_vault(&mut svm, 10 * MIN_AMOUNT);
let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey());
send(&mut svm, &[&admin], close_ix).expect("first close");
let res = send(&mut svm, &[&admin], make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey()));
assert!(res.is_err(), "expected replay close to be rejected");
}
#[test]
fn create_bet_rejects_out_of_range() {
let (mut svm, admin) = setup();
initialize(&mut svm, &admin);
let bettor = Keypair::new();
svm.airdrop(&bettor.pubkey(), 100_000_000_000).unwrap();
set_clock(&mut svm, 1_700_000_000);
let ix = make_create_ix(0, 0, 1, 100_000_000u64, bettor.pubkey());
let res = send(&mut svm, &[&bettor], ix);
assert!(res.is_err(), "expected below-min bet to be rejected");
}
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "1.89.0"
components = ["rustfmt","clippy"]
profile = "minimal"
+14
View File
@@ -0,0 +1,14 @@
# RPC эндпоинт кластера (localnet = запущенный solana-test-validator)
SMART_RPC_URL=http://127.0.0.1:8899
# Разрешить airDrop на localnet (bettor'ам и ваулту). На mainnet выключить.
SMART_ALLOW_AIRDROP=1
# Путь к keypair админа/релеера (тот же ключ = admin и relayer)
SMART_ADMIN_KEYPAIR=~/.config/solana/id.json
# Параметры контракта (должны совпадать со значениями при initialize)
SMART_MIN_AMOUNT=100000000 # 0.1 SOL в лампортах
SMART_MAX_AMOUNT=10000000000 # 10 SOL в лампортах
SMART_MULTIPLIER_BPS=13000 # 1.3x выплата в базисных пунктах
SMART_EXPIRY_SECONDS=15 # экспирация ставки (секунды)
+546
View File
@@ -0,0 +1,546 @@
{
"address": "E7Hg55ywRDKRZJ35rDMJGC3YFThQbh6nAK9GDayG4sr3",
"metadata": {
"name": "smart_updown",
"version": "0.1.0",
"spec": "0.1.0",
"description": "Created with Anchor"
},
"instructions": [
{
"name": "close_bet",
"discriminator": [
2
],
"accounts": [
{
"name": "relayer",
"signer": true
},
{
"name": "bet",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
98,
101,
116
]
},
{
"kind": "arg",
"path": "id"
}
]
}
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
},
{
"name": "bettor",
"docs": [
"CHECK: recipient must be the bet's bettor and is validated in the handler."
],
"writable": true
},
{
"name": "clock",
"address": "SysvarC1ock11111111111111111111111111111111"
}
],
"args": [
{
"name": "id",
"type": "u64"
},
{
"name": "exit_price",
"type": "u64"
}
]
},
{
"name": "create_bet",
"discriminator": [
1
],
"accounts": [
{
"name": "bettor",
"writable": true,
"signer": true
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
},
{
"name": "bet",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
98,
101,
116
]
},
{
"kind": "arg",
"path": "id"
}
]
}
},
{
"name": "clock",
"address": "SysvarC1ock11111111111111111111111111111111"
},
{
"name": "system_program",
"address": "11111111111111111111111111111111"
}
],
"args": [
{
"name": "id",
"type": "u64"
},
{
"name": "side",
"type": "u64"
},
{
"name": "amount",
"type": "u64"
},
{
"name": "entry_price",
"type": "u64"
}
]
},
{
"name": "initialize",
"discriminator": [
0
],
"accounts": [
{
"name": "admin",
"writable": true,
"signer": true
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
},
{
"name": "system_program",
"address": "11111111111111111111111111111111"
}
],
"args": [
{
"name": "min_amount",
"type": "u64"
},
{
"name": "max_amount",
"type": "u64"
},
{
"name": "multiplier_bps",
"type": "u64"
},
{
"name": "expiry_seconds",
"type": "i64"
}
]
},
{
"name": "update",
"discriminator": [
3
],
"accounts": [
{
"name": "admin",
"signer": true
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
}
],
"args": [
{
"name": "new_relayer",
"type": "pubkey"
},
{
"name": "min_amount",
"type": "u64"
},
{
"name": "max_amount",
"type": "u64"
},
{
"name": "multiplier_bps",
"type": "u64"
},
{
"name": "paused",
"type": "u64"
},
{
"name": "expiry_seconds",
"type": "i64"
}
]
}
],
"accounts": [
{
"name": "Bet",
"discriminator": [
147,
23,
35,
59,
15,
75,
155,
32
]
},
{
"name": "Global",
"discriminator": [
167,
232,
232,
177,
200,
108,
114,
127
]
}
],
"events": [
{
"name": "BetClosed",
"discriminator": [
122,
20,
157,
254,
221,
0,
39,
129
]
},
{
"name": "BetCreated",
"discriminator": [
32,
153,
105,
71,
188,
72,
107,
114
]
}
],
"errors": [
{
"code": 6000,
"name": "Paused",
"msg": "Operation is paused"
},
{
"code": 6001,
"name": "NotAdmin",
"msg": "Only the admin can perform this action"
},
{
"code": 6002,
"name": "NotRelayer",
"msg": "Only the relayer is allowed to close bets"
},
{
"code": 6003,
"name": "AmountBelowMin",
"msg": "Amount below the allowed minimum"
},
{
"code": 6004,
"name": "AmountAboveMax",
"msg": "Amount above the allowed maximum"
},
{
"code": 6005,
"name": "InvalidSide",
"msg": "Invalid side value"
},
{
"code": 6006,
"name": "BetAlreadyFinalized",
"msg": "Bet is already finalized"
},
{
"code": 6007,
"name": "NotExpired",
"msg": "Bet cannot be closed before expiry"
},
{
"code": 6008,
"name": "BetIdMismatch",
"msg": "Bet id mismatch"
},
{
"code": 6009,
"name": "InsufficientVault",
"msg": "Payout exceeds vault solvency"
}
],
"types": [
{
"name": "Bet",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"type": {
"kind": "struct",
"fields": [
{
"name": "amount",
"type": "u64"
},
{
"name": "bet_time",
"type": "i64"
},
{
"name": "expire_time",
"type": "i64"
},
{
"name": "entry_price",
"type": "u64"
},
{
"name": "exit_price",
"type": "u64"
},
{
"name": "side",
"type": "u64"
},
{
"name": "status",
"type": "u64"
},
{
"name": "bettor",
"type": "pubkey"
}
]
}
},
{
"name": "BetClosed",
"type": {
"kind": "struct",
"fields": [
{
"name": "bet",
"type": "pubkey"
},
{
"name": "exit_price",
"type": "u64"
},
{
"name": "user_won",
"type": "bool"
},
{
"name": "payout",
"type": "u64"
},
{
"name": "status",
"type": "u64"
}
]
}
},
{
"name": "BetCreated",
"type": {
"kind": "struct",
"fields": [
{
"name": "bet",
"type": "pubkey"
},
{
"name": "bettor",
"type": "pubkey"
},
{
"name": "side",
"type": "u64"
},
{
"name": "amount",
"type": "u64"
},
{
"name": "bet_time",
"type": "i64"
},
{
"name": "expire_time",
"type": "i64"
},
{
"name": "entry_price",
"type": "u64"
}
]
}
},
{
"name": "Global",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"type": {
"kind": "struct",
"fields": [
{
"name": "counter",
"type": "u64"
},
{
"name": "min_amount",
"type": "u64"
},
{
"name": "max_amount",
"type": "u64"
},
{
"name": "multiplier_bps",
"type": "u64"
},
{
"name": "expiry_seconds",
"type": "i64"
},
{
"name": "paused",
"type": "u64"
},
{
"name": "admin",
"type": "pubkey"
},
{
"name": "relayer",
"type": "pubkey"
}
]
}
}
],
"constants": [
{
"name": "BET_SEED",
"type": "bytes",
"value": "[98, 101, 116]"
},
{
"name": "GLOBAL_SEED",
"type": "bytes",
"value": "[103, 108, 111, 98, 97, 108]"
}
]
}
+552
View File
@@ -0,0 +1,552 @@
/**
* Program IDL in camelCase format in order to be used in JS/TS.
*
* Note that this is only a type helper and is not the actual IDL. The original
* IDL can be found at `target/idl/smart_updown.json`.
*/
export type SmartUpdown = {
"address": "E7Hg55ywRDKRZJ35rDMJGC3YFThQbh6nAK9GDayG4sr3",
"metadata": {
"name": "smartUpdown",
"version": "0.1.0",
"spec": "0.1.0",
"description": "Created with Anchor"
},
"instructions": [
{
"name": "closeBet",
"discriminator": [
2
],
"accounts": [
{
"name": "relayer",
"signer": true
},
{
"name": "bet",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
98,
101,
116
]
},
{
"kind": "arg",
"path": "id"
}
]
}
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
},
{
"name": "bettor",
"docs": [
"CHECK: recipient must be the bet's bettor and is validated in the handler."
],
"writable": true
},
{
"name": "clock",
"address": "SysvarC1ock11111111111111111111111111111111"
}
],
"args": [
{
"name": "id",
"type": "u64"
},
{
"name": "exitPrice",
"type": "u64"
}
]
},
{
"name": "createBet",
"discriminator": [
1
],
"accounts": [
{
"name": "bettor",
"writable": true,
"signer": true
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
},
{
"name": "bet",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
98,
101,
116
]
},
{
"kind": "arg",
"path": "id"
}
]
}
},
{
"name": "clock",
"address": "SysvarC1ock11111111111111111111111111111111"
},
{
"name": "systemProgram",
"address": "11111111111111111111111111111111"
}
],
"args": [
{
"name": "id",
"type": "u64"
},
{
"name": "side",
"type": "u64"
},
{
"name": "amount",
"type": "u64"
},
{
"name": "entryPrice",
"type": "u64"
}
]
},
{
"name": "initialize",
"discriminator": [
0
],
"accounts": [
{
"name": "admin",
"writable": true,
"signer": true
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
},
{
"name": "systemProgram",
"address": "11111111111111111111111111111111"
}
],
"args": [
{
"name": "minAmount",
"type": "u64"
},
{
"name": "maxAmount",
"type": "u64"
},
{
"name": "multiplierBps",
"type": "u64"
},
{
"name": "expirySeconds",
"type": "i64"
}
]
},
{
"name": "update",
"discriminator": [
3
],
"accounts": [
{
"name": "admin",
"signer": true
},
{
"name": "global",
"writable": true,
"pda": {
"seeds": [
{
"kind": "const",
"value": [
103,
108,
111,
98,
97,
108
]
}
]
}
}
],
"args": [
{
"name": "newRelayer",
"type": "pubkey"
},
{
"name": "minAmount",
"type": "u64"
},
{
"name": "maxAmount",
"type": "u64"
},
{
"name": "multiplierBps",
"type": "u64"
},
{
"name": "paused",
"type": "u64"
},
{
"name": "expirySeconds",
"type": "i64"
}
]
}
],
"accounts": [
{
"name": "bet",
"discriminator": [
147,
23,
35,
59,
15,
75,
155,
32
]
},
{
"name": "global",
"discriminator": [
167,
232,
232,
177,
200,
108,
114,
127
]
}
],
"events": [
{
"name": "betClosed",
"discriminator": [
122,
20,
157,
254,
221,
0,
39,
129
]
},
{
"name": "betCreated",
"discriminator": [
32,
153,
105,
71,
188,
72,
107,
114
]
}
],
"errors": [
{
"code": 6000,
"name": "paused",
"msg": "Operation is paused"
},
{
"code": 6001,
"name": "notAdmin",
"msg": "Only the admin can perform this action"
},
{
"code": 6002,
"name": "notRelayer",
"msg": "Only the relayer is allowed to close bets"
},
{
"code": 6003,
"name": "amountBelowMin",
"msg": "Amount below the allowed minimum"
},
{
"code": 6004,
"name": "amountAboveMax",
"msg": "Amount above the allowed maximum"
},
{
"code": 6005,
"name": "invalidSide",
"msg": "Invalid side value"
},
{
"code": 6006,
"name": "betAlreadyFinalized",
"msg": "Bet is already finalized"
},
{
"code": 6007,
"name": "notExpired",
"msg": "Bet cannot be closed before expiry"
},
{
"code": 6008,
"name": "betIdMismatch",
"msg": "Bet id mismatch"
},
{
"code": 6009,
"name": "insufficientVault",
"msg": "Payout exceeds vault solvency"
}
],
"types": [
{
"name": "bet",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"type": {
"kind": "struct",
"fields": [
{
"name": "amount",
"type": "u64"
},
{
"name": "betTime",
"type": "i64"
},
{
"name": "expireTime",
"type": "i64"
},
{
"name": "entryPrice",
"type": "u64"
},
{
"name": "exitPrice",
"type": "u64"
},
{
"name": "side",
"type": "u64"
},
{
"name": "status",
"type": "u64"
},
{
"name": "bettor",
"type": "pubkey"
}
]
}
},
{
"name": "betClosed",
"type": {
"kind": "struct",
"fields": [
{
"name": "bet",
"type": "pubkey"
},
{
"name": "exitPrice",
"type": "u64"
},
{
"name": "userWon",
"type": "bool"
},
{
"name": "payout",
"type": "u64"
},
{
"name": "status",
"type": "u64"
}
]
}
},
{
"name": "betCreated",
"type": {
"kind": "struct",
"fields": [
{
"name": "bet",
"type": "pubkey"
},
{
"name": "bettor",
"type": "pubkey"
},
{
"name": "side",
"type": "u64"
},
{
"name": "amount",
"type": "u64"
},
{
"name": "betTime",
"type": "i64"
},
{
"name": "expireTime",
"type": "i64"
},
{
"name": "entryPrice",
"type": "u64"
}
]
}
},
{
"name": "global",
"serialization": "bytemuck",
"repr": {
"kind": "c"
},
"type": {
"kind": "struct",
"fields": [
{
"name": "counter",
"type": "u64"
},
{
"name": "minAmount",
"type": "u64"
},
{
"name": "maxAmount",
"type": "u64"
},
{
"name": "multiplierBps",
"type": "u64"
},
{
"name": "expirySeconds",
"type": "i64"
},
{
"name": "paused",
"type": "u64"
},
{
"name": "admin",
"type": "pubkey"
},
{
"name": "relayer",
"type": "pubkey"
}
]
}
}
],
"constants": [
{
"name": "betSeed",
"type": "bytes",
"value": "[98, 101, 116]"
},
{
"name": "globalSeed",
"type": "bytes",
"value": "[103, 108, 111, 98, 97, 108]"
}
]
};
+23
View File
@@ -0,0 +1,23 @@
{
"name": "smart-updown-relayer",
"version": "0.1.0",
"private": true,
"license": "ISC",
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"init": "ts-node src/index.ts --init --once",
"run": "ts-node src/index.ts --once",
"loop": "ts-node src/index.ts"
},
"dependencies": {
"@anchor-lang/core": "^1.0.0",
"@solana/web3.js": "^1.98.0",
"bn.js": "^5.2.0"
},
"devDependencies": {
"@types/bn.js": "^5.2.0",
"@types/node": "^25.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
+65
View File
@@ -0,0 +1,65 @@
import { Connection, clusterApiUrl, Keypair, PublicKey } from "@solana/web3.js";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
export const SIDE_UP = 0;
export const SIDE_DOWN = 1;
/**
* Runtime configuration. Everything can be overridden via environment
* variables; defaults target the local validator.
*/
export interface Config {
/** RPC endpoint of the cluster (localnet by default). */
rpcUrl: string;
/** Localnet allows naive airdrops (to bettors and the house vault). */
allowAirdrop: boolean;
/** Program id. Defaults to the value embedded in the IDL. */
programId: PublicKey;
/** House admin/relayer keypair file path. */
adminKeypairPath: string;
/** Initial parameters used by `init` (must match on-chain constants). */
minAmountLamports: number;
maxAmountLamports: number;
multiplierBps: number;
expirySeconds: number;
}
function env(name: string, fallback: string): string {
return process.env[name] ?? fallback;
}
function envNum(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const n = Number(raw);
if (!Number.isFinite(n)) throw new Error(`Invalid number for env ${name}: ${raw}`);
return n;
}
export function loadConfig(): Config {
const rpcUrl = env("SMART_RPC_URL", "http://127.0.0.1:8899");
const allowAirdrop = env("SMART_ALLOW_AIRDROP", "1") === "1";
const adminKeypairPath =
env("SMART_ADMIN_KEYPAIR", path.join(os.homedir(), ".config/solana/id.json"));
return {
rpcUrl,
allowAirdrop,
programId: PublicKey.default, // placeholder; real value read from the IDL below
adminKeypairPath,
minAmountLamports: envNum("SMART_MIN_AMOUNT", 100_000_000), // 0.1 SOL
maxAmountLamports: envNum("SMART_MAX_AMOUNT", 10_000_000_000), // 10 SOL
multiplierBps: envNum("SMART_MULTIPLIER_BPS", 13_000), // 1.3x
expirySeconds: envNum("SMART_EXPIRY_SECONDS", 15),
};
}
export function loadKeypair(pathOrJson: string): Keypair {
const resolved = pathOrJson.startsWith("~")
? path.join(os.homedir(), pathOrJson.slice(1))
: path.resolve(pathOrJson);
const raw = fs.readFileSync(resolved, "utf8");
return Keypair.fromSecretKey(Uint8Array.from(JSON.parse(raw)));
}
+24
View File
@@ -0,0 +1,24 @@
import { Keypair } from "@solana/web3.js";
import { SIDE_DOWN, SIDE_UP } from "./config";
/**
* STUB — «фронт»: от пользователя приходят параметры ставки.
*
* В реальной системе это сообщение присылает фронтенд (или твоя БД) после
* того, как пользователь выбрал направление и сумму. Здесь мы эмулируем
* входящую очередь: случайный UP/DOWN и случайную сумму в заданном диапазоне.
*/
export interface BetRequest {
bettor: Keypair;
side: number; // SIDE_UP | SIDE_DOWN
amountLamports: number;
}
/** Очередь входящих ставок. В заглушке наполняем случайными значениями. */
export function nextBetRequest(minAmount: number, maxAmount: number): BetRequest {
const bettor = Keypair.generate();
const side = Math.random() < 0.5 ? SIDE_UP : SIDE_DOWN;
const amount =
minAmount + Math.floor(Math.random() * (maxAmount - minAmount + 1));
return { bettor, side, amountLamports: amount };
}
+136
View File
@@ -0,0 +1,136 @@
import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import { Program } from "@anchor-lang/core";
import { loadConfig, loadKeypair, SIDE_UP } from "./config";
import { getPrice } from "./priceSource";
import { nextBetRequest } from "./frontStub";
import {
initialize,
createBet,
closeBet,
readGlobal,
ensureVaultFunded,
} from "./ops";
import { betPda } from "./pda";
import { makeConnection, makeProgram } from "./program";
import type { SmartUpdown } from "../idl/smart_updown";
const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const STATUS_OPEN = 0;
async function fundBettor(connection: any, bettorPubkey: PublicKey): Promise<void> {
const bal = await connection.getBalance(bettorPubkey);
if (bal < 2 * LAMPORTS_PER_SOL_NUM) {
await connection.requestAirdrop(bettorPubkey, 5 * LAMPORTS_PER_SOL_NUM);
await sleep(800);
console.log(`bettor funded ${bettorPubkey.toBase58()} (+5 SOL)`);
}
}
/** Закрывает ставку, повторяя при «equal price» (exit==entry) пока не зафиналится. */
async function settleBet(
program: Program<SmartUpdown>,
id: any,
entryPrice: number,
relayer: any,
bettor: PublicKey
): Promise<void> {
for (let attempt = 1; ; attempt++) {
const exitPrice = await getPrice();
if (exitPrice === entryPrice) {
console.log(`attempt ${attempt}: exit==entry (${exitPrice}), жду движение...`);
await sleep(2000);
continue;
}
const sig = await closeBet(program, id, exitPrice, relayer, bettor);
console.log(`close_bet #${id} exit=${exitPrice} (attempt ${attempt}) tx=${sig}`);
const status = await getStatus(program, id);
if (status === STATUS_OPEN) {
console.log(`ставка ещё open (equal price), повторяю close...`);
await sleep(2000);
continue;
}
return;
}
}
async function getStatus(program: Program<SmartUpdown>, id: any): Promise<number> {
const bet = await program.account.bet.fetchNullable(betPda(program.programId, id));
return bet ? bet.status.toNumber() : -1;
}
async function main(): Promise<void> {
const cfg = loadConfig();
const admin = loadKeypair(cfg.adminKeypairPath);
const connection = makeConnection(cfg.rpcUrl);
const program = makeProgram(connection, admin);
console.log(`RPC: ${cfg.rpcUrl}`);
console.log(`Admin/relayer: ${admin.publicKey.toBase58()}`);
const global = await readGlobal(program, program.programId);
if (!global) {
if (!process.argv.includes("--init")) {
throw new Error(
"Контракт не инициализирован. Запусти с флагом --init (или init+run)."
);
}
await initialize(program, {
minAmount: cfg.minAmountLamports,
maxAmount: cfg.maxAmountLamports,
multiplierBps: cfg.multiplierBps,
expirySeconds: cfg.expirySeconds,
}, admin);
console.log("Контракт инициализирован.");
} else {
console.log(
`Global: counter=${global.counter} vault=${(global.lamports / LAMPORTS_PER_SOL_NUM).toFixed(2)} SOL ` +
`expiry=${global.expirySeconds}s paused=${global.paused}`
);
}
if (cfg.allowAirdrop) {
await ensureVaultFunded(connection, program, 10 * LAMPORTS_PER_SOL_NUM);
}
const loops = process.argv.includes("--once") ? 1 : Infinity;
let n = 0;
while (n < loops) {
n++;
const req = nextBetRequest(cfg.minAmountLamports, cfg.maxAmountLamports);
await fundBettor(connection, req.bettor.publicKey);
const entryPrice = await getPrice();
await createBet(program, req.bettor, {
side: req.side,
amount: req.amountLamports,
entryPrice,
});
console.log(
`ставка ${req.side === SIDE_UP ? "UP" : "DOWN"} на ${(req.amountLamports / LAMPORTS_PER_SOL_NUM).toFixed(3)} SOL, жду ${cfg.expirySeconds}s...`
);
await sleep((cfg.expirySeconds + 1) * 1000);
const g0 = await readGlobal(program, program.programId);
const id = g0!.counter.subn(1);
await settleBet(program, id, entryPrice, admin, req.bettor.publicKey);
const outcome = await program.account.bet.fetchNullable(betPda(program.programId, id));
console.log(
outcome!.status.toNumber() === 1
? `>> результат #${id}: ПОЛЬЗОВАТЕЛЬ ВЫИГРАЛ (payout 1.3x)`
: `>> результат #${id}: ВЫИГРАЛ ДОМ (user lost)`
);
}
console.log("Готово.");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+131
View File
@@ -0,0 +1,131 @@
import { Connection, Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import BN from "bn.js";
import { Program } from "@anchor-lang/core";
import { SIDE_UP } from "./config";
import { globalPda } from "./pda";
import type { SmartUpdown } from "../idl/smart_updown";
const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL);
/** Считывает account Global (ваулт). Возвращает null, если ещё не инициализирован. */
export async function readGlobal(
program: Program<SmartUpdown>,
programId: PublicKey
): Promise<{
counter: BN;
minAmount: BN;
maxAmount: BN;
multiplierBps: BN;
expirySeconds: BN;
paused: BN;
admin: PublicKey;
relayer: PublicKey;
lamports: number;
} | null> {
const global = globalPda(programId);
const raw = await program.account.global.fetchNullable(global);
if (!raw) return null;
const lamports = await program.provider.connection.getBalance(global);
return {
counter: raw.counter,
minAmount: raw.minAmount,
maxAmount: raw.maxAmount,
multiplierBps: raw.multiplierBps,
expirySeconds: raw.expirySeconds,
paused: raw.paused,
admin: raw.admin,
relayer: raw.relayer,
lamports,
};
}
/** Одноразовая инициализация контракта (вызывается admin'ом до приёма ставок). */
export async function initialize(
program: Program<SmartUpdown>,
args: {
minAmount: number;
maxAmount: number;
multiplierBps: number;
expirySeconds: number;
},
admin: Keypair
): Promise<string> {
const sig = await program.methods
.initialize(
new BN(args.minAmount),
new BN(args.maxAmount),
new BN(args.multiplierBps),
new BN(args.expirySeconds)
)
.accounts({
admin: admin.publicKey,
})
.signers([admin])
.rpc();
return sig;
}
/** Создаёт ставку от имени пользователя. Возвращает фактический id (== counter). */
export async function createBet(
program: Program<SmartUpdown>,
bettor: Keypair,
args: { side: number; amount: number; entryPrice: number }
): Promise<BN> {
const global = await readGlobal(program, program.programId);
if (!global) throw new Error("Contract not initialized");
const id = global.counter;
const sig = await program.methods
.createBet(new BN(id), new BN(args.side), new BN(args.amount), new BN(args.entryPrice))
.accounts({
bettor: bettor.publicKey,
})
.signers([bettor])
.rpc();
console.log(`create_bet #${id} side=${args.side === SIDE_UP ? "UP" : "DOWN"} ` +
`amount=${(args.amount / LAMPORTS_PER_SOL_NUM).toFixed(3)} SOL entry=${args.entryPrice} tx=${sig}`);
return id;
}
/**
* Закрывает ставку после экспирации.
* Если exit_price == entry_price, контракт не финализирует ставку — тогда
* ждём первое движение цены и повторяем close (см. обработку в main loop).
*/
export async function closeBet(
program: Program<SmartUpdown>,
id: BN,
exitPrice: number,
relayer: Keypair,
bettor: PublicKey
): Promise<string> {
return program.methods
.closeBet(new BN(id), new BN(exitPrice))
.accounts({
relayer: relayer.publicKey,
bettor,
})
.signers([relayer])
.rpc();
}
/**
* Догружает ваулт (house bankroll), чтобы хватило на выплаты 1.3x.
* На localnet используем airDrop на PDA ваулта; на mainnet воздух не накапает —
* там нужно переводить SOL с админа (off-chain, вне этого CLI).
*/
export async function ensureVaultFunded(
connection: Connection,
program: Program<SmartUpdown>,
amountLamports: number
): Promise<void> {
const global = globalPda(program.programId);
const current = await connection.getBalance(global);
const needed = amountLamports; // будущая выплата (до 1.3x), запасаем с запасом
if (current >= needed) return;
const topUp = needed - current + 5 * LAMPORTS_PER_SOL_NUM; // +5 SOL запаса
await connection.requestAirdrop(global, topUp);
console.log(`vault funded +${(topUp / LAMPORTS_PER_SOL_NUM).toFixed(2)} SOL`);
}
+16
View File
@@ -0,0 +1,16 @@
import { PublicKey } from "@solana/web3.js";
import BN from "bn.js";
/** Seeds, согласованные с контрактом (см. constants.rs). */
export const GLOBAL_SEED = Buffer.from("global");
export const BET_SEED = Buffer.from("bet");
export function globalPda(programId: PublicKey): PublicKey {
return PublicKey.findProgramAddressSync([GLOBAL_SEED], programId)[0];
}
export function betPda(programId: PublicKey, id: BN): PublicKey {
const idLE = Buffer.alloc(8);
idLE.writeBigUInt64LE(BigInt(id.toString(10)));
return PublicKey.findProgramAddressSync([BET_SEED, idLE], programId)[0];
}
+22
View File
@@ -0,0 +1,22 @@
/**
* STUB — SOL price source.
*
* TODO(пользователь): замени `getPrice` на реальный запрос к своей БД.
* Договоримся, что функция возвращает цену SOL как целое число в «центах»
* (то же юнит-пространство, что у веса entry/exit на контракте). Контракт
* сравнивает тол ько «больше/меньше/равно», поэтому само юнит-пространство
* не критично — главное, чтобы оно было согласовано между entry и exit.
*/
let simulated = 100_000_000;
/** Цена SOL сейчас (заглушка — детерминированно «гуляет»). */
export async function getPrice(): Promise<number> {
const drift = Math.round(Math.random() * 200_000); // ±0.2% движка
simulated += Math.random() < 0.5 ? drift : -drift;
return simulated;
}
/** Управляемая заглушка: вернуть ровно `value` (полезна для теста «equal price»). */
export function setPriceOverride(value: number | undefined): void {
simulated = value ?? 100_000_000;
}
+27
View File
@@ -0,0 +1,27 @@
import { Connection } from "@solana/web3.js";
import { AnchorProvider, Program, Wallet } from "@anchor-lang/core";
import { Keypair } from "@solana/web3.js";
import idl from "../idl/smart_updown.json";
import type { SmartUpdown } from "../idl/smart_updown";
/**
* Создаёт готовый клиент программы: Connection + AnchorProvider (подпись админа-
* релеера) + типизированный Program, собранный из IDL.
*/
export function makeProgram(
connection: Connection,
admin: Keypair
): Program<SmartUpdown> {
const provider = new AnchorProvider(
connection,
new Wallet(admin),
{ commitment: "confirmed", preflightCommitment: "confirmed" }
);
// IDL.address задаёт programId в рантайме.
return new Program<SmartUpdown>(idl as unknown as SmartUpdown, provider);
}
export function makeConnection(rpcUrl: string): Connection {
return new Connection(rpcUrl, "confirmed");
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"lib": ["es2020"],
"module": "commonjs",
"target": "es2020",
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*", "idl/**/*"]
}