From dd4f16a930523ee8073ce76d27dd4a1385f7e70b Mon Sep 17 00:00:00 2001 From: subochev Date: Thu, 17 Sep 2026 00:11:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(relayer):=20API=20v2=20(canBet/30%=20?= =?UTF-8?q?=D0=BE=D1=82=20=D0=B1=D0=B0=D0=BB=D0=B0=D0=BD=D1=81=D0=B0)=20+?= =?UTF-8?q?=20=D0=BF.8=20=E2=80=94=20=D0=BD=D0=B8=D1=87=D1=8C=D1=8F=20?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=B0=D0=B5=D1=82=20=D0=B7=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тестовый 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). --- relayer/API.md | 378 +++++++++++++++++++++ relayer/helm/templates/deployment.yaml | 6 + relayer/helm/values.yaml | 9 + relayer/src/betRules.ts | 132 ++++++++ relayer/src/check.ts | 434 +++++++++++++++++++++++++ relayer/src/config.ts | 33 ++ relayer/src/gateway.ts | 430 ++++++++++++++++-------- relayer/src/settle.ts | 124 ++++++- 8 files changed, 1411 insertions(+), 135 deletions(-) create mode 100644 relayer/API.md create mode 100644 relayer/src/betRules.ts diff --git a/relayer/API.md b/relayer/API.md new file mode 100644 index 0000000..44f02ce --- /dev/null +++ b/relayer/API.md @@ -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-ошибок. diff --git a/relayer/helm/templates/deployment.yaml b/relayer/helm/templates/deployment.yaml index 4c802ca..5116b9b 100644 --- a/relayer/helm/templates/deployment.yaml +++ b/relayer/helm/templates/deployment.yaml @@ -79,6 +79,12 @@ spec: 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 diff --git a/relayer/helm/values.yaml b/relayer/helm/values.yaml index f9c58e5..7c16f92 100644 --- a/relayer/helm/values.yaml +++ b/relayer/helm/values.yaml @@ -29,6 +29,15 @@ smart: 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' diff --git a/relayer/src/betRules.ts b/relayer/src/betRules.ts new file mode 100644 index 0000000..97da380 --- /dev/null +++ b/relayer/src/betRules.ts @@ -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 }; +} diff --git a/relayer/src/check.ts b/relayer/src/check.ts index a2353f3..3cf4a84 100644 --- a/relayer/src/check.ts +++ b/relayer/src/check.ts @@ -26,15 +26,28 @@ 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; @@ -431,9 +444,414 @@ async function main(): Promise { 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; + 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; + }): Promise<{ broadcasts: Array>; replayCalls: Array<{ side: number; amount: number; bettor: PublicKey }>; counts: Map }> { + const broadcasts: Array> = []; + const replayCalls: Array<{ side: number; amount: number; bettor: PublicKey }> = []; + const counts = opts.preCounts ?? new Map(); + 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 => { + 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([[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("open", "last"); + check( + r.openBet === "open" && r.lastBet === null, + 'pickBetViews: открытая приоритетна → openBet="open", lastBet=null' + ); + } + + // pickBetViews: только закрытая — openBet = null, lastBet сохраняется. + { + const r = pickBetViews(null, "last"); + check( + r.openBet === null && r.lastBet === "last", + 'pickBetViews: только закрытая → openBet=null, lastBet="last"' + ); + } + + // pickBetViews: пусто — обе null. + { + const r = pickBetViews(null, null); + check( + r.openBet === null && r.lastBet === null, + "pickBetViews: пусто → обе null" + ); + } + if (failed > 0) { console.error(`\ncheck: ${failed} проверок ПРОВАЛЕНО`); process.exit(1); @@ -441,6 +859,22 @@ async function main(): Promise { 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); diff --git a/relayer/src/config.ts b/relayer/src/config.ts index 5691440..e3f12a8 100644 --- a/relayer/src/config.ts +++ b/relayer/src/config.ts @@ -52,6 +52,19 @@ export interface Config { 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 { @@ -66,6 +79,17 @@ 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")); @@ -96,6 +120,15 @@ export function loadConfig(): Config { 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), }; } diff --git a/relayer/src/gateway.ts b/relayer/src/gateway.ts index 167184d..f3c5987 100644 --- a/relayer/src/gateway.ts +++ b/relayer/src/gateway.ts @@ -8,39 +8,53 @@ * * Эндпоинты (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 /wallet — создать custodial-кошелёк (только test-mode). Ответ: {address}. * НЕ наливает ни SOL, ни фантики — деньги отдельно через /faucet/{address}. - * POST /bet {address, side, amountUnits} - * — address: обязателен (получен из /wallet и пополнен через /faucet). - * Ставка НИЧЕГО не наливает — только проверяет, что фантиков - * и SOL хватает, И что у адреса ещё нет открытой ставки - * (запрос идёт в цепочку, не в память процесса — иначе после - * рестарта пода проверка теряется). Шлёт create_bet и - * сразу же публикует WS-событие `bet_open`. - * Ответ: {tx, id, bettor, entryPrice}. - * Закрытие ставки происходит автоматически по экспирации — - * см. планировщик в `src/settle.ts`. + * 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 + фантиков на ПРОИЗВОЛЬНЫЙ адрес (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_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), @@ -79,24 +93,25 @@ import { SchedulerCtx, SIDE_UP, SIDE_DOWN, + STATUS_OPEN, + STATUS_PAYOUT_DONE, + STATUS_HOUSE_WON, + STATUS_REFUNDED, settleOnce, betOpenEvent, - findOpenBetForBettorViaChain, + DecodedBet, } from "./settle"; +import { + computeBetAmountUnits, + canBetDecision, +} from "./betRules"; import type { SmartUpdown } from "../idl/smart_updown"; 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 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_PRICE_SUBJECT_DEFAULT = "market.price.solusdt"; 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")}`; } +/** + * Формирует 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 { + 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( + 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 { "": [секретные байты...] }, права 0600. @@ -205,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 { const g = await readGlobal(ctx.program, ctx.program.programId, ctx.cfg.tokenMint); if (!g) { @@ -219,9 +277,80 @@ async function handleState(ctx: Ctx): Promise { 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 | null = null; + let lastBetView: Record | 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(), @@ -235,6 +364,18 @@ async function handleState(ctx: Ctx): Promise { 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, }, }; } @@ -256,121 +397,119 @@ async function handlePrice(): Promise { } async function handleBet(ctx: Ctx, body: Record): Promise { + // 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); - if (!g) { - return { - status: 409, - body: { error: "Contract not initialized (relayer: npm run init)" }, - }; + 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)}` + ); + } } - 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 { - status: 400, + status: 200, body: { - error: `amountUnits ${amountUnits} out of bounds [${g.minAmount.toString()}, ${g.maxAmount.toString()}]`, - }, - }; - } - - // address: обязателен. Без него — 400 с подсказкой, как создать кошелёк. - 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(), + accepted: false, + reasonCode: decision.reasonCode, + reason: decision.reasonText, + retryAfterSeconds, + betId: null, + txSig: null, }, }; } + // 8. canBet=true → getPrice() + createBet(...). 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, - amount: amountUnits, + amount: decision.amountUnits, entryPrice, }); - // WS-событие об открытии — после успешной транзакции. Достаём реальные - // значения из цепочки (entry_price/expire_time/bettor), чтобы UI не - // показывал то, чего не подтвердил контракт. + // 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}`); @@ -384,10 +523,23 @@ async function handleBet(ctx: Ctx, body: Record): Promise { 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 /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( `gateway: settle scheduler: interval=${cfg.settleIntervalMs}ms (close_bet auto on expire)` @@ -836,13 +988,27 @@ async function main(): Promise { 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). + // Карта последних состояний и счётчик переигровок живут здесь и + // передаются в каждый тик — иначе settleOnce считает, что состояния не + // видел (спам одинаковыми строками каждые SMART_SETTLE_INTERVAL_MS) и + // теряет счётчик переигровок по цепочке ничьих. const lastState = new Map(); + const replayCounts = new Map(); const schedulerHandle = setInterval(() => { - withBetLock(() => settleOnce({ ...schedulerCtx, lastState })).catch((e) => + withBetLock(() => settleOnce({ ...schedulerCtx, lastState, replayCounts })).catch((e) => console.error( `settle: tick crashed: ${e instanceof Error ? e.message : String(e)}` ) @@ -861,7 +1027,9 @@ async function main(): Promise { process.on("SIGTERM", shutdown); } -main().catch((e) => { - console.error(e); - process.exit(1); -}); \ No newline at end of file +if (require.main === module) { + main().catch((e) => { + console.error(e); + process.exit(1); + }); +} \ No newline at end of file diff --git a/relayer/src/settle.ts b/relayer/src/settle.ts index 6d2f419..e9b18d5 100644 --- a/relayer/src/settle.ts +++ b/relayer/src/settle.ts @@ -195,6 +195,30 @@ export function betClosedEvent( }; } +/** + * п.8 ТЗ заказчика: при ничьей ставка молча играет заново. Игроку НЕ + * сообщаем про ничью — вместо `bet_closed` уходит это событие: новый + * раунд с тем же направлением и размером. + */ +export function betReplayEvent( + bet: DecodedBet, + id: number, + prevId: number, + replayCount: number +): Record { + 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 { /** @@ -222,6 +246,29 @@ export interface SchedulerCtx { * тогда поведение прежнее (всё логируется каждый тик). */ lastState?: Map; + /** + * Пересоздать ставку тем же размером и направлением (п.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; } /** @@ -367,6 +414,73 @@ export async function settleOnce(ctx: SchedulerCtx): Promise<{ ) { 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}` @@ -405,12 +519,14 @@ export function startScheduler( if (!Number.isFinite(intervalMs) || intervalMs <= 0) { throw new Error(`startScheduler: invalid intervalMs ${intervalMs}`); } - // Карта последних состояний живёт в замыкании планировщика и - // передаётся в каждый тик — см. settleOnce. Без неё поведение прежнее - // (логируется всё каждый тик). + // Карта последних состояний и счётчик переигровок живут в замыкании + // планировщика и передаются в каждый тик — см. settleOnce. Без них + // поведение прежнее (логируется всё каждый тик / переигровка ничьих + // недоступна). const lastState = new Map(); + const replayCounts = new Map(); const handle = setInterval(() => { - settleOnce({ ...ctx, lastState }).catch((e) => { + settleOnce({ ...ctx, lastState, replayCounts }).catch((e) => { console.error( `settle: tick crashed: ${e instanceof Error ? e.message : String(e)}` );