Files
subochev dd4f16a930 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).
2026-09-17 00:11:31 +03:00

16 KiB
Raw Permalink Blame History

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-ошибок.