Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd4f16a930 | |||
| 42dfa58582 | |||
| a73e44de9a | |||
| 0b136c6502 | |||
| 5ab6c9def5 | |||
| a3178cfa6e |
@@ -17,5 +17,8 @@ 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;
|
||||
/// Возврат залога при ничьей (`exit_price == entry_price`).
|
||||
/// Добавлен позже остальных — 0/1/2 уже зашиты в шлюз и в живые аккаунты цепи.
|
||||
pub const STATUS_REFUNDED: u64 = 3;
|
||||
|
||||
pub const BPS_DIVISOR: u64 = 10_000;
|
||||
|
||||
@@ -4,7 +4,7 @@ use anchor_spl::token::{Token, TokenAccount};
|
||||
use crate::{
|
||||
constants::{
|
||||
BPS_DIVISOR, BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_HOUSE_WON,
|
||||
STATUS_PAYOUT_DONE, STATUS_OPEN, VAULT_SEED,
|
||||
STATUS_PAYOUT_DONE, STATUS_OPEN, STATUS_REFUNDED, VAULT_SEED,
|
||||
},
|
||||
error::SmartUpdownError,
|
||||
events::BetClosed,
|
||||
@@ -64,6 +64,40 @@ pub fn handler(ctx: &mut Context<CloseBet>, id: u64, exit_price: u64) -> Result<
|
||||
let amount = ctx.accounts.bet.amount;
|
||||
|
||||
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(());
|
||||
}
|
||||
|
||||
|
||||
@@ -441,7 +441,7 @@ async fn close_bet_house_wins_down() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_bet_equal_price_stays_open() {
|
||||
async fn close_bet_equal_price_refunds() {
|
||||
let (mut ctx, admin) = setup().await;
|
||||
initialize(&mut ctx, &admin).await;
|
||||
let mint = create_token_env(&mut ctx, &admin).await;
|
||||
@@ -449,21 +449,35 @@ async fn close_bet_equal_price_stays_open() {
|
||||
|
||||
set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await;
|
||||
let before = read_token_balance(&mut ctx, &ata).await;
|
||||
let payout = amount * MULTIPLIER_BPS / 10_000;
|
||||
|
||||
let equal_ix = make_close_ix(0, entry_price, admin.pubkey(), bettor.pubkey(), &ata);
|
||||
run_close_tx(&mut ctx, &admin, &equal_ix).await.expect("equal close");
|
||||
assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 0);
|
||||
assert_eq!(read_token_balance(&mut ctx, &ata).await, before);
|
||||
run_close_tx(
|
||||
&mut ctx,
|
||||
&admin,
|
||||
&make_close_ix(0, entry_price, admin.pubkey(), bettor.pubkey(), &ata),
|
||||
)
|
||||
.await
|
||||
.expect("equal close");
|
||||
|
||||
let exit_price = entry_price + 1_000;
|
||||
let second_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey(), &ata);
|
||||
run_close_tx(&mut ctx, &admin, &second_ix).await.expect("second close");
|
||||
let b = read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await);
|
||||
assert_eq!(b.status, 3, "expected status REFUNDED=3");
|
||||
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!(
|
||||
read_token_balance(&mut ctx, &ata).await,
|
||||
before + payout
|
||||
read_token_balance(&mut ctx, &vault_pda()).await,
|
||||
VAULT_TOKEN_SUPPLY,
|
||||
"vault -amount"
|
||||
);
|
||||
assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).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");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+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-ошибок.
|
||||
@@ -14,6 +14,12 @@ spec:
|
||||
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 }}
|
||||
@@ -25,7 +31,7 @@ spec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- '[ -f /app/data/.gateway-bettors.json ] || cp /seed/.gateway-bettors.json /app/data/.gateway-bettors.json'
|
||||
- '[ -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
|
||||
@@ -49,6 +55,8 @@ spec:
|
||||
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
|
||||
@@ -61,10 +69,22 @@ spec:
|
||||
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
|
||||
|
||||
@@ -17,14 +17,27 @@ ingress:
|
||||
smart:
|
||||
rpcUrl: 'http://192.168.76.181:8899'
|
||||
programId: '9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf'
|
||||
tokenMint: '8dpXNUgWN4m8ohhi6dkVqw4XezbDF7oKDSgnbf2VKjQJ'
|
||||
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'
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+750
-2
@@ -5,15 +5,49 @@
|
||||
* 1. PDA-деривации (global/bet/vault) совпадают с седами IDL.
|
||||
* 2. Инструкции create_bet/close_bet содержат bettor_token / vault_token /
|
||||
* token_program с нужными адресами и writable-флагами.
|
||||
*
|
||||
* Запуск: `npm test`.
|
||||
* 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;
|
||||
@@ -42,6 +76,42 @@ function idlIx(name: string) {
|
||||
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");
|
||||
@@ -120,6 +190,668 @@ async function main(): Promise<void> {
|
||||
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);
|
||||
@@ -127,6 +859,22 @@ async function main(): Promise<void> {
|
||||
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);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Connection, clusterApiUrl, Keypair, PublicKey } from "@solana/web3.js";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { SmartMode, parseMode } from "./faucetGate";
|
||||
|
||||
export const SIDE_UP = 0;
|
||||
export const SIDE_DOWN = 1;
|
||||
@@ -13,6 +14,12 @@ export const SIDE_DOWN = 1;
|
||||
export interface Config {
|
||||
/** RPC endpoint of the cluster (localnet by default). */
|
||||
rpcUrl: string;
|
||||
/**
|
||||
* Режим работы релейера: "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.
|
||||
@@ -39,6 +46,25 @@ export interface Config {
|
||||
startBalance: number;
|
||||
multiplierBps: 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 {
|
||||
@@ -53,8 +79,20 @@ function envNum(name: string, fallback: number): number {
|
||||
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 {
|
||||
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 adminKeypairPath =
|
||||
env("SMART_ADMIN_KEYPAIR", path.join(os.homedir(), ".config/solana/id.json"));
|
||||
@@ -62,6 +100,7 @@ export function loadConfig(): Config {
|
||||
|
||||
return {
|
||||
rpcUrl,
|
||||
mode,
|
||||
allowAirdrop,
|
||||
programId: PublicKey.default, // placeholder; real value read from the IDL below
|
||||
// Плейсхолдер: настоящий адрес фантичного mint задаётся через SMART_TOKEN_MINT.
|
||||
@@ -71,9 +110,25 @@ export function loadConfig(): Config {
|
||||
maxAmount: envNum("SMART_MAX_AMOUNT", 10_000_000_000), // 10 000 фантиков
|
||||
multiplierBps: envNum("SMART_MULTIPLIER_BPS", 13_000), // 1.3x
|
||||
expirySeconds: envNum("SMART_EXPIRY_SECONDS", 15),
|
||||
// Локальная история цены (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;
|
||||
}
|
||||
+505
-145
@@ -3,43 +3,71 @@
|
||||
*
|
||||
* Шлюз — custodial: keypair'ы бетторов хранит у себя (keystore-файл
|
||||
* `.gateway-bettors.json` рядом с relayer/, в .gitignore) и подписывает
|
||||
* create_bet от их имени. Для /close ключи не нужны — подписывает
|
||||
* relayer (admin). Все сетевые операции — переиспользованные функции
|
||||
* create_bet от их имени. Все сетевые операции — переиспользованные функции
|
||||
* из ops.ts (createBet / closeBet / readGlobal / fundBettor / ...).
|
||||
*
|
||||
* Эндпоинты (CORS: Access-Control-Allow-Origin: *):
|
||||
* GET /state — Global: counter, min/max, multiplier, expiry, paused, vault
|
||||
* + информация о фиксированном бетторе (bettor / betPercent /
|
||||
* betAmountUnits / canBet / canBetReason / balance / openBet /
|
||||
* lastBet).
|
||||
* GET /price — текущая сырая цена SOL из NATS (для отладки); 503 если ещё нет
|
||||
* POST /bet {address?, side, amountUnits}
|
||||
* — address: custodial-беттор шлюза; если не задан — шлюз
|
||||
* генерирует новый и регистрирует в keystore.
|
||||
* Ответ: {tx, id, bettor, entryPrice}
|
||||
* POST /close {id} — close_bet по текущей цене (подпись relayer)
|
||||
* POST /wallet — создать custodial-кошелёк (только test-mode). Ответ: {address}.
|
||||
* НЕ наливает ни SOL, ни фантики — деньги отдельно через /faucet/{address}.
|
||||
* POST /bet {side} — ставка за захардкоженный беттор (GW_FIXED_BETTOR).
|
||||
* side: 0/"UP" или 1/"DOWN". Сумма НЕ передаётся — шлюз считает
|
||||
* её сам = GW_BET_PERCENT% от баланса фантиков беттора (с клампом
|
||||
* в [minAmount, maxAmount]). Адрес беттора тоже НЕ передаётся —
|
||||
* берётся из GW_FIXED_BETTOR. Если они присланы — молча
|
||||
* игнорируются (обратная совместимость с тестовым фронтом).
|
||||
* Ответ (всегда HTTP 200, кроме кривого side / не-JSON):
|
||||
* {accepted, reasonCode, reason, retryAfterSeconds,
|
||||
* betId, txSig, amountUnits, amountHuman, side,
|
||||
* entryPrice, expireTime, id, tx, bettor}
|
||||
* При accepted=false ставка НЕ создаётся, betId/txSig = null.
|
||||
* Закрытие ставки происходит автоматически по экспирации —
|
||||
* см. планировщик в `src/settle.ts`.
|
||||
* GET /balance/{address} — {sol, solLamports, tokenUnits, tokenHuman}
|
||||
* GET /faucet/{address} — налив SOL + фантиков на произвольный адрес
|
||||
* (только localnet, allowAirdrop=1; иначе 403). Ответ:
|
||||
* {address, sol, solLamports, tokenUnits, tokenHuman}
|
||||
* GET /ws (WebSocket) — push цены SOL из NATS; на подключение шлёт
|
||||
* снапшот (если цена уже есть), дальше — каждый тик.
|
||||
* GET /faucet/{address} — налив SOL + фантиков на ПРОИЗВОЛЬНЫЙ адрес (Phantom и т.п.).
|
||||
* Доступен ТОЛЬКО в test-режиме: SMART_MODE=test И не публичный
|
||||
* кластер (проверка по genesis hash: mainnet/devnet/testnet запрещены).
|
||||
* Иначе 403. Ответ: {address, sol, solLamports, tokenUnits, tokenHuman}
|
||||
* GET /ws (WebSocket) — push цены SOL из NATS + WS-события жизненного цикла
|
||||
* ставки (`bet_open` / `bet_closed` / `bet_replay`).
|
||||
* На подключение шлёт снапшот цены (если уже есть),
|
||||
* дальше — каждый тик/событие. См. `relayer/API.md`.
|
||||
*
|
||||
* Авто-закрытие: см. `startScheduler` в `src/settle.ts` — каждые
|
||||
* `SMART_SETTLE_INTERVAL_MS` мс (дефолт 2000) обходит Bet-аккаунты
|
||||
* программы, для просроченных открытых подтягивает цену из локальной
|
||||
* истории (`ctx.history.priceAtOrBefore(expire_time*1000)`) и зовёт
|
||||
* `closeBet`. При успешной финализации шлёт `bet_closed` всем WS-клиентам.
|
||||
* п.8 ТЗ: при исходе `refunded` (ничья, exit==entry) планировщик НЕ шлёт
|
||||
* `bet_closed` — он молча пересоздаёт ставку тем же размером и направлением
|
||||
* через хук `replayBet`, после чего шлёт `bet_replay`. После
|
||||
* `GW_MAX_REPLAYS` (дефолт `10`) переигровок подряд цепочка обрывается
|
||||
* (тогда уходит `bet_closed` со `refunded`).
|
||||
*
|
||||
* Пути нормализуются: ingress пробрасывает путь как есть, поэтому
|
||||
* /api/... и /... должны работать одинаково (strip /api).
|
||||
*
|
||||
* Запуск: npm run gateway (ts-node src/gateway.ts)
|
||||
* Env: GW_PORT (default 8895), GW_BETTOR_KEYSTORE, SMART_* (как у релейера),
|
||||
* Env: GW_PORT (default 8895), GW_BETTOR_KEYSTORE, GW_FIXED_BETTOR,
|
||||
* GW_BET_PERCENT (default 30), GW_MAX_REPLAYS (default 10, лимит
|
||||
* переигровок ничьих — см. п.8 ТЗ), SMART_* (как у релейера),
|
||||
* NATS_URL (default nats://192.168.88.93:4222),
|
||||
* NATS_PRICE_SUBJECT (default market.price.solusdt),
|
||||
* PRICE_WAIT_TIMEOUT_MS (default 15000).
|
||||
* PRICE_WAIT_TIMEOUT_MS (default 15000),
|
||||
* SMART_SETTLE_INTERVAL_MS (default 2000).
|
||||
*/
|
||||
import http from "http";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
|
||||
import { getAssociatedTokenAddress, getMint } from "@solana/spl-token";
|
||||
import BN from "bn.js";
|
||||
import type { Program } from "@anchor-lang/core";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { loadConfig, loadKeypair, SIDE_DOWN, SIDE_UP } from "./config";
|
||||
import { loadConfig, loadKeypair } from "./config";
|
||||
import {
|
||||
closeBet,
|
||||
createBet,
|
||||
@@ -49,15 +77,37 @@ import {
|
||||
readGlobal,
|
||||
} from "./ops";
|
||||
import { betPda } from "./pda";
|
||||
import { PriceHistory } from "./priceHistory";
|
||||
import { makeConnection, makeProgram } from "./program";
|
||||
import { currentPrice, onPrice, connectNats } from "./natsPrice";
|
||||
import {
|
||||
currentPrice,
|
||||
onPrice,
|
||||
connectNats,
|
||||
priceStringToInt,
|
||||
} from "./natsPrice";
|
||||
import { currentPriceHuman, getPrice } from "./priceSource";
|
||||
import { faucetDenyReason } from "./faucetGate";
|
||||
import {
|
||||
decodeBet,
|
||||
loadAllBetsForProgram,
|
||||
SchedulerCtx,
|
||||
SIDE_UP,
|
||||
SIDE_DOWN,
|
||||
STATUS_OPEN,
|
||||
STATUS_PAYOUT_DONE,
|
||||
STATUS_HOUSE_WON,
|
||||
STATUS_REFUNDED,
|
||||
settleOnce,
|
||||
betOpenEvent,
|
||||
DecodedBet,
|
||||
} from "./settle";
|
||||
import {
|
||||
computeBetAmountUnits,
|
||||
canBetDecision,
|
||||
} from "./betRules";
|
||||
|
||||
import type { SmartUpdown } from "../idl/smart_updown";
|
||||
|
||||
const STATUS_OPEN = 0;
|
||||
const STATUS_PAYOUT_DONE = 1;
|
||||
const STATUS_HOUSE_WON = 2;
|
||||
import BN from "bn.js";
|
||||
|
||||
const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL);
|
||||
const MAX_BODY_BYTES = 100 * 1024;
|
||||
@@ -87,6 +137,55 @@ function humanize(units: string | number | bigint, decimals: number): string {
|
||||
return `${sign}${abs / pow}.${(abs % pow).toString().padStart(decimals, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует JSON-объект ставки для /state и /bet (openBet / lastBet).
|
||||
* statusCode: 0/1/2/3 — сырое значение из контракта.
|
||||
* status: "open" | "closed" — ключевое поле для фронта (п.7 ТЗ).
|
||||
* result: null | "payout_done" | "house_won" | "refunded".
|
||||
* retryAfterSeconds: ceil(expireTime*1000 - nowMs)/1000 для открытой;
|
||||
* null для закрытой (отсчёт по времени неприменим).
|
||||
*/
|
||||
function betToView(bet: DecodedBet, id: number, decimals: number, nowMs: number): Record<string, unknown> {
|
||||
const statusCode = bet.status;
|
||||
const status = statusCode === STATUS_OPEN ? "open" : "closed";
|
||||
let result: string | null = null;
|
||||
if (statusCode === STATUS_PAYOUT_DONE) result = "payout_done";
|
||||
else if (statusCode === STATUS_HOUSE_WON) result = "house_won";
|
||||
else if (statusCode === STATUS_REFUNDED) result = "refunded";
|
||||
const side = bet.side === SIDE_UP ? "UP" : "DOWN";
|
||||
const retryAfterSeconds =
|
||||
statusCode === STATUS_OPEN
|
||||
? Math.max(0, Math.ceil((bet.expireTime * 1000 - nowMs) / 1000))
|
||||
: null;
|
||||
return {
|
||||
id: id.toString(),
|
||||
status,
|
||||
statusCode,
|
||||
result,
|
||||
side,
|
||||
amountUnits: bet.amount.toString(),
|
||||
amountHuman: humanize(bet.amount, decimals),
|
||||
entryPrice: bet.entryPrice,
|
||||
exitPrice: bet.exitPrice,
|
||||
betTime: bet.betTime,
|
||||
expireTime: bet.expireTime,
|
||||
retryAfterSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор представления ставок для /state (п.7 ТЗ заказчика):
|
||||
* открытая имеет приоритет, lastBet в этом случае = null.
|
||||
* Принимает уже отобранные ставки фиксированного беттора.
|
||||
*/
|
||||
export function pickBetViews<T>(
|
||||
openBet: T | null,
|
||||
lastBet: T | null
|
||||
): { openBet: T | null; lastBet: T | null } {
|
||||
if (openBet !== null) return { openBet, lastBet: null };
|
||||
return { openBet: null, lastBet };
|
||||
}
|
||||
|
||||
/**
|
||||
* Custodial keystore бетторов шлюза: адрес -> keypair.
|
||||
* Файл — JSON { "<pubkey>": [секретные байты...] }, права 0600.
|
||||
@@ -137,6 +236,13 @@ interface Ctx {
|
||||
program: Program<SmartUpdown>;
|
||||
decimals: number;
|
||||
keystore: BettorKeystore;
|
||||
/** Локальная история цены (SQLite). Используется планировщиком авто-закрытия
|
||||
* для честной цены на момент `expire_time` ставки, а не текущей. */
|
||||
history: PriceHistory;
|
||||
/** Кэш getGenesisHash() — RPC дёргаем один раз при первом обращении к /faucet. */
|
||||
genesisHash?: string;
|
||||
/** Рассылка WS-сообщения всем подключённым клиентам (price/bet_open/bet_closed). */
|
||||
broadcast: (msg: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
interface HttpJson {
|
||||
@@ -163,12 +269,6 @@ function normalizeSide(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAmountUnits(value: unknown): number | null {
|
||||
const n = typeof value === "string" ? Number(value) : value;
|
||||
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
async function handleState(ctx: Ctx): Promise<HttpJson> {
|
||||
const g = await readGlobal(ctx.program, ctx.program.programId, ctx.cfg.tokenMint);
|
||||
if (!g) {
|
||||
@@ -177,9 +277,80 @@ async function handleState(ctx: Ctx): Promise<HttpJson> {
|
||||
body: { error: "Contract not initialized (relayer: npm run init)" },
|
||||
};
|
||||
}
|
||||
|
||||
const fixedBettor = ctx.cfg.fixedBettor;
|
||||
const fixedBettorStr = fixedBettor.toBase58();
|
||||
const nowMs = Date.now();
|
||||
|
||||
// Балансы + keystore для фиксированного беттора. Ошибки балансов НЕ
|
||||
// роняют /state — фронту всё равно нужен снимок остального, просто
|
||||
// balance будет с нулями, а canBet=false с INSUFFICIENT_FUNDS/INSUFFICIENT_SOL.
|
||||
const balances = await readBalances(ctx, fixedBettor);
|
||||
const walletInKeystore = ctx.keystore.get(fixedBettorStr) !== undefined;
|
||||
|
||||
// Открытая + последняя ставка фиксированного беттора — один
|
||||
// getProgramAccounts на все Bet-аккаунты программы, фильтр в коде.
|
||||
let openBetView: Record<string, unknown> | null = null;
|
||||
let lastBetView: Record<string, unknown> | null = null;
|
||||
let lastBetId = -1;
|
||||
try {
|
||||
const loaded = await loadAllBetsForProgram(
|
||||
ctx.program,
|
||||
ctx.program.programId,
|
||||
g.counter.toNumber()
|
||||
);
|
||||
for (const entry of loaded.entries) {
|
||||
let bet: DecodedBet;
|
||||
try {
|
||||
bet = decodeBet(entry.account.data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (bet.bettor.toBase58() !== fixedBettorStr) continue;
|
||||
if (bet.status === STATUS_OPEN && openBetView === null) {
|
||||
openBetView = betToView(bet, entry.id, ctx.decimals, nowMs);
|
||||
}
|
||||
if (entry.id > lastBetId) {
|
||||
lastBetId = entry.id;
|
||||
lastBetView = betToView(bet, entry.id, ctx.decimals, nowMs);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`gateway: /state loadAllBetsForProgram failed: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
}
|
||||
|
||||
// п.7: либо открытая, либо последняя — не обе сразу. Открытая имеет приоритет.
|
||||
({ openBet: openBetView, lastBet: lastBetView } = pickBetViews(openBetView, lastBetView));
|
||||
|
||||
const minAmount = g.minAmount.toNumber();
|
||||
const maxAmount = g.maxAmount.toNumber();
|
||||
const betAmountUnits = computeBetAmountUnits(
|
||||
balances.tokenUnits,
|
||||
ctx.cfg.betPercent,
|
||||
minAmount,
|
||||
maxAmount
|
||||
);
|
||||
const priceAvailable = currentPriceHuman() !== null;
|
||||
|
||||
const decision = canBetDecision({
|
||||
paused: g.paused.toNumber(),
|
||||
globalPresent: true,
|
||||
hasOpenBet: openBetView !== null,
|
||||
walletInKeystore,
|
||||
solLamports: balances.solLamports,
|
||||
tokenUnits: balances.tokenUnits,
|
||||
minAmount,
|
||||
maxAmount,
|
||||
betPercent: ctx.cfg.betPercent,
|
||||
priceAvailable,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
// Прежние поля — НЕ трогаем (тестовый фронт их читает).
|
||||
counter: g.counter.toString(),
|
||||
nextBetId: g.counter.toString(),
|
||||
minAmount: g.minAmount.toString(),
|
||||
@@ -193,6 +364,18 @@ async function handleState(ctx: Ctx): Promise<HttpJson> {
|
||||
relayer: g.relayer.toBase58(),
|
||||
vaultBalance: g.vaultBalance,
|
||||
vaultBalanceHuman: humanize(g.vaultBalance, ctx.decimals),
|
||||
|
||||
// Новые поля (пункты 1, 2, 7 ТЗ заказчика).
|
||||
bettor: fixedBettorStr,
|
||||
betPercent: ctx.cfg.betPercent,
|
||||
betAmountUnits: betAmountUnits.toString(),
|
||||
betAmountHuman: humanize(betAmountUnits, ctx.decimals),
|
||||
canBet: decision.canBet,
|
||||
canBetReasonCode: decision.reasonCode,
|
||||
canBetReason: decision.reasonText,
|
||||
balance: balances,
|
||||
openBet: openBetView,
|
||||
lastBet: lastBetView,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -214,84 +397,149 @@ async function handlePrice(): Promise<HttpJson> {
|
||||
}
|
||||
|
||||
async function handleBet(ctx: Ctx, body: Record<string, unknown>): Promise<HttpJson> {
|
||||
// 1. side обязателен; address/amountUnits в новой версии молча игнорируются
|
||||
// (тестовый фронт ещё может их слать — не ломаем совместимость).
|
||||
const side = normalizeSide(body.side);
|
||||
if (side === null) {
|
||||
return { status: 400, body: { error: 'side must be 0/"UP" or 1/"DOWN"' } };
|
||||
}
|
||||
const amountUnits = parseAmountUnits(body.amountUnits);
|
||||
if (amountUnits === null) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "amountUnits must be a positive integer (token base units)" },
|
||||
};
|
||||
const sideText = side === SIDE_UP ? "UP" : "DOWN";
|
||||
const fixedBettor = ctx.cfg.fixedBettor;
|
||||
const fixedBettorStr = fixedBettor.toBase58();
|
||||
const nowMs = Date.now();
|
||||
|
||||
// 2. readGlobal → globalPresent / paused / min / max / expiry.
|
||||
const g = await readGlobal(ctx.program, ctx.program.programId, ctx.cfg.tokenMint);
|
||||
const globalPresent = g !== null;
|
||||
const paused = g ? g.paused.toNumber() : 0;
|
||||
const minAmount = g ? g.minAmount.toNumber() : 0;
|
||||
const maxAmount = g ? g.maxAmount.toNumber() : 0;
|
||||
const expirySeconds = g ? g.expirySeconds.toNumber() : 0;
|
||||
|
||||
// 3. keystore + balances фиксированного беттора.
|
||||
const kp = ctx.keystore.get(fixedBettorStr);
|
||||
const walletInKeystore = kp !== undefined;
|
||||
const balances = await readBalances(ctx, fixedBettor);
|
||||
|
||||
// 4. Найти открытую ставку фиксированного беттора. Один getProgramAccounts
|
||||
// на все Bet-аккаунты программы, фильтр в коде. Сразу берём expireTime —
|
||||
// нужен для retryAfterSeconds при OPEN_BET-отказе.
|
||||
let openBetExpireTime = 0;
|
||||
let hasOpenBet = false;
|
||||
if (g) {
|
||||
try {
|
||||
const loaded = await loadAllBetsForProgram(
|
||||
ctx.program,
|
||||
ctx.program.programId,
|
||||
g.counter.toNumber()
|
||||
);
|
||||
for (const entry of loaded.entries) {
|
||||
let bet: DecodedBet;
|
||||
try {
|
||||
bet = decodeBet(entry.account.data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (bet.bettor.toBase58() !== fixedBettorStr) continue;
|
||||
if (bet.status === STATUS_OPEN) {
|
||||
hasOpenBet = true;
|
||||
openBetExpireTime = bet.expireTime;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`gateway: /bet loadAllBetsForProgram failed: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const g = await readGlobal(ctx.program, ctx.program.programId, ctx.cfg.tokenMint);
|
||||
if (!g) {
|
||||
// 5. Цена из NATS доступна?
|
||||
const priceAvailable = currentPriceHuman() !== null;
|
||||
|
||||
// 6. Решение «можно ли ставить».
|
||||
const decision = canBetDecision({
|
||||
paused,
|
||||
globalPresent,
|
||||
hasOpenBet,
|
||||
walletInKeystore,
|
||||
solLamports: balances.solLamports,
|
||||
tokenUnits: balances.tokenUnits,
|
||||
minAmount,
|
||||
maxAmount,
|
||||
betPercent: ctx.cfg.betPercent,
|
||||
priceAvailable,
|
||||
});
|
||||
|
||||
// 7. Отказ → 200 с accepted=false (400 только на кривой side / не-JSON).
|
||||
if (!decision.canBet) {
|
||||
let retryAfterSeconds: number | null = null;
|
||||
if (decision.reasonCode === "OPEN_BET") {
|
||||
retryAfterSeconds = Math.max(
|
||||
0,
|
||||
Math.ceil((openBetExpireTime * 1000 - nowMs) / 1000)
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: 409,
|
||||
body: { error: "Contract not initialized (relayer: npm run init)" },
|
||||
};
|
||||
}
|
||||
if (amountUnits < g.minAmount.toNumber() || amountUnits > g.maxAmount.toNumber()) {
|
||||
return {
|
||||
status: 400,
|
||||
status: 200,
|
||||
body: {
|
||||
error: `amountUnits ${amountUnits} out of bounds [${g.minAmount.toString()}, ${g.maxAmount.toString()}]`,
|
||||
accepted: false,
|
||||
reasonCode: decision.reasonCode,
|
||||
reason: decision.reasonText,
|
||||
retryAfterSeconds,
|
||||
betId: null,
|
||||
txSig: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Кому принадлежат фантики: либо зарегистрированный custodial-адрес,
|
||||
// либо новый кошелёк, который шлюз только что сгенерировал.
|
||||
let bettor: PublicKey;
|
||||
let kp: Keypair | undefined;
|
||||
if (body.address !== undefined && body.address !== null && body.address !== "") {
|
||||
const pub = parsePubkey(body.address);
|
||||
if (!pub) {
|
||||
return { status: 400, body: { error: "address is not a valid Solana pubkey" } };
|
||||
}
|
||||
kp = ctx.keystore.get(pub.toBase58());
|
||||
if (!kp) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
error: `bettor ${pub.toBase58()} is not registered in the gateway keystore ` +
|
||||
"(send a bet without address to let the gateway create the wallet)",
|
||||
},
|
||||
};
|
||||
}
|
||||
bettor = pub;
|
||||
} else {
|
||||
kp = ctx.keystore.generate();
|
||||
bettor = kp.publicKey;
|
||||
}
|
||||
// 8. canBet=true → getPrice() + createBet(...).
|
||||
const entryPrice = await getPrice();
|
||||
const { id, tx } = await createBet(ctx.program, ctx.cfg.tokenMint, kp!, {
|
||||
side,
|
||||
amount: decision.amountUnits,
|
||||
entryPrice,
|
||||
});
|
||||
|
||||
// Фандинг как в релейере: SOL под ренту/комиссию + фантики в ATA.
|
||||
if (ctx.cfg.allowAirdrop) {
|
||||
await fundBettor(ctx.connection, bettor);
|
||||
await ensureBettorTokenAccount(
|
||||
ctx.connection,
|
||||
ctx.admin,
|
||||
ctx.cfg.tokenMint,
|
||||
bettor,
|
||||
BigInt(ctx.cfg.startBalance)
|
||||
// 9. Прочитать реальный Bet-аккаунт (entry/expire/bettor) → WS-событие
|
||||
// + реальный expireTime для ответа.
|
||||
let expireTime = 0;
|
||||
try {
|
||||
const betPdaAddr = betPda(ctx.program.programId, id);
|
||||
const info = await ctx.connection.getAccountInfo(betPdaAddr);
|
||||
if (info) {
|
||||
const decoded = decodeBet(info.data as Buffer);
|
||||
expireTime = decoded.expireTime;
|
||||
ctx.broadcast(betOpenEvent(decoded, id.toNumber()));
|
||||
} else {
|
||||
console.error(`gateway: bet #${id.toString()} account not found after create tx=${tx}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`gateway: bet_open broadcast failed for #${id.toString()}: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
}
|
||||
|
||||
const entryPrice = await getPrice();
|
||||
const { id, tx } = await createBet(ctx.program, ctx.cfg.tokenMint, kp, {
|
||||
side,
|
||||
amount: amountUnits,
|
||||
entryPrice,
|
||||
});
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
tx,
|
||||
id: id.toString(),
|
||||
bettor: bettor.toBase58(),
|
||||
// Новый формат.
|
||||
accepted: true,
|
||||
reasonCode: null,
|
||||
reason: null,
|
||||
retryAfterSeconds: g ? expirySeconds : null,
|
||||
betId: id.toString(),
|
||||
txSig: tx,
|
||||
amountUnits: decision.amountUnits.toString(),
|
||||
amountHuman: humanize(decision.amountUnits, ctx.decimals),
|
||||
side: sideText,
|
||||
entryPrice,
|
||||
expireTime,
|
||||
|
||||
// Обратная совместимость с тестовым фронтом: j.id / j.tx / j.bettor.
|
||||
id: id.toString(),
|
||||
tx,
|
||||
bettor: fixedBettorStr,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -307,44 +555,6 @@ function withBetLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return run;
|
||||
}
|
||||
|
||||
async function handleClose(ctx: Ctx, body: Record<string, unknown>): Promise<HttpJson> {
|
||||
let id: BN;
|
||||
try {
|
||||
id = new BN(String(body.id));
|
||||
} catch {
|
||||
return { status: 400, body: { error: "id must be an integer" } };
|
||||
}
|
||||
const programId = ctx.program.programId;
|
||||
const bet = await ctx.program.account.bet.fetchNullable(betPda(programId, id));
|
||||
if (!bet) {
|
||||
return { status: 404, body: { error: `bet #${id.toString()} not found` } };
|
||||
}
|
||||
|
||||
const exitPrice = await getPrice();
|
||||
const tx = await closeBet(ctx.program, id, exitPrice, ctx.admin, bet.bettor, ctx.cfg.tokenMint);
|
||||
|
||||
const after = await ctx.program.account.bet.fetchNullable(betPda(programId, id));
|
||||
const status = after ? after.status.toNumber() : STATUS_OPEN;
|
||||
const bodyOut: Record<string, unknown> = {
|
||||
tx,
|
||||
id: id.toString(),
|
||||
exitPrice,
|
||||
status,
|
||||
statusName:
|
||||
status === STATUS_PAYOUT_DONE
|
||||
? "payout_done"
|
||||
: status === STATUS_HOUSE_WON
|
||||
? "house_won"
|
||||
: "open",
|
||||
bettor: bet.bettor.toBase58(),
|
||||
};
|
||||
if (status === STATUS_OPEN) {
|
||||
bodyOut.note =
|
||||
"exit==entry: ставка осталась open (контракт не финализирует при равной цене) — повторите /close позже";
|
||||
}
|
||||
return { status: 200, body: bodyOut };
|
||||
}
|
||||
|
||||
/** Балансы адреса (SOL + фантики в ATA по нашему mint) — переиспользуется
|
||||
* и /balance, и /faucet (реальные значения после налива). */
|
||||
async function readBalances(
|
||||
@@ -385,7 +595,9 @@ async function handleBalance(ctx: Ctx, address: string): Promise<HttpJson> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Налив фантиков/SOL на ПРОИЗВОЛЬНЫЙ адрес (Phantom и т.п.) — только localnet.
|
||||
* Налив фантиков/SOL на ПРОИЗВОЛЬНЫЙ адрес (Phantom и т.п.).
|
||||
* Доступ ТОЛЬКО в test-режиме: SMART_MODE=test И не публичный кластер
|
||||
* (проверка по genesis hash — mainnet/devnet/testnet запрещены).
|
||||
* Переиспользует готовые функции ops.ts без дублирования логики:
|
||||
* - SOL (рента/комиссия) — тот же airdrop, что fundBettor;
|
||||
* - фантики — mintTo в ATA адреса (до startBalance, дефолт = maxAmount),
|
||||
@@ -393,9 +605,32 @@ async function handleBalance(ctx: Ctx, address: string): Promise<HttpJson> {
|
||||
* Ответ — адрес + реальные балансы после налива (readBalances, как в /balance).
|
||||
*/
|
||||
async function handleFaucet(ctx: Ctx, address: string): Promise<HttpJson> {
|
||||
if (!ctx.cfg.allowAirdrop) {
|
||||
return { status: 403, body: { error: "faucet only on localnet" } };
|
||||
// Уровень 1: режим. Любой не-test — запрет (даже на localnet).
|
||||
if (ctx.cfg.mode !== "test") {
|
||||
console.log(`gateway: faucet DENIED (mode=${ctx.cfg.mode})`);
|
||||
return {
|
||||
status: 403,
|
||||
body: { error: "faucet is disabled: only test mode (SMART_MODE=test)" },
|
||||
};
|
||||
}
|
||||
// Уровень 2: генезис. Кэшируем первый getGenesisHash() в ctx.
|
||||
if (ctx.genesisHash === undefined) {
|
||||
try {
|
||||
ctx.genesisHash = await ctx.connection.getGenesisHash();
|
||||
} catch (e) {
|
||||
console.log(`gateway: faucet DENIED (genesis hash unavailable: ${e instanceof Error ? e.message : String(e)})`);
|
||||
return {
|
||||
status: 403,
|
||||
body: { error: "faucet is disabled: genesis hash unavailable" },
|
||||
};
|
||||
}
|
||||
}
|
||||
const reason = faucetDenyReason({ mode: ctx.cfg.mode, genesisHash: ctx.genesisHash });
|
||||
if (reason !== null) {
|
||||
console.log(`gateway: faucet DENIED (${reason})`);
|
||||
return { status: 403, body: { error: `faucet is disabled: ${reason}` } };
|
||||
}
|
||||
|
||||
const pub = parsePubkey(address);
|
||||
if (!pub) {
|
||||
return { status: 400, body: { error: "address is not a valid Solana pubkey" } };
|
||||
@@ -417,6 +652,18 @@ async function handleFaucet(ctx: Ctx, address: string): Promise<HttpJson> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт новый custodial-кошелёк беттора. НИЧЕГО не наливает — только
|
||||
* генерирует keypair и регистрирует в keystore. Деньги — через /faucet/{address}.
|
||||
*/
|
||||
async function handleWallet(ctx: Ctx): Promise<HttpJson> {
|
||||
const kp = ctx.keystore.generate();
|
||||
return {
|
||||
status: 200,
|
||||
body: { address: kp.publicKey.toBase58() },
|
||||
};
|
||||
}
|
||||
|
||||
function readJsonBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
@@ -453,19 +700,32 @@ interface WsClient {
|
||||
alive: boolean;
|
||||
}
|
||||
|
||||
/** Обобщённая рассылка: сериализует один раз, шлёт всем открытым клиентам. */
|
||||
function broadcastMsg(wsClients: Set<WsClient>, msg: Record<string, unknown>): void {
|
||||
const json = JSON.stringify(msg);
|
||||
for (const c of wsClients) {
|
||||
if (c.ws.readyState === WebSocket.OPEN) {
|
||||
c.ws.send(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Отправка ОДНОМУ клиенту (для снапшота при подключении). */
|
||||
function sendMsg(client: WsClient, msg: Record<string, unknown>): void {
|
||||
if (client.ws.readyState === WebSocket.OPEN) {
|
||||
client.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
/** Подписка на цену → рассылка price-сообщений через общий broadcastMsg. */
|
||||
function broadcastPrice(wsClients: Set<WsClient>): (p: { price: string; ts: number }) => void {
|
||||
return (p) => {
|
||||
const msg = JSON.stringify({
|
||||
broadcastMsg(wsClients, {
|
||||
type: "price",
|
||||
symbol: "solusdt",
|
||||
price: p.price,
|
||||
ts: p.ts,
|
||||
});
|
||||
for (const c of wsClients) {
|
||||
if (c.ws.readyState === WebSocket.OPEN) {
|
||||
c.ws.send(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -486,7 +746,39 @@ async function main(): Promise<void> {
|
||||
process.env.GW_BETTOR_KEYSTORE ?? path.join(__dirname, "..", ".gateway-bettors.json")
|
||||
);
|
||||
|
||||
const ctx: Ctx = { cfg, admin, connection, program, decimals, keystore };
|
||||
// Локальная история цены (SQLite): пишется каждый тик из NATS (только при
|
||||
// изменении цены), читается в handleClose для фиксированной цены на момент
|
||||
// expire_time ставки. Ретеншен чистится периодически ОДНИМ DELETE.
|
||||
const history = new PriceHistory(cfg.priceHistoryPath);
|
||||
try {
|
||||
const removed = history.prune(Date.now(), cfg.priceHistoryRetentionMs);
|
||||
if (removed > 0) {
|
||||
console.log(`gateway: price history pruned ${removed} stale row(s) at startup`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`gateway: initial price-history prune failed: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
}
|
||||
setInterval(() => {
|
||||
try {
|
||||
history.prune(Date.now(), cfg.priceHistoryRetentionMs);
|
||||
} catch {
|
||||
// тихий фоновый prune — ронять процесс нельзя
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
const wsClients = new Set<WsClient>();
|
||||
const ctx: Ctx = {
|
||||
cfg,
|
||||
admin,
|
||||
connection,
|
||||
program,
|
||||
decimals,
|
||||
keystore,
|
||||
history,
|
||||
broadcast: (msg) => broadcastMsg(wsClients, msg),
|
||||
};
|
||||
|
||||
// Прелоад как в index.ts: ваулт-фантики на localnet (если включён airdrop).
|
||||
// Не блокируем старт сервера: подтверждение tx может вешаться на 30с,
|
||||
@@ -549,12 +841,11 @@ async function main(): Promise<void> {
|
||||
result = await handleState(ctx);
|
||||
} else if (m === "GET" && p === "/price") {
|
||||
result = await handlePrice();
|
||||
} else if (m === "POST" && p === "/wallet") {
|
||||
result = await handleWallet(ctx);
|
||||
} else if (m === "POST" && p === "/bet") {
|
||||
const body = await readJsonBody(req);
|
||||
result = await withBetLock(() => handleBet(ctx, body));
|
||||
} else if (m === "POST" && p === "/close") {
|
||||
const body = await readJsonBody(req);
|
||||
result = await handleClose(ctx, body);
|
||||
} else if (m === "GET" && p.startsWith("/balance/")) {
|
||||
const address = decodeURIComponent(p.slice("/balance/".length));
|
||||
result = await handleBalance(ctx, address);
|
||||
@@ -587,7 +878,6 @@ async function main(): Promise<void> {
|
||||
|
||||
// WS-сервер на том же порту (noServer=true), обновляем апгрейды вручную.
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const wsClients = new Set<WsClient>();
|
||||
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
const client: WsClient = { ws, alive: true };
|
||||
@@ -604,19 +894,32 @@ async function main(): Promise<void> {
|
||||
// Снапшот сразу, если цена уже пришла из NATS.
|
||||
const snap = currentPrice();
|
||||
if (snap !== null) {
|
||||
const msg = JSON.stringify({
|
||||
sendMsg(client, {
|
||||
type: "price",
|
||||
symbol: "solusdt",
|
||||
price: snap.price,
|
||||
ts: snap.ts,
|
||||
});
|
||||
ws.send(msg);
|
||||
}
|
||||
});
|
||||
|
||||
// Рассылка каждого нового NATS-тика по всем подключённым WS.
|
||||
onPrice(broadcastPrice(wsClients));
|
||||
|
||||
// Параллельно пишем тик в локальную историю цены (только при изменении
|
||||
// цены — `record` сам это разруливает и вернёт false на дубликат).
|
||||
// Ошибку записи НЕ роняем в процесс — это фоновая телеметрия,
|
||||
// шлюз без неё работает.
|
||||
onPrice((p) => {
|
||||
try {
|
||||
history.record(p.eventTime, priceStringToInt(p.price));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`gateway: price-history record failed: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Heartbeat: пинг раз в 30с, нет pong 2 цикла — terminate.
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const c of wsClients) {
|
||||
@@ -654,13 +957,68 @@ async function main(): Promise<void> {
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`gateway: listening on 0.0.0.0:${port} (RPC ${cfg.rpcUrl}, mint ${cfg.tokenMint.toBase58()})`);
|
||||
console.log(
|
||||
`gateway: endpoints: GET /state, GET /price, POST /bet, POST /close, GET /balance/{address}, GET /faucet/{address}, WS /ws`
|
||||
`gateway: endpoints: GET /state, GET /price, POST /wallet, POST /bet, GET /balance/{address}, GET /faucet/{address}, WS /ws (price + bet_open + bet_closed + bet_replay)`
|
||||
);
|
||||
console.log(
|
||||
`gateway: settle scheduler: interval=${cfg.settleIntervalMs}ms (close_bet auto on expire)`
|
||||
);
|
||||
});
|
||||
|
||||
// Корректная остановка heartbeat при завершении.
|
||||
// Планировщик авто-закрытия: каждые `cfg.settleIntervalMs` обходит Bet-аккаунты
|
||||
// и для просроченных открытых шлёт close_bet + bet_closed WS-событие.
|
||||
// Весь тик целиком внутри withBetLock — чтобы не пересекаться с /bet
|
||||
// (две параллельные ставки на один counter дают BetIdMismatch).
|
||||
const schedulerCtx: SchedulerCtx = {
|
||||
priceAtOrBefore: (expireMs) => history.priceAtOrBefore(expireMs),
|
||||
closeBet: async (id, exitPrice, bettor) =>
|
||||
closeBet(program, new BN(id), exitPrice, admin, bettor, cfg.tokenMint),
|
||||
broadcast: (msg) => broadcastMsg(wsClients, msg),
|
||||
fetchBet: async (id) => {
|
||||
const info = await connection.getAccountInfo(betPda(program.programId, new BN(id)));
|
||||
if (!info) return null;
|
||||
try {
|
||||
return decodeBet(info.data as Buffer);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
loadAllBets: async () => {
|
||||
const g = await readGlobal(program, program.programId, cfg.tokenMint);
|
||||
const counter = g ? g.counter.toNumber() : 0;
|
||||
return loadAllBetsForProgram(program, program.programId, counter);
|
||||
},
|
||||
nowMs: () => Date.now(),
|
||||
// п.8: при ничьей ставка молча играет заново — replayBet пересоздаёт её
|
||||
// тем же размером и направлением, лимит `maxReplays` обрывает цепочку,
|
||||
// если цена «заморожена».
|
||||
replayBet: async ({ side, amount, bettor }) => {
|
||||
const kp = keystore.get(bettor.toBase58());
|
||||
if (!kp) {
|
||||
throw new Error(`gateway: no keystore key for bettor ${bettor.toBase58()}`);
|
||||
}
|
||||
const entryPrice = await getPrice();
|
||||
return createBet(program, cfg.tokenMint, kp, { side, amount, entryPrice });
|
||||
},
|
||||
maxReplays: cfg.maxReplays,
|
||||
};
|
||||
// Карта последних состояний и счётчик переигровок живут здесь и
|
||||
// передаются в каждый тик — иначе settleOnce считает, что состояния не
|
||||
// видел (спам одинаковыми строками каждые SMART_SETTLE_INTERVAL_MS) и
|
||||
// теряет счётчик переигровок по цепочке ничьих.
|
||||
const lastState = new Map<number, string>();
|
||||
const replayCounts = new Map<number, number>();
|
||||
const schedulerHandle = setInterval(() => {
|
||||
withBetLock(() => settleOnce({ ...schedulerCtx, lastState, replayCounts })).catch((e) =>
|
||||
console.error(
|
||||
`settle: tick crashed: ${e instanceof Error ? e.message : String(e)}`
|
||||
)
|
||||
);
|
||||
}, cfg.settleIntervalMs);
|
||||
|
||||
// Корректная остановка heartbeat и планировщика при завершении.
|
||||
const shutdown = () => {
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(schedulerHandle);
|
||||
wss.close();
|
||||
server.close();
|
||||
process.exit(0);
|
||||
@@ -669,7 +1027,9 @@ async function main(): Promise<void> {
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
if (require.main === module) {
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,14 @@ export interface PriceTick {
|
||||
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;
|
||||
@@ -87,7 +95,11 @@ export async function connectNats(url: string, subject: string): Promise<void> {
|
||||
typeof obj.ts === "number" && Number.isFinite(obj.ts)
|
||||
? (obj.ts as number)
|
||||
: Date.now();
|
||||
const tick: PriceTick = { price: priceRaw, ts: tsNum };
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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