feat(relayer): цена SOL из NATS + WS-раздача + деплой-артефакты
deploy-contract / deploy-contract (release) Failing after 3m34s
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:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.gateway-bettors.json
|
||||
*.md
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: smart-updown-relayer
|
||||
type: application
|
||||
version: "9.9.9"
|
||||
appVersion: "9.9.9"
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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: {}
|
||||
Generated
+73
-39
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user