feat(relayer): цена SOL из NATS + WS-раздача + деплой-артефакты
deploy-contract / deploy-contract (release) Failing after 3m34s

priceSource больше не рандомит: цена берётся из NATS (market.price.solusdt),
конверсия строка->целое x1e8 без float. Шлюз раздаёт цену по WS (/ws,
нормализация /api/... -> /...) — фронт просто выводит её. Добавлены
Dockerfile и helm-чарт для деплоя в k3s.
This commit is contained in:
Caffeine
2026-09-13 05:53:48 +03:00
parent bf9d05f2f2
commit 92cf2f61b4
14 changed files with 705 additions and 58 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.log
.gateway-bettors.json
*.md
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY src/ ./src/
COPY idl/ ./idl/
COPY tsconfig.json ./
USER node
ENV GW_PORT=8895
EXPOSE 8895
CMD ["npx", "ts-node", "src/gateway.ts"]
+5
View File
@@ -0,0 +1,5 @@
apiVersion: v2
name: smart-updown-relayer
type: application
version: "9.9.9"
appVersion: "9.9.9"
+41
View File
@@ -0,0 +1,41 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "smart-updown-relayer.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "smart-updown-relayer.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Common labels.
*/}}
{{- define "smart-updown-relayer.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
app.kubernetes.io/name: {{ include "smart-updown-relayer.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels.
*/}}
{{- define "smart-updown-relayer.selectorLabels" -}}
app.kubernetes.io/name: {{ include "smart-updown-relayer.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
+111
View File
@@ -0,0 +1,111 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "smart-updown-relayer.fullname" . }}
labels:
{{- include "smart-updown-relayer.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "smart-updown-relayer.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "smart-updown-relayer.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
initContainers:
- name: seed-bettors
image: "{{ .Values.image.name }}:{{ default .Chart.AppVersion .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- sh
- -c
- '[ -f /app/data/.gateway-bettors.json ] || cp /seed/.gateway-bettors.json /app/data/.gateway-bettors.json'
volumeMounts:
- name: data
mountPath: /app/data
- name: bettors-seed
mountPath: /seed
readOnly: true
containers:
- name: gateway
image: "{{ .Values.image.name }}:{{ default .Chart.AppVersion .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.port }}
protocol: TCP
env:
- name: SMART_RPC_URL
value: {{ .Values.smart.rpcUrl | quote }}
- name: SMART_PROGRAM_ID
value: {{ .Values.smart.programId | quote }}
- name: SMART_TOKEN_MINT
value: {{ .Values.smart.tokenMint | quote }}
- name: SMART_ADMIN_KEYPAIR
value: {{ .Values.smart.adminKeypair | quote }}
- name: SMART_ALLOW_AIRDROP
value: {{ .Values.smart.allowAirdrop | ternary "1" "0" | quote }}
- name: SMART_MIN_AMOUNT
value: {{ .Values.smart.minAmount | quote }}
- name: SMART_MAX_AMOUNT
value: {{ .Values.smart.maxAmount | quote }}
- name: SMART_MULTIPLIER_BPS
value: {{ .Values.smart.multiplierBps | quote }}
- name: SMART_EXPIRY_SECONDS
value: {{ .Values.smart.expirySeconds | quote }}
- name: SMART_START_BALANCE
value: {{ .Values.smart.startBalance | quote }}
- name: GW_PORT
value: {{ .Values.port | quote }}
- name: GW_BETTOR_KEYSTORE
value: {{ .Values.keystorePath | quote }}
- name: NATS_URL
value: {{ .Values.nats.url | quote }}
- name: NATS_PRICE_SUBJECT
value: {{ .Values.nats.priceSubject | quote }}
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 20
periodSeconds: 15
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 20
periodSeconds: 15
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /app/data
- name: admin-keypair
mountPath: /app/secrets
readOnly: true
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "smart-updown-relayer.fullname" . }}-data
{{- else }}
emptyDir: {}
{{- end }}
- name: admin-keypair
secret:
secretName: smart-updown-keypair
items:
- key: admin-keypair.json
path: admin-keypair.json
defaultMode: 0444
- name: bettors-seed
secret:
secretName: testfront-bettors
items:
- key: .gateway-bettors.json
path: .gateway-bettors.json
defaultMode: 0444
+20
View File
@@ -0,0 +1,20 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "smart-updown-relayer.fullname" . }}
labels:
{{- include "smart-updown-relayer.labels" . | nindent 4 }}
spec:
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "smart-updown-relayer.fullname" . }}
port:
number: {{ .Values.port }}
{{- end }}
+17
View File
@@ -0,0 +1,17 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "smart-updown-relayer.fullname" . }}-data
labels:
{{- include "smart-updown-relayer.labels" . | nindent 4 }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- if .Values.persistence.storageClassName }}
storageClassName: {{ .Values.persistence.storageClassName }}
{{- end }}
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "smart-updown-relayer.fullname" . }}
labels:
{{- include "smart-updown-relayer.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- name: http
port: {{ .Values.port }}
targetPort: http
protocol: TCP
selector:
{{- include "smart-updown-relayer.selectorLabels" . | nindent 4 }}
+40
View File
@@ -0,0 +1,40 @@
replicaCount: 1
image:
name: 'images.binom.pw/smart/updown-relayer'
pullPolicy: IfNotPresent
tag: ''
imagePullSecrets:
- name: regcred
port: 8895
ingress:
host: ''
enabled: false
smart:
rpcUrl: 'http://192.168.76.181:8899'
programId: '9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf'
tokenMint: '8dpXNUgWN4m8ohhi6dkVqw4XezbDF7oKDSgnbf2VKjQJ'
adminKeypair: '/app/secrets/admin-keypair.json'
allowAirdrop: true
minAmount: 100000000
maxAmount: 10000000000
multiplierBps: 13000
expirySeconds: 15
startBalance: 10000000000
nats:
url: 'nats://192.168.88.93:4222'
priceSubject: 'market.price.solusdt'
keystorePath: '/app/data/.gateway-bettors.json'
persistence:
enabled: true
size: 1Gi
storageClassName: local-path
resources: {}
+73 -39
View File
@@ -12,11 +12,14 @@
"@anchor-lang/core": "^1.0.0",
"@solana/spl-token": "^0.4.15",
"@solana/web3.js": "^1.98.0",
"bn.js": "^5.2.0"
"bn.js": "^5.2.0",
"nats": "^2.29.3",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/bn.js": "^5.2.0",
"@types/node": "^25.0.0",
"@types/ws": "^8.5.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
@@ -501,9 +504,9 @@
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "7.4.7",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz",
"integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -855,12 +858,42 @@
"integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==",
"license": "MIT"
},
"node_modules/jayson/node_modules/@types/ws": {
"version": "7.4.7",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz",
"integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/jayson/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/jayson/node_modules/ws": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
"integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
@@ -880,6 +913,31 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nats": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz",
"integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==",
"deprecated": "Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js",
"license": "Apache-2.0",
"dependencies": {
"nkeys.js": "1.1.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/nkeys.js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz",
"integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==",
"license": "Apache-2.0",
"dependencies": {
"tweetnacl": "1.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -951,15 +1009,6 @@
"utf-8-validate": "^6.0.0"
}
},
"node_modules/rpc-websockets/node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/rpc-websockets/node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
@@ -993,27 +1042,6 @@
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/rpc-websockets/node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1122,6 +1150,12 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"license": "Unlicense"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1190,16 +1224,16 @@
}
},
"node_modules/ws": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
"integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
+4 -1
View File
@@ -16,11 +16,14 @@
"@anchor-lang/core": "^1.0.0",
"@solana/spl-token": "^0.4.15",
"@solana/web3.js": "^1.98.0",
"bn.js": "^5.2.0"
"bn.js": "^5.2.0",
"nats": "^2.29.3",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/bn.js": "^5.2.0",
"@types/node": "^25.0.0",
"@types/ws": "^8.5.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
+157 -5
View File
@@ -9,6 +9,7 @@
*
* Эндпоинты (CORS: Access-Control-Allow-Origin: *):
* GET /state — Global: counter, min/max, multiplier, expiry, paused, vault
* GET /price — текущая сырая цена SOL из NATS (для отладки); 503 если ещё нет
* POST /bet {address?, side, amountUnits}
* — address: custodial-беттор шлюза; если не задан — шлюз
* генерирует новый и регистрирует в keystore.
@@ -18,9 +19,17 @@
* GET /faucet/{address} — налив SOL + фантиков на произвольный адрес
* (только localnet, allowAirdrop=1; иначе 403). Ответ:
* {address, sol, solLamports, tokenUnits, tokenHuman}
* GET /ws (WebSocket) — push цены SOL из NATS; на подключение шлёт
* снапшот (если цена уже есть), дальше — каждый тик.
*
* Пути нормализуются: 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, SMART_* (как у релейера),
* NATS_URL (default nats://192.168.88.93:4222),
* NATS_PRICE_SUBJECT (default market.price.solusdt),
* PRICE_WAIT_TIMEOUT_MS (default 15000).
*/
import http from "http";
import * as fs from "fs";
@@ -29,6 +38,7 @@ import { Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import { getAssociatedTokenAddress, getMint } from "@solana/spl-token";
import BN from "bn.js";
import type { Program } from "@anchor-lang/core";
import { WebSocketServer, WebSocket } from "ws";
import { loadConfig, loadKeypair, SIDE_DOWN, SIDE_UP } from "./config";
import {
closeBet,
@@ -40,7 +50,8 @@ import {
} from "./ops";
import { betPda } from "./pda";
import { makeConnection, makeProgram } from "./program";
import { getPrice } from "./priceSource";
import { currentPrice, onPrice, connectNats } from "./natsPrice";
import { currentPriceHuman, getPrice } from "./priceSource";
import type { SmartUpdown } from "../idl/smart_updown";
@@ -51,6 +62,22 @@ const STATUS_HOUSE_WON = 2;
const LAMPORTS_PER_SOL_NUM = Number(LAMPORTS_PER_SOL);
const MAX_BODY_BYTES = 100 * 1024;
const NATS_URL_DEFAULT = "nats://192.168.88.93:4222";
const NATS_PRICE_SUBJECT_DEFAULT = "market.price.solusdt";
const WS_PATH = "/ws";
const WS_HEARTBEAT_MS = 30_000;
const WS_MISSED_PONGS = 2;
/**
* Ingress пробрасывает путь как есть, без stripPrefix. Нормализуем
* `/api/...` к `/...`, чтобы `/api/state` и `/state` работали одинаково.
*/
function normalizePath(p: string): string {
if (p === "/api" || p === "/api/") return "/";
if (p.startsWith("/api/")) return p.slice(4);
return p;
}
/** Токен-юниты -> "N.NNNNNN" по decimals (дефолт 6, как в контракте). */
function humanize(units: string | number | bigint, decimals: number): string {
const n = BigInt(units);
@@ -170,6 +197,22 @@ async function handleState(ctx: Ctx): Promise<HttpJson> {
};
}
async function handlePrice(): Promise<HttpJson> {
const human = currentPriceHuman();
if (human === null) {
return { status: 503, body: { error: "price not available yet" } };
}
const last = currentPrice();
return {
status: 200,
body: {
symbol: "solusdt",
price: human,
ts: last ? last.ts : Date.now(),
},
};
}
async function handleBet(ctx: Ctx, body: Record<string, unknown>): Promise<HttpJson> {
const side = normalizeSide(body.side);
if (side === null) {
@@ -404,6 +447,28 @@ function readJsonBody(req: http.IncomingMessage): Promise<Record<string, unknown
});
}
/** WS-клиенты с флагом живости (2 цикла пропущенных pong -> terminate). */
interface WsClient {
ws: WebSocket;
alive: boolean;
}
function broadcastPrice(wsClients: Set<WsClient>): (p: { price: string; ts: number }) => void {
return (p) => {
const msg = JSON.stringify({
type: "price",
symbol: "solusdt",
price: p.price,
ts: p.ts,
});
for (const c of wsClients) {
if (c.ws.readyState === WebSocket.OPEN) {
c.ws.send(msg);
}
}
};
}
async function main(): Promise<void> {
const cfg = loadConfig();
const admin = loadKeypair(cfg.adminKeypairPath);
@@ -437,6 +502,14 @@ async function main(): Promise<void> {
console.log("gateway: contract NOT initialized — /state будет 409 до `npm run init`");
}
// NATS-подписка на цену: НЕ блокируем старт сервера — если NATS недоступен,
// шлюз всё равно поднимается, /bet будет ждать цену до таймаута.
const natsUrl = process.env.NATS_URL ?? NATS_URL_DEFAULT;
const natsSubject = process.env.NATS_PRICE_SUBJECT ?? NATS_PRICE_SUBJECT_DEFAULT;
connectNats(natsUrl, natsSubject).catch((e) => {
console.error(`gateway: NATS connect failed (${natsUrl}): ${e instanceof Error ? e.message : String(e)}`);
});
const port = Number(process.env.GW_PORT ?? "8895");
const server = http.createServer((req, res) => {
const started = Date.now();
@@ -455,13 +528,14 @@ async function main(): Promise<void> {
res.writeHead(status);
res.end();
}
// Логируем ИСХОДНЫЙ путь, как требует ТЗ.
const url = req.url ?? "/";
console.log(`gateway: ${req.method} ${url} -> ${status} (${Date.now() - started}ms)`);
};
(async () => {
const reqUrl = new URL(req.url ?? "/", "http://gateway.local");
const p = reqUrl.pathname;
const p = normalizePath(reqUrl.pathname);
const m = req.method ?? "GET";
if (m === "OPTIONS") {
@@ -473,6 +547,8 @@ async function main(): Promise<void> {
try {
if (m === "GET" && p === "/state") {
result = await handleState(ctx);
} else if (m === "GET" && p === "/price") {
result = await handlePrice();
} else if (m === "POST" && p === "/bet") {
const body = await readJsonBody(req);
result = await withBetLock(() => handleBet(ctx, body));
@@ -509,15 +585,91 @@ async function main(): Promise<void> {
});
});
// WS-сервер на том же порту (noServer=true), обновляем апгрейды вручную.
const wss = new WebSocketServer({ noServer: true });
const wsClients = new Set<WsClient>();
wss.on("connection", (ws: WebSocket) => {
const client: WsClient = { ws, alive: true };
wsClients.add(client);
ws.on("pong", () => {
client.alive = true;
});
ws.on("close", () => {
wsClients.delete(client);
});
ws.on("error", () => {
wsClients.delete(client);
});
// Снапшот сразу, если цена уже пришла из NATS.
const snap = currentPrice();
if (snap !== null) {
const msg = JSON.stringify({
type: "price",
symbol: "solusdt",
price: snap.price,
ts: snap.ts,
});
ws.send(msg);
}
});
// Рассылка каждого нового NATS-тика по всем подключённым WS.
onPrice(broadcastPrice(wsClients));
// Heartbeat: пинг раз в 30с, нет pong 2 цикла — terminate.
const heartbeat = setInterval(() => {
for (const c of wsClients) {
if (!c.alive) {
try {
c.ws.terminate();
} catch {
// ignore
}
wsClients.delete(c);
continue;
}
c.alive = false;
try {
c.ws.ping();
} catch {
wsClients.delete(c);
}
}
}, WS_HEARTBEAT_MS);
server.on("upgrade", (req, socket, head) => {
const rawUrl = req.url ?? "/";
const reqUrl = new URL(rawUrl, "http://gateway.local");
const p = normalizePath(reqUrl.pathname);
if (p !== WS_PATH) {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
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, POST /bet, POST /close, GET /balance/{address}, GET /faucet/{address}`
`gateway: endpoints: GET /state, GET /price, POST /bet, POST /close, GET /balance/{address}, GET /faucet/{address}, WS /ws`
);
});
// Корректная остановка heartbeat при завершении.
const shutdown = () => {
clearInterval(heartbeat);
wss.close();
server.close();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
});
+166
View File
@@ -0,0 +1,166 @@
/**
* NATS-источник цены SOL.
*
* Подписывается на subject вида `market.price.solusdt` (payload — JSON
* `{"symbol":"solusdt","type":"price","price":"101.89000000",...}`),
* хранит последнее валидное значение и рассылает его всем подписчикам.
*
* Reconnect/autoreconnect включены по умолчанию (пакет `nats`); в Core NATS
* история не сохраняется — новые значения приходят только живой подписке.
*
* Публикаций наружу нет — только чтение.
*/
import { connect, StringCodec, NatsConnection } from "nats";
export interface PriceTick {
/** Сырая строка цены из payload (например, "101.89000000"). */
price: string;
/** Unix ms: берётся из поля `ts` payload'а, иначе Date.now(). */
ts: number;
}
let nc: NatsConnection | null = null;
let last: PriceTick | null = null;
const listeners: Array<(p: PriceTick) => void> = [];
let firstLogged = false;
function notify(p: PriceTick): void {
last = p;
for (const cb of listeners) {
try {
cb(p);
} catch (e) {
console.error(`natsPrice: listener threw: ${e instanceof Error ? e.message : String(e)}`);
}
}
}
function isValidPriceString(s: unknown): s is string {
if (typeof s !== "string") return false;
if (s.length === 0) return false;
// Только цифры и одна точка. Иначе — мусор.
if (!/^[0-9]+(?:\.[0-9]+)?$/.test(s)) return false;
return true;
}
/**
* Подключиться к NATS и подписаться на subject. Возвращает управление
* сразу после установки подписки, не дожидаясь первой цены. Сообщения с
* невалидным JSON / невалидным `price` молча пропускаются (с логом).
*/
export async function connectNats(url: string, subject: string): Promise<void> {
if (nc) {
console.log(`natsPrice: already connected, ignoring reconnect request to ${url}`);
return;
}
const conn = await connect({ servers: [url] });
nc = conn;
console.log(`nats: connected ${url}`);
const sc = StringCodec();
const sub = conn.subscribe(subject, {
callback: (err, msg) => {
if (err) {
console.error(`natsPrice: subscription error: ${err.message}`);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(sc.decode(msg.data));
} catch (e) {
console.log(
`natsPrice: malformed JSON on ${subject}: ${e instanceof Error ? e.message : String(e)}`
);
return;
}
if (!parsed || typeof parsed !== "object") {
console.log(`natsPrice: non-object payload on ${subject}`);
return;
}
const obj = parsed as Record<string, unknown>;
const priceRaw = obj.price;
if (!isValidPriceString(priceRaw)) {
console.log(`natsPrice: invalid price on ${subject}: ${String(priceRaw)}`);
return;
}
const tsNum =
typeof obj.ts === "number" && Number.isFinite(obj.ts)
? (obj.ts as number)
: Date.now();
const tick: PriceTick = { price: priceRaw, ts: tsNum };
if (!firstLogged) {
console.log(`natsPrice: first price ${priceRaw} (ts=${tsNum})`);
firstLogged = true;
}
notify(tick);
},
});
// keep reference so it isn't GC'd
void sub;
}
/** Текущая последняя цена (или null, если ещё не приходила). */
export function currentPrice(): PriceTick | null {
return last;
}
/** Регистрирует слушателя на КАЖДОЕ новое значение. */
export function onPrice(cb: (p: PriceTick) => void): void {
listeners.push(cb);
}
/**
* Ждёт первую цену из NATS и возвращает её в ЦЕЛЫХ ЕДИНИЦАХ
* (price × 1e8, через строковое преобразование — без потери точности).
* При таймауте — throw.
*/
export async function waitForPrice(timeoutMs: number): Promise<number> {
if (last !== null) {
return priceStringToInt(last.price);
}
return new Promise<number>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`price timeout: no data from NATS after ${timeoutMs}ms`));
}, timeoutMs);
onPrice((p) => {
clearTimeout(timer);
resolve(priceStringToInt(p.price));
});
});
}
/**
* Чистая конверсия: сырая строка из NATS → целое (price × 1e8).
* "101.89000000" → 10189000000
* "0.00000001" → 1
* "1234567.12345678" → 123456712345678
* "100" → 10000000000
* Без float — только string/BigInt.
*/
export function priceStringToInt(raw: string): number {
if (typeof raw !== "string" || raw.length === 0) {
throw new Error(`priceStringToInt: empty/invalid input`);
}
if (!/^[0-9]+(?:\.[0-9]+)?$/.test(raw)) {
throw new Error(`priceStringToInt: malformed price string: ${raw}`);
}
const dot = raw.indexOf(".");
let intPart: string;
let fracPart: string;
if (dot === -1) {
intPart = raw;
fracPart = "";
} else {
intPart = raw.slice(0, dot);
fracPart = raw.slice(dot + 1);
}
if (fracPart.length > 8) {
fracPart = fracPart.slice(0, 8);
} else if (fracPart.length < 8) {
fracPart = fracPart.padEnd(8, "0");
}
const combined = `${intPart}${fracPart}`;
// strip leading zeros
const stripped = combined.replace(/^0+(?=\d)/, "");
return Number(stripped);
}
+34 -13
View File
@@ -1,22 +1,43 @@
/**
* STUB — SOL price source.
*
* TODO(пользователь): замени `getPrice` на реальный запрос к своей БД.
* Договоримся, что функция возвращает цену SOL как целое число в «центах»
* (то же юнит-пространство, что у веса entry/exit на контракте). Контракт
* сравнивает тол ько «больше/меньше/равно», поэтому само юнит-пространство
* не критично — главное, чтобы оно было согласовано между entry и exit.
* Цена SOL. Источник — NATS (`relayer/src/natsPrice.ts`). `getPrice()`
* возвращает цену как ЦЕЛОЕ (price × 1e8, без float — точность нужна для
* сравнения на контракте). Override из старой заглушки сохранён для
* тестов «equal price» / offline-сценариев.
*/
let simulated = 100_000_000;
import { currentPrice, priceStringToInt, waitForPrice } from "./natsPrice";
/** Цена SOL сейчас (заглушка — детерминированно «гуляет»). */
const PRICE_WAIT_TIMEOUT_MS = Number(process.env.PRICE_WAIT_TIMEOUT_MS ?? "15000");
let override: number | undefined = undefined;
/**
* Вернуть целое число = price × 1e8. Конверсия — через строковый парсер
* (`priceStringToInt`), БЕЗ `Number(price) * 1e8` (float теряет точность
* на 8 знаках).
*
* Override выставлен → возвращаем его и НЕ ходим в NATS.
* NATS ещё не прислал цену → ждём первую до PRICE_WAIT_TIMEOUT_MS.
*/
export async function getPrice(): Promise<number> {
const drift = Math.round(Math.random() * 200_000); // ±0.2% движка
simulated += Math.random() < 0.5 ? drift : -drift;
return simulated;
if (override !== undefined) return override;
// waitForPrice сам разруливает "уже есть" vs "ещё ждём".
return waitForPrice(PRICE_WAIT_TIMEOUT_MS);
}
/** Управляемая заглушка: вернуть ровно `value` (полезна для теста «equal price»). */
export function setPriceOverride(value: number | undefined): void {
simulated = value ?? 100_000_000;
override = value;
}
/**
* Последняя СЫРАЯ строка цены из NATS (например, `"101.89000000"`) —
* для UI. `null`, если цены ещё не было. При выставленном override
* возвращает его строковую форму с 8 знаками (UI-привычно).
*/
export function currentPriceHuman(): string | null {
if (override !== undefined) {
return override.toString().padStart(9, "0"); // минимум 1 знак слева
}
const p = currentPrice();
return p ? p.price : null;
}