From 92cf2f61b457affe82829b5d3d9adbc3d4fd0d2d Mon Sep 17 00:00:00 2001 From: Caffeine Date: Sun, 13 Sep 2026 05:53:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(relayer):=20=D1=86=D0=B5=D0=BD=D0=B0=20SOL?= =?UTF-8?q?=20=D0=B8=D0=B7=20NATS=20+=20WS-=D1=80=D0=B0=D0=B7=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=20+=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9-=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit priceSource больше не рандомит: цена берётся из NATS (market.price.solusdt), конверсия строка->целое x1e8 без float. Шлюз раздаёт цену по WS (/ws, нормализация /api/... -> /...) — фронт просто выводит её. Добавлены Dockerfile и helm-чарт для деплоя в k3s. --- relayer/.dockerignore | 5 + relayer/Dockerfile | 17 +++ relayer/helm/Chart.yaml | 5 + relayer/helm/templates/_helpers.tpl | 41 ++++++ relayer/helm/templates/deployment.yaml | 111 +++++++++++++++++ relayer/helm/templates/ingress.yaml | 20 +++ relayer/helm/templates/pvc.yaml | 17 +++ relayer/helm/templates/service.yaml | 15 +++ relayer/helm/values.yaml | 40 ++++++ relayer/package-lock.json | 112 +++++++++++------ relayer/package.json | 5 +- relayer/src/gateway.ts | 162 +++++++++++++++++++++++- relayer/src/natsPrice.ts | 166 +++++++++++++++++++++++++ relayer/src/priceSource.ts | 47 +++++-- 14 files changed, 705 insertions(+), 58 deletions(-) create mode 100644 relayer/.dockerignore create mode 100644 relayer/Dockerfile create mode 100644 relayer/helm/Chart.yaml create mode 100644 relayer/helm/templates/_helpers.tpl create mode 100644 relayer/helm/templates/deployment.yaml create mode 100644 relayer/helm/templates/ingress.yaml create mode 100644 relayer/helm/templates/pvc.yaml create mode 100644 relayer/helm/templates/service.yaml create mode 100644 relayer/helm/values.yaml create mode 100644 relayer/src/natsPrice.ts diff --git a/relayer/.dockerignore b/relayer/.dockerignore new file mode 100644 index 0000000..b7b173a --- /dev/null +++ b/relayer/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +*.log +.gateway-bettors.json +*.md diff --git a/relayer/Dockerfile b/relayer/Dockerfile new file mode 100644 index 0000000..27caaaf --- /dev/null +++ b/relayer/Dockerfile @@ -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"] diff --git a/relayer/helm/Chart.yaml b/relayer/helm/Chart.yaml new file mode 100644 index 0000000..bc5af5d --- /dev/null +++ b/relayer/helm/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: smart-updown-relayer +type: application +version: "9.9.9" +appVersion: "9.9.9" diff --git a/relayer/helm/templates/_helpers.tpl b/relayer/helm/templates/_helpers.tpl new file mode 100644 index 0000000..57a12e6 --- /dev/null +++ b/relayer/helm/templates/_helpers.tpl @@ -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 }} diff --git a/relayer/helm/templates/deployment.yaml b/relayer/helm/templates/deployment.yaml new file mode 100644 index 0000000..6505a05 --- /dev/null +++ b/relayer/helm/templates/deployment.yaml @@ -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 diff --git a/relayer/helm/templates/ingress.yaml b/relayer/helm/templates/ingress.yaml new file mode 100644 index 0000000..a8e20fa --- /dev/null +++ b/relayer/helm/templates/ingress.yaml @@ -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 }} diff --git a/relayer/helm/templates/pvc.yaml b/relayer/helm/templates/pvc.yaml new file mode 100644 index 0000000..9881186 --- /dev/null +++ b/relayer/helm/templates/pvc.yaml @@ -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 }} diff --git a/relayer/helm/templates/service.yaml b/relayer/helm/templates/service.yaml new file mode 100644 index 0000000..32d354b --- /dev/null +++ b/relayer/helm/templates/service.yaml @@ -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 }} diff --git a/relayer/helm/values.yaml b/relayer/helm/values.yaml new file mode 100644 index 0000000..4d7981e --- /dev/null +++ b/relayer/helm/values.yaml @@ -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: {} diff --git a/relayer/package-lock.json b/relayer/package-lock.json index cba366f..c0e407c 100644 --- a/relayer/package-lock.json +++ b/relayer/package-lock.json @@ -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": { diff --git a/relayer/package.json b/relayer/package.json index 03f0963..9bf0bc3 100644 --- a/relayer/package.json +++ b/relayer/package.json @@ -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" } diff --git a/relayer/src/gateway.ts b/relayer/src/gateway.ts index ad9ec0d..e330496 100644 --- a/relayer/src/gateway.ts +++ b/relayer/src/gateway.ts @@ -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 { }; } +async function handlePrice(): Promise { + 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): Promise { const side = normalizeSide(body.side); if (side === null) { @@ -404,6 +447,28 @@ function readJsonBody(req: http.IncomingMessage): Promise terminate). */ +interface WsClient { + ws: WebSocket; + alive: boolean; +} + +function broadcastPrice(wsClients: Set): (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 { const cfg = loadConfig(); const admin = loadKeypair(cfg.adminKeypairPath); @@ -437,6 +502,14 @@ async function main(): Promise { 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 { 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 { 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 { }); }); + // WS-сервер на том же порту (noServer=true), обновляем апгрейды вручную. + const wss = new WebSocketServer({ noServer: true }); + const wsClients = new Set(); + + 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); -}); +}); \ No newline at end of file diff --git a/relayer/src/natsPrice.ts b/relayer/src/natsPrice.ts new file mode 100644 index 0000000..b72fdf2 --- /dev/null +++ b/relayer/src/natsPrice.ts @@ -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 { + 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; + 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 { + if (last !== null) { + return priceStringToInt(last.price); + } + return new Promise((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); +} \ No newline at end of file diff --git a/relayer/src/priceSource.ts b/relayer/src/priceSource.ts index 3a5b007..9a73366 100644 --- a/relayer/src/priceSource.ts +++ b/relayer/src/priceSource.ts @@ -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 { - 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; +} \ No newline at end of file