Тестовый 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).
16 KiB
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)
{
// Прежние поля (НЕ убирать — тестовый фронт их читает).
"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)
{
"symbol": "solusdt",
"price": "101.89000000",
"ts": 1789589385123
}
Коды ошибок
- 503 — NATS ещё не прислал ни одного тика (
error: "price not available yet").
POST /wallet
Создаёт новый custodial-кошелёк беттора. НИЧЕГО не наливает — деньги через /faucet/{address}.
Тело запроса
Пустое.
Ответ (HTTP 200)
{ "address": "7xK9...AbCd" }
Адрес автоматически регистрируется в keystore шлюза.
POST /bet
Ставка за захардкоженного беттора (GW_FIXED_BETTOR). Сумма НЕ передаётся —
шлюз считает её сам. Адрес беттора тоже НЕ передаётся.
Тело запроса
{ "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):
{
"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):
{
"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)
{
"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)
{
"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-тик цены
{
"type": "price",
"symbol": "solusdt",
"price": "101.89000000",
"ts": 1789589385123
}
Событие bet_open — после успешного POST /bet
{
"type": "bet_open",
"id": "81",
"bettor": "6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6",
"side": "UP",
"amountUnits": "2940000000",
"entryPrice": 9856000000,
"expireTime": 1789589400
}
Событие bet_closed — после авто-закрытия планировщиком (settle.ts)
Приходит, когда сработал close_bet (по экспирации + наличию цены в истории).
{
"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.
{
"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-ошибок.