feat(relayer): API v2 (canBet/30% от баланса) + п.8 — ничья играет заново
Тестовый TS-шлюз (relayer/), обслуживающий smart-updown.binom.pw и
testfront.binom.pw. Контракт и prod-Kotlin сервер не тронуты.
1+2. GET /state: canBet (boolean) + canBetReasonCode/canBetReason (русский
текст) — можно ли ставить с фиксированным кошельком. Учитывает:
захардкоженный адрес (GW_FIXED_BETTOR), наличие открытой ставки,
хватает ли фантиков на ставку, лимиты [min,max], paused, наличие цены.
3-6. POST /bet: тело {"side":"up"|"down"} — сумма с фронта НЕ приходит,
размер = GW_BET_PERCENT (30) % от баланса фантиков, клампится в
[minAmount, maxAmount]. Ответ: {accepted, reasonCode, reason,
retryAfterSeconds, betId, txSig, ...}.
7. GET /state: openBet / lastBet (взаимоисключающие: открытая имеет
приоритет) + поле status ("open"/"closed"), result, retryAfterSeconds.
8. Ничья (refunded, exit==entry) игроку НЕ показывается: планировщик молча
пересоздаёт ставку тем же размером и направлением (хук replayBet) и
шлёт WS-событие bet_replay вместо bet_closed. Лимит GW_MAX_REPLAYS
(дефолт 10) обрывает цепочку при «замороженной» цене.
9. relayer/API.md — документация всех endpoint-ов и WS-событий.
Новые файлы: src/betRules.ts (правила ставки: canBetDecision,
computeBetAmountUnits), API.md.
Новые env: GW_FIXED_BETTOR, GW_BET_PERCENT (30), GW_MAX_REPLAYS (10).
Проверено: npm run typecheck + npm run test — 127 проверок, 0 провалов;
живая приёмка tools/gateway-api-v2-check.cjs — 26/26 на
https://smart-updown.binom.pw/api (включая реальную ставку → payout_done).
This commit is contained in:
+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-ошибок.
|
||||||
@@ -79,6 +79,12 @@ spec:
|
|||||||
value: {{ .Values.port | quote }}
|
value: {{ .Values.port | quote }}
|
||||||
- name: GW_BETTOR_KEYSTORE
|
- name: GW_BETTOR_KEYSTORE
|
||||||
value: {{ .Values.keystorePath | quote }}
|
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
|
- name: NATS_URL
|
||||||
value: {{ .Values.nats.url | quote }}
|
value: {{ .Values.nats.url | quote }}
|
||||||
- name: NATS_PRICE_SUBJECT
|
- name: NATS_PRICE_SUBJECT
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ smart:
|
|||||||
priceHistory: '/app/data/price-history.db'
|
priceHistory: '/app/data/price-history.db'
|
||||||
priceRetentionHours: 24
|
priceRetentionHours: 24
|
||||||
settleIntervalMs: 2000
|
settleIntervalMs: 2000
|
||||||
|
# Захардкоженный беттор шлюза: шлюз ставит от его имени на 30% от баланса.
|
||||||
|
# Адрес «bettor-1» из тестового фронта, уже зарегистрирован в keystore
|
||||||
|
# и пополнен (~9800 фантиков, ~5 SOL).
|
||||||
|
fixedBettor: '6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6'
|
||||||
|
betPercent: 30
|
||||||
|
# п.8 ТЗ: лимит переигровок ничьих на одну цепочку ставок (10 по умолчанию).
|
||||||
|
# Цена «заморожена» → каждая ставка рефандится → без лимита цепочка
|
||||||
|
# крутится бесконечно.
|
||||||
|
maxReplays: 10
|
||||||
|
|
||||||
nats:
|
nats:
|
||||||
url: 'nats://192.168.88.93:4222'
|
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 };
|
||||||
|
}
|
||||||
@@ -26,15 +26,28 @@ import {
|
|||||||
BET_ACCOUNT_SIZE,
|
BET_ACCOUNT_SIZE,
|
||||||
betClosedEvent,
|
betClosedEvent,
|
||||||
betOpenEvent,
|
betOpenEvent,
|
||||||
|
betReplayEvent,
|
||||||
decodeBet,
|
decodeBet,
|
||||||
|
DecodedBet,
|
||||||
dueForSettle,
|
dueForSettle,
|
||||||
openBetOf,
|
openBetOf,
|
||||||
BetAccountEntry,
|
BetAccountEntry,
|
||||||
|
SchedulerCtx,
|
||||||
|
settleOnce,
|
||||||
|
SIDE_UP,
|
||||||
|
SIDE_DOWN,
|
||||||
STATUS_OPEN,
|
STATUS_OPEN,
|
||||||
STATUS_PAYOUT_DONE,
|
STATUS_PAYOUT_DONE,
|
||||||
STATUS_HOUSE_WON,
|
STATUS_HOUSE_WON,
|
||||||
STATUS_REFUNDED,
|
STATUS_REFUNDED,
|
||||||
} from "./settle";
|
} from "./settle";
|
||||||
|
import {
|
||||||
|
computeBetAmountUnits,
|
||||||
|
canBetDecision,
|
||||||
|
CanBetInput,
|
||||||
|
MIN_SOL_LAMPORTS,
|
||||||
|
} from "./betRules";
|
||||||
|
import { pickBetViews } from "./gateway";
|
||||||
|
|
||||||
const ok = (msg: string) => console.log(`ok: ${msg}`);
|
const ok = (msg: string) => console.log(`ok: ${msg}`);
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@@ -431,9 +444,414 @@ async function main(): Promise<void> {
|
|||||||
check(ev.statusName === "refunded", 'betClosedEvent refunded: statusName==="refunded"');
|
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 контрактом.
|
// Санити-проверка: STATUS_OPEN == 0 для совместимости с on-chain контрактом.
|
||||||
check(STATUS_OPEN === 0, "settle: STATUS_OPEN === 0 (on-chain OPEN)");
|
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) {
|
if (failed > 0) {
|
||||||
console.error(`\ncheck: ${failed} проверок ПРОВАЛЕНО`);
|
console.error(`\ncheck: ${failed} проверок ПРОВАЛЕНО`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -441,6 +859,22 @@ async function main(): Promise<void> {
|
|||||||
console.log("\ncheck: все проверки пройдены — сигнатуры релейера соответствуют новому токеновому IDL.");
|
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) => {
|
main().catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ export interface Config {
|
|||||||
priceHistoryRetentionMs: number;
|
priceHistoryRetentionMs: number;
|
||||||
/** Как часто планировщик авто-закрытия просроченных ставок просыпается (мс). */
|
/** Как часто планировщик авто-закрытия просроченных ставок просыпается (мс). */
|
||||||
settleIntervalMs: number;
|
settleIntervalMs: number;
|
||||||
|
/**
|
||||||
|
* Захардкоженный беттор шлюза. Шлюз ставит ОТ ЕГО ИМЕНИ (keypair в keystore),
|
||||||
|
* клиент фронта НЕ передаёт адрес. Сумма ставки = betPercent% от баланса
|
||||||
|
* фантиков этого кошелька (с клампом в [minAmount, maxAmount]).
|
||||||
|
*/
|
||||||
|
fixedBettor: PublicKey;
|
||||||
|
/** Процент от баланса фантиков fixedBettor, идущий в ставку (дефолт 30). */
|
||||||
|
betPercent: number;
|
||||||
|
/**
|
||||||
|
* п.8 ТЗ: лимит переигровок на одну цепочку ничьих. По умолчанию 10 —
|
||||||
|
* если цена «заморожена», цепочка обрывается после 10-й ничьей.
|
||||||
|
*/
|
||||||
|
maxReplays: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function env(name: string, fallback: string): string {
|
function env(name: string, fallback: string): string {
|
||||||
@@ -66,6 +79,17 @@ function envNum(name: string, fallback: number): number {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Захардкоженный беттор шлюза — «bettor-1» из тестового фронта (уже
|
||||||
|
* зарегистрирован в keystore и пополнен: ~9800 фантиков, ~5 SOL).
|
||||||
|
* Совпадает с .gateway-bettors.json, инициализированным при деплое пода.
|
||||||
|
*/
|
||||||
|
const FIXED_BETTOR_DEFAULT = "6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6";
|
||||||
|
|
||||||
|
function envPubkey(name: string, fallback: string): PublicKey {
|
||||||
|
return new PublicKey(process.env[name] ?? fallback);
|
||||||
|
}
|
||||||
|
|
||||||
export function loadConfig(): Config {
|
export function loadConfig(): Config {
|
||||||
const rpcUrl = env("SMART_RPC_URL", "http://127.0.0.1:8899");
|
const rpcUrl = env("SMART_RPC_URL", "http://127.0.0.1:8899");
|
||||||
const mode = parseMode(env("SMART_MODE", "release"));
|
const mode = parseMode(env("SMART_MODE", "release"));
|
||||||
@@ -96,6 +120,15 @@ export function loadConfig(): Config {
|
|||||||
startBalance: envNum("SMART_START_BALANCE", 10_000_000_000),
|
startBalance: envNum("SMART_START_BALANCE", 10_000_000_000),
|
||||||
// Интервал планировщика авто-закрытия просроченных ставок (мс). Дефолт 2с.
|
// Интервал планировщика авто-закрытия просроченных ставок (мс). Дефолт 2с.
|
||||||
settleIntervalMs: envNum("SMART_SETTLE_INTERVAL_MS", 2000),
|
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+299
-131
@@ -8,39 +8,53 @@
|
|||||||
*
|
*
|
||||||
* Эндпоинты (CORS: Access-Control-Allow-Origin: *):
|
* Эндпоинты (CORS: Access-Control-Allow-Origin: *):
|
||||||
* GET /state — Global: counter, min/max, multiplier, expiry, paused, vault
|
* GET /state — Global: counter, min/max, multiplier, expiry, paused, vault
|
||||||
|
* + информация о фиксированном бетторе (bettor / betPercent /
|
||||||
|
* betAmountUnits / canBet / canBetReason / balance / openBet /
|
||||||
|
* lastBet).
|
||||||
* GET /price — текущая сырая цена SOL из NATS (для отладки); 503 если ещё нет
|
* GET /price — текущая сырая цена SOL из NATS (для отладки); 503 если ещё нет
|
||||||
* POST /wallet — создать custodial-кошелёк (только test-mode). Ответ: {address}.
|
* POST /wallet — создать custodial-кошелёк (только test-mode). Ответ: {address}.
|
||||||
* НЕ наливает ни SOL, ни фантики — деньги отдельно через /faucet/{address}.
|
* НЕ наливает ни SOL, ни фантики — деньги отдельно через /faucet/{address}.
|
||||||
* POST /bet {address, side, amountUnits}
|
* POST /bet {side} — ставка за захардкоженный беттор (GW_FIXED_BETTOR).
|
||||||
* — address: обязателен (получен из /wallet и пополнен через /faucet).
|
* side: 0/"UP" или 1/"DOWN". Сумма НЕ передаётся — шлюз считает
|
||||||
* Ставка НИЧЕГО не наливает — только проверяет, что фантиков
|
* её сам = GW_BET_PERCENT% от баланса фантиков беттора (с клампом
|
||||||
* и SOL хватает, И что у адреса ещё нет открытой ставки
|
* в [minAmount, maxAmount]). Адрес беттора тоже НЕ передаётся —
|
||||||
* (запрос идёт в цепочку, не в память процесса — иначе после
|
* берётся из GW_FIXED_BETTOR. Если они присланы — молча
|
||||||
* рестарта пода проверка теряется). Шлёт create_bet и
|
* игнорируются (обратная совместимость с тестовым фронтом).
|
||||||
* сразу же публикует WS-событие `bet_open`.
|
* Ответ (всегда HTTP 200, кроме кривого side / не-JSON):
|
||||||
* Ответ: {tx, id, bettor, entryPrice}.
|
* {accepted, reasonCode, reason, retryAfterSeconds,
|
||||||
* Закрытие ставки происходит автоматически по экспирации —
|
* betId, txSig, amountUnits, amountHuman, side,
|
||||||
* см. планировщик в `src/settle.ts`.
|
* entryPrice, expireTime, id, tx, bettor}
|
||||||
|
* При accepted=false ставка НЕ создаётся, betId/txSig = null.
|
||||||
|
* Закрытие ставки происходит автоматически по экспирации —
|
||||||
|
* см. планировщик в `src/settle.ts`.
|
||||||
* GET /balance/{address} — {sol, solLamports, tokenUnits, tokenHuman}
|
* GET /balance/{address} — {sol, solLamports, tokenUnits, tokenHuman}
|
||||||
* GET /faucet/{address} — налив SOL + фантиков на ПРОИЗВОЛЬНЫЙ адрес (Phantom и т.п.).
|
* GET /faucet/{address} — налив SOL + фантиков на ПРОИЗВОЛЬНЫЙ адрес (Phantom и т.п.).
|
||||||
* Доступен ТОЛЬКО в test-режиме: SMART_MODE=test И не публичный
|
* Доступен ТОЛЬКО в test-режиме: SMART_MODE=test И не публичный
|
||||||
* кластер (проверка по genesis hash: mainnet/devnet/testnet запрещены).
|
* кластер (проверка по genesis hash: mainnet/devnet/testnet запрещены).
|
||||||
* Иначе 403. Ответ: {address, sol, solLamports, tokenUnits, tokenHuman}
|
* Иначе 403. Ответ: {address, sol, solLamports, tokenUnits, tokenHuman}
|
||||||
* GET /ws (WebSocket) — push цены SOL из NATS + WS-события жизненного цикла
|
* GET /ws (WebSocket) — push цены SOL из NATS + WS-события жизненного цикла
|
||||||
* ставки (`bet_open` / `bet_closed`). На подключение шлёт
|
* ставки (`bet_open` / `bet_closed` / `bet_replay`).
|
||||||
* снапшот цены (если уже есть), дальше — каждый тик/событие.
|
* На подключение шлёт снапшот цены (если уже есть),
|
||||||
|
* дальше — каждый тик/событие. См. `relayer/API.md`.
|
||||||
*
|
*
|
||||||
* Авто-закрытие: см. `startScheduler` в `src/settle.ts` — каждые
|
* Авто-закрытие: см. `startScheduler` в `src/settle.ts` — каждые
|
||||||
* `SMART_SETTLE_INTERVAL_MS` мс (дефолт 2000) обходит Bet-аккаунты
|
* `SMART_SETTLE_INTERVAL_MS` мс (дефолт 2000) обходит Bet-аккаунты
|
||||||
* программы, для просроченных открытых подтягивает цену из локальной
|
* программы, для просроченных открытых подтягивает цену из локальной
|
||||||
* истории (`ctx.history.priceAtOrBefore(expire_time*1000)`) и зовёт
|
* истории (`ctx.history.priceAtOrBefore(expire_time*1000)`) и зовёт
|
||||||
* `closeBet`. При успешной финализации шлёт `bet_closed` всем WS-клиентам.
|
* `closeBet`. При успешной финализации шлёт `bet_closed` всем WS-клиентам.
|
||||||
|
* п.8 ТЗ: при исходе `refunded` (ничья, exit==entry) планировщик НЕ шлёт
|
||||||
|
* `bet_closed` — он молча пересоздаёт ставку тем же размером и направлением
|
||||||
|
* через хук `replayBet`, после чего шлёт `bet_replay`. После
|
||||||
|
* `GW_MAX_REPLAYS` (дефолт `10`) переигровок подряд цепочка обрывается
|
||||||
|
* (тогда уходит `bet_closed` со `refunded`).
|
||||||
*
|
*
|
||||||
* Пути нормализуются: ingress пробрасывает путь как есть, поэтому
|
* Пути нормализуются: ingress пробрасывает путь как есть, поэтому
|
||||||
* /api/... и /... должны работать одинаково (strip /api).
|
* /api/... и /... должны работать одинаково (strip /api).
|
||||||
*
|
*
|
||||||
* Запуск: npm run gateway (ts-node src/gateway.ts)
|
* Запуск: 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_URL (default nats://192.168.88.93:4222),
|
||||||
* NATS_PRICE_SUBJECT (default market.price.solusdt),
|
* NATS_PRICE_SUBJECT (default market.price.solusdt),
|
||||||
* PRICE_WAIT_TIMEOUT_MS (default 15000),
|
* PRICE_WAIT_TIMEOUT_MS (default 15000),
|
||||||
@@ -79,24 +93,25 @@ import {
|
|||||||
SchedulerCtx,
|
SchedulerCtx,
|
||||||
SIDE_UP,
|
SIDE_UP,
|
||||||
SIDE_DOWN,
|
SIDE_DOWN,
|
||||||
|
STATUS_OPEN,
|
||||||
|
STATUS_PAYOUT_DONE,
|
||||||
|
STATUS_HOUSE_WON,
|
||||||
|
STATUS_REFUNDED,
|
||||||
settleOnce,
|
settleOnce,
|
||||||
betOpenEvent,
|
betOpenEvent,
|
||||||
findOpenBetForBettorViaChain,
|
DecodedBet,
|
||||||
} from "./settle";
|
} from "./settle";
|
||||||
|
import {
|
||||||
|
computeBetAmountUnits,
|
||||||
|
canBetDecision,
|
||||||
|
} from "./betRules";
|
||||||
|
|
||||||
import type { SmartUpdown } from "../idl/smart_updown";
|
import type { SmartUpdown } from "../idl/smart_updown";
|
||||||
import BN from "bn.js";
|
import BN from "bn.js";
|
||||||
|
|
||||||
const STATUS_OPEN = 0;
|
|
||||||
const STATUS_PAYOUT_DONE = 1;
|
|
||||||
const STATUS_HOUSE_WON = 2;
|
|
||||||
|
|
||||||
const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL);
|
const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL);
|
||||||
const MAX_BODY_BYTES = 100 * 1024;
|
const MAX_BODY_BYTES = 100 * 1024;
|
||||||
|
|
||||||
/** Минимальный баланс SOL на бетторе: рента PDA + комиссия. */
|
|
||||||
const MIN_SOL_LAMPORTS = 10_000_000;
|
|
||||||
|
|
||||||
const NATS_URL_DEFAULT = "nats://192.168.88.93:4222";
|
const NATS_URL_DEFAULT = "nats://192.168.88.93:4222";
|
||||||
const NATS_PRICE_SUBJECT_DEFAULT = "market.price.solusdt";
|
const NATS_PRICE_SUBJECT_DEFAULT = "market.price.solusdt";
|
||||||
const WS_PATH = "/ws";
|
const WS_PATH = "/ws";
|
||||||
@@ -122,6 +137,55 @@ function humanize(units: string | number | bigint, decimals: number): string {
|
|||||||
return `${sign}${abs / pow}.${(abs % pow).toString().padStart(decimals, "0")}`;
|
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.
|
* Custodial keystore бетторов шлюза: адрес -> keypair.
|
||||||
* Файл — JSON { "<pubkey>": [секретные байты...] }, права 0600.
|
* Файл — JSON { "<pubkey>": [секретные байты...] }, права 0600.
|
||||||
@@ -205,12 +269,6 @@ function normalizeSide(value: unknown): number | null {
|
|||||||
return 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> {
|
async function handleState(ctx: Ctx): Promise<HttpJson> {
|
||||||
const g = await readGlobal(ctx.program, ctx.program.programId, ctx.cfg.tokenMint);
|
const g = await readGlobal(ctx.program, ctx.program.programId, ctx.cfg.tokenMint);
|
||||||
if (!g) {
|
if (!g) {
|
||||||
@@ -219,9 +277,80 @@ async function handleState(ctx: Ctx): Promise<HttpJson> {
|
|||||||
body: { error: "Contract not initialized (relayer: npm run init)" },
|
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 {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
|
// Прежние поля — НЕ трогаем (тестовый фронт их читает).
|
||||||
counter: g.counter.toString(),
|
counter: g.counter.toString(),
|
||||||
nextBetId: g.counter.toString(),
|
nextBetId: g.counter.toString(),
|
||||||
minAmount: g.minAmount.toString(),
|
minAmount: g.minAmount.toString(),
|
||||||
@@ -235,6 +364,18 @@ async function handleState(ctx: Ctx): Promise<HttpJson> {
|
|||||||
relayer: g.relayer.toBase58(),
|
relayer: g.relayer.toBase58(),
|
||||||
vaultBalance: g.vaultBalance,
|
vaultBalance: g.vaultBalance,
|
||||||
vaultBalanceHuman: humanize(g.vaultBalance, ctx.decimals),
|
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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -256,121 +397,119 @@ async function handlePrice(): Promise<HttpJson> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleBet(ctx: Ctx, body: Record<string, unknown>): Promise<HttpJson> {
|
async function handleBet(ctx: Ctx, body: Record<string, unknown>): Promise<HttpJson> {
|
||||||
|
// 1. side обязателен; address/amountUnits в новой версии молча игнорируются
|
||||||
|
// (тестовый фронт ещё может их слать — не ломаем совместимость).
|
||||||
const side = normalizeSide(body.side);
|
const side = normalizeSide(body.side);
|
||||||
if (side === null) {
|
if (side === null) {
|
||||||
return { status: 400, body: { error: 'side must be 0/"UP" or 1/"DOWN"' } };
|
return { status: 400, body: { error: 'side must be 0/"UP" or 1/"DOWN"' } };
|
||||||
}
|
}
|
||||||
const amountUnits = parseAmountUnits(body.amountUnits);
|
const sideText = side === SIDE_UP ? "UP" : "DOWN";
|
||||||
if (amountUnits === null) {
|
const fixedBettor = ctx.cfg.fixedBettor;
|
||||||
return {
|
const fixedBettorStr = fixedBettor.toBase58();
|
||||||
status: 400,
|
const nowMs = Date.now();
|
||||||
body: { error: "amountUnits must be a positive integer (token base units)" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 2. readGlobal → globalPresent / paused / min / max / expiry.
|
||||||
const g = await readGlobal(ctx.program, ctx.program.programId, ctx.cfg.tokenMint);
|
const g = await readGlobal(ctx.program, ctx.program.programId, ctx.cfg.tokenMint);
|
||||||
if (!g) {
|
const globalPresent = g !== null;
|
||||||
return {
|
const paused = g ? g.paused.toNumber() : 0;
|
||||||
status: 409,
|
const minAmount = g ? g.minAmount.toNumber() : 0;
|
||||||
body: { error: "Contract not initialized (relayer: npm run init)" },
|
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)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (amountUnits < g.minAmount.toNumber() || amountUnits > g.maxAmount.toNumber()) {
|
|
||||||
|
// 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 {
|
return {
|
||||||
status: 400,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
error: `amountUnits ${amountUnits} out of bounds [${g.minAmount.toString()}, ${g.maxAmount.toString()}]`,
|
accepted: false,
|
||||||
},
|
reasonCode: decision.reasonCode,
|
||||||
};
|
reason: decision.reasonText,
|
||||||
}
|
retryAfterSeconds,
|
||||||
|
betId: null,
|
||||||
// address: обязателен. Без него — 400 с подсказкой, как создать кошелёк.
|
txSig: null,
|
||||||
const addressRaw = body.address;
|
|
||||||
if (addressRaw === undefined || addressRaw === null || addressRaw === "") {
|
|
||||||
return {
|
|
||||||
status: 400,
|
|
||||||
body: {
|
|
||||||
error:
|
|
||||||
"address is required: create a wallet first (POST /wallet) and top it up (GET /faucet/{address})",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const pub = parsePubkey(addressRaw);
|
|
||||||
if (!pub) {
|
|
||||||
return { status: 400, body: { error: "address is not a valid Solana pubkey" } };
|
|
||||||
}
|
|
||||||
const kp = ctx.keystore.get(pub.toBase58());
|
|
||||||
if (!kp) {
|
|
||||||
return {
|
|
||||||
status: 400,
|
|
||||||
body: {
|
|
||||||
error: `bettor ${pub.toBase58()} is not registered in the gateway keystore`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const bettor = pub;
|
|
||||||
|
|
||||||
// Предварительная проверка балансов — лучше явный 400, чем невнятная ошибка контракта.
|
|
||||||
// Сначала SOL (рента PDA + комиссия), потом фантики (размер ставки).
|
|
||||||
const balances = await readBalances(ctx, bettor);
|
|
||||||
if (balances.solLamports < MIN_SOL_LAMPORTS) {
|
|
||||||
return {
|
|
||||||
status: 400,
|
|
||||||
body: {
|
|
||||||
error: `insufficient funds: need at least ${MIN_SOL_LAMPORTS / LAMPORTS_PER_SOL_NUM} SOL for rent+fees; top up via GET /faucet/${bettor.toBase58()} (test mode only)`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (BigInt(balances.tokenUnits) < BigInt(amountUnits)) {
|
|
||||||
return {
|
|
||||||
status: 400,
|
|
||||||
body: {
|
|
||||||
error: `insufficient funds: have ${balances.tokenUnits} token base units, need ${amountUnits}; top up via GET /faucet/${bettor.toBase58()} (test mode only)`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// «У адреса уже есть открытая ставка» — читаем цепочку, не память процесса
|
|
||||||
// (при рестарте пода она теряется). Идём по PDA `betPda(0..counter-1)` и
|
|
||||||
// фильтруем по status === 0 И bettor === address. Возвращаем 409 + id первой.
|
|
||||||
//
|
|
||||||
// ⚠️ Ловушка, проверенная живьём: фильтровать через
|
|
||||||
// `memcmp: { offset: 56, bytes: ... }` нельзя — `bytes` это base58
|
|
||||||
// **32-байтового** значения, а поле `status` занимает 8 байт, и сравнение
|
|
||||||
// молча промахивается. Поэтому фильтруем ТОЛЬКО по bettor (если бы
|
|
||||||
// memcmp был нужен), статус — в коде. Здесь мы идём по PDA напрямую,
|
|
||||||
// так что вообще не используем memcmp.
|
|
||||||
const openId = await findOpenBetForBettorViaChain(
|
|
||||||
ctx.program,
|
|
||||||
ctx.program.programId,
|
|
||||||
g.counter.toNumber(),
|
|
||||||
bettor
|
|
||||||
);
|
|
||||||
if (openId !== null) {
|
|
||||||
return {
|
|
||||||
status: 409,
|
|
||||||
body: {
|
|
||||||
error: "you already have an open bet: close it before placing a new one",
|
|
||||||
openBetId: openId.toString(),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 8. canBet=true → getPrice() + createBet(...).
|
||||||
const entryPrice = await getPrice();
|
const entryPrice = await getPrice();
|
||||||
const { id, tx } = await createBet(ctx.program, ctx.cfg.tokenMint, kp, {
|
const { id, tx } = await createBet(ctx.program, ctx.cfg.tokenMint, kp!, {
|
||||||
side,
|
side,
|
||||||
amount: amountUnits,
|
amount: decision.amountUnits,
|
||||||
entryPrice,
|
entryPrice,
|
||||||
});
|
});
|
||||||
|
|
||||||
// WS-событие об открытии — после успешной транзакции. Достаём реальные
|
// 9. Прочитать реальный Bet-аккаунт (entry/expire/bettor) → WS-событие
|
||||||
// значения из цепочки (entry_price/expire_time/bettor), чтобы UI не
|
// + реальный expireTime для ответа.
|
||||||
// показывал то, чего не подтвердил контракт.
|
let expireTime = 0;
|
||||||
try {
|
try {
|
||||||
const betPdaAddr = betPda(ctx.program.programId, id);
|
const betPdaAddr = betPda(ctx.program.programId, id);
|
||||||
const info = await ctx.connection.getAccountInfo(betPdaAddr);
|
const info = await ctx.connection.getAccountInfo(betPdaAddr);
|
||||||
if (info) {
|
if (info) {
|
||||||
const decoded = decodeBet(info.data as Buffer);
|
const decoded = decodeBet(info.data as Buffer);
|
||||||
|
expireTime = decoded.expireTime;
|
||||||
ctx.broadcast(betOpenEvent(decoded, id.toNumber()));
|
ctx.broadcast(betOpenEvent(decoded, id.toNumber()));
|
||||||
} else {
|
} else {
|
||||||
console.error(`gateway: bet #${id.toString()} account not found after create tx=${tx}`);
|
console.error(`gateway: bet #${id.toString()} account not found after create tx=${tx}`);
|
||||||
@@ -384,10 +523,23 @@ async function handleBet(ctx: Ctx, body: Record<string, unknown>): Promise<HttpJ
|
|||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
tx,
|
// Новый формат.
|
||||||
id: id.toString(),
|
accepted: true,
|
||||||
bettor: bettor.toBase58(),
|
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,
|
entryPrice,
|
||||||
|
expireTime,
|
||||||
|
|
||||||
|
// Обратная совместимость с тестовым фронтом: j.id / j.tx / j.bettor.
|
||||||
|
id: id.toString(),
|
||||||
|
tx,
|
||||||
|
bettor: fixedBettorStr,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -805,7 +957,7 @@ async function main(): Promise<void> {
|
|||||||
server.listen(port, "0.0.0.0", () => {
|
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: listening on 0.0.0.0:${port} (RPC ${cfg.rpcUrl}, mint ${cfg.tokenMint.toBase58()})`);
|
||||||
console.log(
|
console.log(
|
||||||
`gateway: endpoints: GET /state, GET /price, POST /wallet, POST /bet, GET /balance/{address}, GET /faucet/{address}, WS /ws (price + bet_open + bet_closed)`
|
`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(
|
console.log(
|
||||||
`gateway: settle scheduler: interval=${cfg.settleIntervalMs}ms (close_bet auto on expire)`
|
`gateway: settle scheduler: interval=${cfg.settleIntervalMs}ms (close_bet auto on expire)`
|
||||||
@@ -836,13 +988,27 @@ async function main(): Promise<void> {
|
|||||||
return loadAllBetsForProgram(program, program.programId, counter);
|
return loadAllBetsForProgram(program, program.programId, counter);
|
||||||
},
|
},
|
||||||
nowMs: () => Date.now(),
|
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 считает, что состояния не видел, и логирует всё подряд
|
// передаются в каждый тик — иначе settleOnce считает, что состояния не
|
||||||
// (спам одинаковыми строками каждые SMART_SETTLE_INTERVAL_MS).
|
// видел (спам одинаковыми строками каждые SMART_SETTLE_INTERVAL_MS) и
|
||||||
|
// теряет счётчик переигровок по цепочке ничьих.
|
||||||
const lastState = new Map<number, string>();
|
const lastState = new Map<number, string>();
|
||||||
|
const replayCounts = new Map<number, number>();
|
||||||
const schedulerHandle = setInterval(() => {
|
const schedulerHandle = setInterval(() => {
|
||||||
withBetLock(() => settleOnce({ ...schedulerCtx, lastState })).catch((e) =>
|
withBetLock(() => settleOnce({ ...schedulerCtx, lastState, replayCounts })).catch((e) =>
|
||||||
console.error(
|
console.error(
|
||||||
`settle: tick crashed: ${e instanceof Error ? e.message : String(e)}`
|
`settle: tick crashed: ${e instanceof Error ? e.message : String(e)}`
|
||||||
)
|
)
|
||||||
@@ -861,7 +1027,9 @@ async function main(): Promise<void> {
|
|||||||
process.on("SIGTERM", shutdown);
|
process.on("SIGTERM", shutdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
if (require.main === module) {
|
||||||
console.error(e);
|
main().catch((e) => {
|
||||||
process.exit(1);
|
console.error(e);
|
||||||
});
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
+120
-4
@@ -195,6 +195,30 @@ export function betClosedEvent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* п.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 {
|
export interface SchedulerCtx {
|
||||||
/**
|
/**
|
||||||
@@ -222,6 +246,29 @@ export interface SchedulerCtx {
|
|||||||
* тогда поведение прежнее (всё логируется каждый тик).
|
* тогда поведение прежнее (всё логируется каждый тик).
|
||||||
*/
|
*/
|
||||||
lastState?: Map<number, string>;
|
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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -367,6 +414,73 @@ export async function settleOnce(ctx: SchedulerCtx): Promise<{
|
|||||||
) {
|
) {
|
||||||
stats.settled++;
|
stats.settled++;
|
||||||
if (lastState) lastState.delete(item.id);
|
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));
|
ctx.broadcast(betClosedEvent(after, item.id, exitPrice, after.status));
|
||||||
console.log(
|
console.log(
|
||||||
`settle: closed #${item.id} exit=${exitPrice} status=${after.status} tx=${txSig}`
|
`settle: closed #${item.id} exit=${exitPrice} status=${after.status} tx=${txSig}`
|
||||||
@@ -405,12 +519,14 @@ export function startScheduler(
|
|||||||
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
||||||
throw new Error(`startScheduler: invalid intervalMs ${intervalMs}`);
|
throw new Error(`startScheduler: invalid intervalMs ${intervalMs}`);
|
||||||
}
|
}
|
||||||
// Карта последних состояний живёт в замыкании планировщика и
|
// Карта последних состояний и счётчик переигровок живут в замыкании
|
||||||
// передаётся в каждый тик — см. settleOnce. Без неё поведение прежнее
|
// планировщика и передаются в каждый тик — см. settleOnce. Без них
|
||||||
// (логируется всё каждый тик).
|
// поведение прежнее (логируется всё каждый тик / переигровка ничьих
|
||||||
|
// недоступна).
|
||||||
const lastState = new Map<number, string>();
|
const lastState = new Map<number, string>();
|
||||||
|
const replayCounts = new Map<number, number>();
|
||||||
const handle = setInterval(() => {
|
const handle = setInterval(() => {
|
||||||
settleOnce({ ...ctx, lastState }).catch((e) => {
|
settleOnce({ ...ctx, lastState, replayCounts }).catch((e) => {
|
||||||
console.error(
|
console.error(
|
||||||
`settle: tick crashed: ${e instanceof Error ? e.message : String(e)}`
|
`settle: tick crashed: ${e instanceof Error ? e.message : String(e)}`
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user