Compare commits
2 Commits
main
...
517df542e1
| Author | SHA1 | Date | |
|---|---|---|---|
| 517df542e1 | |||
| 17855ecd87 |
@@ -1,12 +1,12 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.git
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/*.log
|
||||
**/logs
|
||||
**/.DS_Store
|
||||
**/.vscode
|
||||
**/.idea
|
||||
pastdeploy/
|
||||
.seed-backup/
|
||||
node_modules/
|
||||
dist/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.turbo/
|
||||
.git/
|
||||
.gitea/
|
||||
coverage/
|
||||
.DS_Store
|
||||
docs/
|
||||
|
||||
102
.env.example
102
.env.example
@@ -1,4 +1,20 @@
|
||||
# ── Vault (AppRole) ────────────────────────────────────────────────
|
||||
# PostgreSQL
|
||||
# Для локального dev: DB_HOST=localhost
|
||||
# Для Docker Compose: DB_HOST переопределяется на 'postgres' в docker-compose.yml
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=cryptowallet_v2
|
||||
|
||||
# Database Pool
|
||||
DATABASE_POOL_SIZE=10
|
||||
DATABASE_MAX_OVERFLOW=20
|
||||
DATABASE_POOL_TIMEOUT=30
|
||||
DATABASE_POOL_RECYCLE=3600
|
||||
DATABASE_ECHO=false
|
||||
|
||||
# Vault (AppRole auth)
|
||||
VAULT_ADDR=
|
||||
VAULT_ROLE_ID=
|
||||
VAULT_SECRET_ID=
|
||||
@@ -6,61 +22,57 @@ VAULT_MOUNT_POINT=dev-secrets
|
||||
VAULT_SECRET_PATH=database
|
||||
VAULT_JWT_KID_PATH=jwt/kid
|
||||
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
||||
VAULT_CSRF_SECRET_PATH=cryptowallet/csrf
|
||||
|
||||
# CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF)
|
||||
VAULT_CSRF_PATH=csrf
|
||||
# CSRF (min 32 chars if not using Vault CSRF path)
|
||||
CSRF_SECRET_KEY=change-me-to-at-least-32-chars-long!!
|
||||
CSRF_COOKIE_SECURE=false
|
||||
CSRF_COOKIE_HTTPONLY=true
|
||||
CSRF_COOKIE_SAMESITE=Lax
|
||||
CSRF_COOKIE_PATH=/
|
||||
CSRF_COOKIE_DOMAIN=
|
||||
|
||||
# Crypto master-key для шифрования мнемоник юзеров (AES-256-GCM).
|
||||
# В Vault лежит hex-строка длиной 64 (32 байта).
|
||||
# Положить: vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32)
|
||||
VAULT_CRYPTO_KEY_PATH=crypto/master
|
||||
|
||||
# ── JWT (внешний bitok issuer) ─────────────────────────────────────
|
||||
# bitok-сервис подписывает JWT своим приватником, public key регистрируется
|
||||
# в Vault под kid'ом (см. VAULT_JWT_KIDS_PREFIX).
|
||||
# Allowed alg: RS256 / RS384 / RS512 / ES256 / ES384 / ES512 / EdDSA / PS256 / PS384 / PS512
|
||||
# JWT
|
||||
JWT_ALGORITHM=RS256
|
||||
JWT_ISSUER=bitok
|
||||
JWT_AUDIENCE=elcsa
|
||||
JWT_ACCESS_TTL_SECONDS=900
|
||||
JWT_REFRESH_TTL_SECONDS=2592000
|
||||
JWT_ISSUER=auth-service
|
||||
JWT_AUDIENCE=wallet-service
|
||||
|
||||
# ── Server ─────────────────────────────────────────────────────────
|
||||
API_PORT=3001
|
||||
LOG_LEVEL=INFO
|
||||
# Docs
|
||||
DOCS_USERNAME=admin
|
||||
DOCS_PASSWORD=admin
|
||||
|
||||
# ── KeyDB / Redis (idempotency cache) ──────────────────────────────
|
||||
# REDIS_PASSWORD also used by docker-compose to seed KeyDB --requirepass.
|
||||
# Redis
|
||||
REDIS_HOST=keydb
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_PASSWORD=keydb
|
||||
REDIS_DB=0
|
||||
|
||||
# ── CORS ────────────────────────────────────────────────────────────
|
||||
# Comma-separated list of allowed origins. ПУСТО = no cross-origin.
|
||||
# Никогда не используй wildcard *
|
||||
CORS_ORIGINS=
|
||||
# RabbitMQ
|
||||
RABBIT_EMAIL_CODE_QUEUE=email.verification_code
|
||||
RABBIT_PUBLISH_PERSIST=true
|
||||
RABBIT_CONNECT_TIMEOUT=5
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FORMAT=JSON
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8000
|
||||
CORS_ALLOW_CREDENTIALS=true
|
||||
|
||||
# ── External API keys (optional, fallback если Vault их не выдаёт) ─
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_REQUESTS=60
|
||||
RATE_LIMIT_WINDOW=60
|
||||
|
||||
# Server
|
||||
API_PORT=3001
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
RELAY_API_KEY=
|
||||
|
||||
# TRON
|
||||
TRON_API_KEY=
|
||||
|
||||
# Jupiter (Solana DEX aggregator)
|
||||
JUPITER_API_KEY=
|
||||
JUPITER_REFERRAL_ACCOUNT=
|
||||
JUPITER_FEE_BPS=70
|
||||
|
||||
# ── Block explorers (optional, для tx history) ─────────────────────
|
||||
ETHERSCAN_API_KEY=
|
||||
BSCSCAN_API_KEY=
|
||||
|
||||
# ── Price oracle (optional) ─────────────────────────────────────────
|
||||
# CoinGecko Demo API key. Без ключа работает free tier (~10-30 req/min).
|
||||
# Если задан → передаётся через header `x-cg-demo-api-key`.
|
||||
# Используется в /api/wallets/{chain}/balance (для usdPrice/usdValue)
|
||||
# и /api/prices?symbols=... KeyDB cache: 5 минут.
|
||||
COINGECKO_API_KEY=
|
||||
|
||||
# ── DB fallback (если Vault недоступен при старте) ─────────────────
|
||||
DB_HOST=
|
||||
DB_PORT=5432
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_NAME=
|
||||
|
||||
46
.gitignore
vendored
46
.gitignore
vendored
@@ -1,14 +1,36 @@
|
||||
.env
|
||||
.env.local
|
||||
# Никогда не коммитить артефакты установки/сборки — только Docker build на сервере
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
dist/
|
||||
**/dist/
|
||||
.turbo/
|
||||
**/.turbo/
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.pnpm-debug.log*
|
||||
|
||||
.turbo
|
||||
|
||||
dist
|
||||
build
|
||||
out
|
||||
.next
|
||||
.nuxt
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
*.log
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
coverage
|
||||
*.lcov
|
||||
.nyc_output
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
Thumbs.db
|
||||
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
82
Dockerfile
82
Dockerfile
@@ -1,52 +1,82 @@
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Production Dockerfile — multi-stage build для shippable image.
|
||||
# Финальный image: только compiled dist + prod deps + tini, runs as uid 1001.
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Production Dockerfile for CryptoWallet API
|
||||
# Multi-stage: deps → build → prod-deps → runtime
|
||||
# Runtime: non-root user, tini (signal handling), wget (healthcheck)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate \
|
||||
&& apk add --no-cache python3 make g++
|
||||
ARG NODE_VERSION=20.19-alpine
|
||||
ARG PNPM_VERSION=10.28.2
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Base: node + pnpm
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM node:${NODE_VERSION} AS base
|
||||
ARG PNPM_VERSION
|
||||
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
CI=true \
|
||||
HUSKY=0
|
||||
|
||||
# ── Stage 1: install ALL deps (incl. devDeps) для build ──
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# deps: install ALL deps (incl. dev) for TypeScript build
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# ── Stage 2: TypeScript compile ──
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build: compile TypeScript → dist/
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM base AS build
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
|
||||
COPY . .
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api ./apps/api
|
||||
RUN cd apps/api && pnpm build
|
||||
|
||||
# ── Stage 3: prod-only deps (без devDeps, меньше image) ──
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# prod-deps: only production dependencies (smaller image)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM base AS prod-deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
RUN pnpm install --frozen-lockfile --prod \
|
||||
&& BIGINT_DIR="$(find /app -path '*/node_modules/bigint-buffer' -type d 2>/dev/null | head -1)" \
|
||||
&& if [ -n "$BIGINT_DIR" ]; then (cd "$BIGINT_DIR" && npm run rebuild); fi
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --prod
|
||||
|
||||
# ── Stage 4: runtime image — minimal surface ──
|
||||
FROM node:20-alpine AS runtime
|
||||
RUN apk add --no-cache tini wget \
|
||||
&& addgroup -S app -g 1001 \
|
||||
&& adduser -S app -G app -u 1001
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# runtime: minimal production image
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM node:${NODE_VERSION} AS runtime
|
||||
|
||||
# tini for proper PID 1 / signal handling
|
||||
# wget for healthcheck
|
||||
RUN apk add --no-cache tini wget && \
|
||||
addgroup -S -g 1001 app && \
|
||||
adduser -S -u 1001 -G app app
|
||||
|
||||
WORKDIR /app/apps/api
|
||||
|
||||
COPY --from=prod-deps --chown=app:app /app/node_modules /app/node_modules
|
||||
COPY --from=prod-deps --chown=app:app /app/apps/api/node_modules ./node_modules
|
||||
COPY --from=build --chown=app:app /app/apps/api/dist ./dist
|
||||
COPY --from=build --chown=app:app /app/apps/api/swagger.json ./swagger.json
|
||||
COPY --from=build --chown=app:app /app/apps/api/package.json ./package.json
|
||||
# Copy production dependencies
|
||||
COPY --from=prod-deps --chown=app:app /app/node_modules /app/node_modules
|
||||
COPY --from=prod-deps --chown=app:app /app/apps/api/node_modules ./node_modules
|
||||
|
||||
# Copy compiled code + static assets
|
||||
COPY --from=build --chown=app:app /app/apps/api/dist ./dist
|
||||
COPY --from=build --chown=app:app /app/apps/api/swagger.json ./swagger.json
|
||||
COPY --from=build --chown=app:app /app/apps/api/package.json ./package.json
|
||||
|
||||
USER app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
API_PORT=3001
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3001/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
|
||||
130
README.md
Normal file
130
README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# CryptoWallet API — Production Deploy Bundle
|
||||
|
||||
Самодостаточная папка для деплоя на Linux-сервер. Содержит всё нужное для сборки и запуска продакшн-версии API.
|
||||
|
||||
## Состав
|
||||
|
||||
```
|
||||
deployserver/
|
||||
├── Dockerfile # Multi-stage production build
|
||||
├── docker-compose.yml # PostgreSQL + API
|
||||
├── .env.example # Шаблон переменных окружения
|
||||
├── .dockerignore
|
||||
├── start.sh # Автоматический deploy скрипт
|
||||
├── apps/api/ # Исходник API
|
||||
│ ├── src/
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── swagger.json
|
||||
│ └── .eslintrc.json
|
||||
├── package.json # Монорепо root
|
||||
├── pnpm-workspace.yaml
|
||||
└── pnpm-lock.yaml
|
||||
```
|
||||
|
||||
## Требования
|
||||
|
||||
- Ubuntu 20.04+ / Debian 11+ / любой Linux с Docker 24+
|
||||
- Docker Compose plugin (`docker compose` команда)
|
||||
- Исходящий HTTPS на Vault (`corp.vault.elcsa.ru:443`)
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
# 1. Скопировать папку на сервер (или git clone и cd deployserver)
|
||||
scp -r deployserver user@server:/opt/cryptowallet
|
||||
ssh user@server
|
||||
cd /opt/cryptowallet
|
||||
|
||||
# 2. Установить Docker (если нет)
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
|
||||
# 3. Настроить .env
|
||||
cp .env.example .env
|
||||
nano .env # заполнить VAULT_ROLE_ID, VAULT_SECRET_ID
|
||||
|
||||
# 4. Запустить
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
|
||||
# 5. Открыть порт наружу
|
||||
sudo ufw allow 22/tcp
|
||||
sudo ufw allow 3001/tcp
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/health
|
||||
# → {"success":true,"data":{"status":"ok"}}
|
||||
|
||||
curl http://<server-ip>:3001/api/health # извне
|
||||
```
|
||||
|
||||
Swagger UI: `http://<server-ip>:3001/api/docs`
|
||||
|
||||
## Порты
|
||||
|
||||
| Порт | Назначение | Открыть наружу? |
|
||||
|------|-----------|-----------------|
|
||||
| 3001 | API HTTP | ✅ да (`ufw allow 3001`) |
|
||||
| 5432 | PostgreSQL | ❌ нет (только docker network) |
|
||||
| 443 (out) | Vault | исходящий, обычно открыт |
|
||||
|
||||
## Управление
|
||||
|
||||
```bash
|
||||
docker compose logs -f api # смотреть логи
|
||||
docker compose restart api # рестарт
|
||||
docker compose down # остановить
|
||||
docker compose down -v # + удалить БД (ОСТОРОЖНО)
|
||||
docker compose ps # статус
|
||||
docker compose exec postgres psql -U postgres cryptowallet_v2 # подключиться к БД
|
||||
```
|
||||
|
||||
## Обновление
|
||||
|
||||
```bash
|
||||
# Скопировать новую версию deployserver/
|
||||
docker compose build --pull api
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Миграции применятся автоматически при старте API.
|
||||
|
||||
## Безопасность Dockerfile
|
||||
|
||||
- **Non-root user** (uid 1001) — контейнер не работает от root
|
||||
- **tini** как PID 1 — корректная обработка `SIGTERM` / `SIGKILL`
|
||||
- **Multi-stage build** — в финальный образ попадают только production deps + компилированный dist
|
||||
- **Alpine base** — минимальный образ (~150 MB)
|
||||
- **Healthcheck** — Docker рестартит контейнер если API упал
|
||||
- **Log rotation** — max 5×20MB логов, не забьёт диск
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`Vault AppRole login failed`**
|
||||
- Проверь VAULT_ROLE_ID / VAULT_SECRET_ID в .env
|
||||
- Проверь доступ: `curl -v https://corp.vault.elcsa.ru/v1/sys/health`
|
||||
|
||||
**API рестартуется в цикле**
|
||||
- `docker compose logs api` — смотри ошибку
|
||||
- Скорее всего БД не поднялась: `docker compose logs postgres`
|
||||
|
||||
**Port 3001 занят**
|
||||
- `sudo lsof -i :3001`
|
||||
- Измени порт: в `docker-compose.yml` `"3002:3001"` и в `ufw allow 3002`
|
||||
|
||||
**Нет места на диске**
|
||||
- `docker system prune -a` — удалит старые образы
|
||||
- `docker compose logs --tail=0 --no-log-prefix > /dev/null` — логи ротейтятся автоматически
|
||||
|
||||
## Автозапуск при reboot
|
||||
|
||||
Restart policy `unless-stopped` уже настроен. Убедись что Docker стартует:
|
||||
```bash
|
||||
sudo systemctl enable docker
|
||||
```
|
||||
@@ -1,37 +0,0 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# ── deps: install all node_modules ───────────────────────────────────────────
|
||||
FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# ── build: compile TypeScript ────────────────────────────────────────────────
|
||||
FROM base AS build
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
|
||||
COPY . .
|
||||
RUN cd apps/api && pnpm build
|
||||
|
||||
# ── prod-deps: production-only dependencies ─────────────────────────────────
|
||||
FROM base AS prod-deps
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
RUN pnpm install --frozen-lockfile --prod \
|
||||
&& BIGINT_DIR="$(find /app -path '*/node_modules/bigint-buffer' -type d 2>/dev/null | head -1)" \
|
||||
&& if [ -n "$BIGINT_DIR" ]; then (cd "$BIGINT_DIR" && npm run rebuild); fi
|
||||
|
||||
# ── runtime: minimal image ───────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS runtime
|
||||
WORKDIR /app/apps/api
|
||||
COPY --from=prod-deps /app/node_modules /app/node_modules
|
||||
COPY --from=prod-deps /app/apps/api/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/api/dist ./dist
|
||||
COPY --from=build /app/apps/api/swagger.json ./swagger.json
|
||||
COPY --from=build /app/apps/api/package.json ./package.json
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/index.js"]
|
||||
4595
apps/api/package-lock.json
generated
Normal file
4595
apps/api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,32 +6,24 @@
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"migrate": "node --require ts-node/register node_modules/knex/bin/cli.js migrate:latest --knexfile src/db/knexfile.ts",
|
||||
"migrate:rollback": "node --require ts-node/register node_modules/knex/bin/cli.js migrate:rollback --knexfile src/db/knexfile.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/ --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solana/spl-token": "^0.4.14",
|
||||
"@solana/web3.js": "^1.98.4",
|
||||
"bip32": "^4.0.0",
|
||||
"bip39": "^3.1.0",
|
||||
"bitcoinjs-lib": "^6.1.5",
|
||||
"bs58": "^6.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.0",
|
||||
"ed25519-hd-key": "^1.3.0",
|
||||
"ethers": "5.7.2",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"jose": "^6.2.2",
|
||||
"knex": "^3.1.0",
|
||||
"pg": "^8.13.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tiny-secp256k1": "^2.2.3",
|
||||
"ulidx": "^2.4.1",
|
||||
"undici": "^6.21.0"
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
|
||||
@@ -6,128 +6,46 @@ import swaggerUi from 'swagger-ui-express';
|
||||
import { env } from './config/env';
|
||||
import { swaggerSpec } from './config/swagger';
|
||||
import { traceMiddleware } from './middleware/trace';
|
||||
import { csrfProtect } from './middleware/csrf';
|
||||
import { authMiddleware } from './middleware/auth';
|
||||
import { csrfMiddleware } from './middleware/csrf';
|
||||
import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit';
|
||||
import { errorHandler } from './middleware/error-handler';
|
||||
import csrfRoutes from './routes/csrf.routes';
|
||||
import walletRoutes from './routes/wallet.routes';
|
||||
import relayProxyRoutes from './routes/relay-proxy.routes';
|
||||
import jumperProxyRoutes from './routes/jumper-proxy.routes';
|
||||
import tronProxyRoutes from './routes/tron-proxy.routes';
|
||||
import solSwapProxyRoutes from './routes/sol-swap-proxy.routes';
|
||||
import tronSwapProxyRoutes from './routes/tron-swap-proxy.routes';
|
||||
import btcProxyRoutes from './routes/btc-proxy.routes';
|
||||
import pricesRoutes from './routes/prices.routes';
|
||||
import tokensRoutes from './routes/tokens.routes';
|
||||
import bridgeRoutes from './routes/bridge.routes';
|
||||
import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Trust proxy для корректного req.ip за reverse proxy / load balancer
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use(helmet());
|
||||
|
||||
// CORS — поддерживаем 3 режима:
|
||||
// 1. wildcard ['*'] — любой origin (для dev/staging); credentials force=false (browser spec)
|
||||
// 2. whitelist [a, b, c] — только эти origins
|
||||
// 3. пустой массив — все cross-origin blocked (fail-secure default)
|
||||
const corsOrigins = env.cors.origins;
|
||||
const corsIsWildcard = corsOrigins.length === 1 && corsOrigins[0] === '*';
|
||||
if (corsIsWildcard) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[CORS] WILDCARD enabled (CORS_ORIGINS=*) — any origin can call API. Use only for dev/staging. Production: use explicit whitelist.');
|
||||
}
|
||||
app.use(
|
||||
cors({
|
||||
origin: corsIsWildcard ? '*' : (corsOrigins.length > 0 ? corsOrigins : false),
|
||||
// Wildcard incompatible с credentials per browser spec — force false при wildcard.
|
||||
credentials: corsIsWildcard ? false : env.cors.allowCredentials,
|
||||
exposedHeaders: ['X-CSRF-Token', 'X-Trace-ID'],
|
||||
}),
|
||||
);
|
||||
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS
|
||||
app.use(cors({ origin: env.frontendUrl, credentials: true }));
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(traceMiddleware);
|
||||
|
||||
// ── PUBLIC endpoints ─────────────────────────────────────────────────────────
|
||||
// H11 — /api/health with DB probe (не возвращает OK если DB down)
|
||||
import { db } from './config/database';
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
try {
|
||||
await Promise.race([
|
||||
db.raw('select 1'),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('db-timeout')), 1000)),
|
||||
]);
|
||||
res.json({ success: true, data: { status: 'ok' } });
|
||||
} catch (err: any) {
|
||||
res.status(503).json({ success: false, error: 'db_unavailable' });
|
||||
}
|
||||
// ── PUBLIC endpoints (no auth) ────────────────────────────────────────────────
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ success: true, data: { status: 'ok' } });
|
||||
});
|
||||
|
||||
// ── Глобальный rate limit на /api/* — ДО docs чтобы не было unauthenticated DoS на swagger.json
|
||||
app.use('/api', globalLimiter);
|
||||
|
||||
// H1 — Swagger gated. В production требуется basic-auth ИЛИ NODE_ENV != production.
|
||||
// JSON endpoint ОБЯЗАТЕЛЬНО до app.use('/api/docs', ...) — иначе swagger-ui-express
|
||||
// перехватывает все /api/docs/* и возвращает HTML вместо JSON.
|
||||
const docsGate = (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
if (process.env.NODE_ENV !== 'production' || process.env.SWAGGER_PUBLIC === 'true') {
|
||||
return next();
|
||||
}
|
||||
// Production без SWAGGER_PUBLIC=true → require basic auth (operator credentials)
|
||||
const auth = req.headers.authorization || '';
|
||||
const expected = process.env.SWAGGER_BASIC_AUTH; // "user:pass"
|
||||
if (!expected || !auth.startsWith('Basic ')) {
|
||||
res.set('WWW-Authenticate', 'Basic realm="docs"');
|
||||
res.status(401).json({ success: false, error: 'Docs auth required' });
|
||||
return;
|
||||
}
|
||||
const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
|
||||
if (decoded !== expected) {
|
||||
res.set('WWW-Authenticate', 'Basic realm="docs"');
|
||||
res.status(401).json({ success: false, error: 'Invalid docs credentials' });
|
||||
return;
|
||||
}
|
||||
return next();
|
||||
};
|
||||
app.get('/api/docs/swagger.json', docsGate, (_req, res) => {
|
||||
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
app.get('/api/docs/swagger.json', (_req, res) => {
|
||||
res.json(swaggerSpec);
|
||||
});
|
||||
app.use('/api/docs', docsGate, swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
|
||||
// ── PROTECTED endpoints (JWT + CSRF) ─────────────────────────────────────────
|
||||
const protect = [authMiddleware, csrfMiddleware];
|
||||
app.use('/api/csrf', csrfRoutes);
|
||||
|
||||
// Sensitive — самый строгий лимит. Каждый POST защищён JWT + CSRF.
|
||||
app.use('/api/wallets/create', ...protect, sensitiveLimiter);
|
||||
app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter);
|
||||
app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter);
|
||||
|
||||
// Mutating (proxy + read endpoints) — повышенный лимит
|
||||
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
|
||||
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes);
|
||||
// Jumper.xyz — LiFi-backed bridge aggregator (50+ chains: ETH/BSC/SOL/TRX/BTC + others).
|
||||
// Используется когда Relay не поддерживает направление (TRX/BTC bridges).
|
||||
app.use('/api/jumper', ...protect, mutateLimiter, jumperProxyRoutes);
|
||||
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes);
|
||||
app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes);
|
||||
// Legacy non-custodial swap proxies (/api/bsc/swap, /api/sol/swap, /api/tron/swap)
|
||||
// УДАЛЕНЫ. Custodial 2-step swap живёт под /api/wallets/{chain}/swap{,/quote}.
|
||||
|
||||
// USD-цены (CoinGecko + KeyDB cache). GET-only, auth required, max 50 symbols.
|
||||
app.use('/api/prices', ...protect, mutateLimiter, pricesRoutes);
|
||||
|
||||
// Token registry — всех известных contracts/mints по всем chain'ам. GET-only, auth required.
|
||||
app.use('/api/tokens', ...protect, mutateLimiter, tokensRoutes);
|
||||
|
||||
// Bridge execute — one-click "Подтвердить" для bridge через Jumper (LiFi) / Relay.
|
||||
// Dispatcher по source chain: EVM (approve+fee+bridge) / SOL (versioned tx) / TRX (TRC20 approve+bridge) / BTC (PSBT deposit).
|
||||
// Sign + broadcast custodial через server (mnemonic не покидает API).
|
||||
app.use('/api/bridge', ...protect, mutateLimiter, bridgeRoutes);
|
||||
|
||||
// 404 для всего что не сматчилось выше — единый JSON-ответ, не express default text
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({ success: false, error: 'Not found' });
|
||||
});
|
||||
// ── PROTECTED endpoints (JWT required) ────────────────────────────────────────
|
||||
app.use('/api/wallets', csrfProtect, authMiddleware, walletRoutes);
|
||||
app.use('/api/relay', csrfProtect, authMiddleware, relayProxyRoutes);
|
||||
app.use('/api/tron', csrfProtect, authMiddleware, tronProxyRoutes);
|
||||
app.use('/api/sol/swap', csrfProtect, authMiddleware, solSwapProxyRoutes);
|
||||
app.use('/api/tron/swap', csrfProtect, authMiddleware, tronSwapProxyRoutes);
|
||||
app.use('/api/btc', csrfProtect, authMiddleware, btcProxyRoutes);
|
||||
app.use('/api/bsc/swap', csrfProtect, authMiddleware, bscSwapProxyRoutes);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
|
||||
@@ -9,18 +9,23 @@ const p = process.env;
|
||||
|
||||
export let env = {
|
||||
db: {
|
||||
host: p.DB_HOST || '',
|
||||
host: p.DB_HOST || 'localhost',
|
||||
port: parseInt(p.DB_PORT || '5432'),
|
||||
user: p.DB_USER || '',
|
||||
password: p.DB_PASSWORD || '',
|
||||
name: p.DB_NAME || '',
|
||||
user: p.DB_USER || 'postgres',
|
||||
password: p.DB_PASSWORD || 'postgres',
|
||||
name: p.DB_NAME || 'cryptowallet_v2',
|
||||
poolSize: parseInt(p.DATABASE_POOL_SIZE || '10'),
|
||||
maxOverflow: parseInt(p.DATABASE_MAX_OVERFLOW || '20'),
|
||||
poolTimeout: parseInt(p.DATABASE_POOL_TIMEOUT || '30'),
|
||||
poolRecycle: parseInt(p.DATABASE_POOL_RECYCLE || '3600'),
|
||||
echo: p.DATABASE_ECHO === 'true',
|
||||
},
|
||||
jwt: {
|
||||
algorithm: p.JWT_ALGORITHM || 'RS256',
|
||||
// Намеренно без default — каждый деплой ЯВНО указывает iss/aud, иначе сервис
|
||||
// примет любой токен подписанный нашими ключами с любым iss/aud.
|
||||
issuer: p.JWT_ISSUER || '',
|
||||
audience: p.JWT_AUDIENCE || '',
|
||||
issuer: p.JWT_ISSUER || 'auth-service',
|
||||
audience: p.JWT_AUDIENCE || 'bitforce',
|
||||
accessTtl: parseInt(p.JWT_ACCESS_TTL_SECONDS || '900'),
|
||||
refreshTtl: parseInt(p.JWT_REFRESH_TTL_SECONDS || '2592000'),
|
||||
},
|
||||
vault: {
|
||||
addr: p.VAULT_ADDR || '',
|
||||
@@ -30,47 +35,49 @@ export let env = {
|
||||
secretPath: p.VAULT_SECRET_PATH || 'database',
|
||||
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
|
||||
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
|
||||
csrfPath: p.VAULT_CSRF_PATH || '',
|
||||
cryptoKeyPath: p.VAULT_CRYPTO_KEY_PATH || 'crypto/master',
|
||||
csrfSecretPath: p.VAULT_CSRF_SECRET_PATH || 'cryptowallet/csrf',
|
||||
},
|
||||
cors: {
|
||||
// CORS_ORIGINS:
|
||||
// - comma-separated list of origins → whitelist (recommended for prod)
|
||||
// - "*" → wildcard, любой origin принят (для dev/staging)
|
||||
// - "" → cross-origin blocked (fail-secure default)
|
||||
// Wildcard incompatible с CORS_ALLOW_CREDENTIALS=true (browser spec).
|
||||
origins: (() => {
|
||||
const raw = (p.CORS_ORIGINS || '').split(',').map((o) => o.trim()).filter(Boolean);
|
||||
// Wildcard sentinel — единственное значение `*` активирует wildcard mode.
|
||||
if (raw.length === 1 && raw[0] === '*') return ['*'];
|
||||
// Иначе строгая URL-валидация каждого origin'а.
|
||||
return raw.filter((o) => {
|
||||
try {
|
||||
const u = new URL(o);
|
||||
return u.protocol === 'https:' || u.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
})(),
|
||||
// Default = false (fail-secure). Чтобы включить credentials cross-origin —
|
||||
// ОБЯЗАТЕЛЬНО явный CORS_ALLOW_CREDENTIALS=true.
|
||||
allowCredentials: p.CORS_ALLOW_CREDENTIALS === 'true',
|
||||
csrf: {
|
||||
cookieSecure: p.CSRF_COOKIE_SECURE === 'true',
|
||||
cookieHttpOnly: p.CSRF_COOKIE_HTTPONLY !== 'false',
|
||||
cookieSameSite: p.CSRF_COOKIE_SAMESITE || 'Lax',
|
||||
cookiePath: p.CSRF_COOKIE_PATH || '/',
|
||||
cookieDomain: p.CSRF_COOKIE_DOMAIN || '',
|
||||
},
|
||||
docs: {
|
||||
username: p.DOCS_USERNAME || 'admin',
|
||||
password: p.DOCS_PASSWORD || 'admin',
|
||||
},
|
||||
port: parseInt(p.API_PORT || '3001'),
|
||||
redis: {
|
||||
host: p.REDIS_HOST || 'keydb',
|
||||
port: parseInt(p.REDIS_PORT || '6379'),
|
||||
password: p.REDIS_PASSWORD || '',
|
||||
password: p.REDIS_PASSWORD || 'keydb',
|
||||
db: parseInt(p.REDIS_DB || '0'),
|
||||
},
|
||||
rabbit: {
|
||||
emailCodeQueue: p.RABBIT_EMAIL_CODE_QUEUE || 'email.verification_code',
|
||||
publishPersist: p.RABBIT_PUBLISH_PERSIST !== 'false',
|
||||
connectTimeout: parseInt(p.RABBIT_CONNECT_TIMEOUT || '5'),
|
||||
},
|
||||
log: {
|
||||
level: p.LOG_LEVEL || 'INFO',
|
||||
format: p.LOG_FORMAT || 'JSON',
|
||||
},
|
||||
cors: {
|
||||
origins: (p.CORS_ORIGINS || 'http://localhost:3000').split(','),
|
||||
allowCredentials: p.CORS_ALLOW_CREDENTIALS !== 'false',
|
||||
},
|
||||
rateLimit: {
|
||||
requests: parseInt(p.RATE_LIMIT_REQUESTS || '60'),
|
||||
window: parseInt(p.RATE_LIMIT_WINDOW || '60'),
|
||||
},
|
||||
port: parseInt(p.API_PORT || '3001'),
|
||||
frontendUrl: p.FRONTEND_URL || 'http://localhost:3000',
|
||||
relayApiKey: p.RELAY_API_KEY || null,
|
||||
tronApiKey: p.TRON_API_KEY || null,
|
||||
jupiterApiKey: p.JUPITER_API_KEY || null,
|
||||
jupiterReferralAccount: p.JUPITER_REFERRAL_ACCOUNT || null,
|
||||
jupiterFeeBps: parseInt(p.JUPITER_FEE_BPS || '70'),
|
||||
etherscanApiKey: p.ETHERSCAN_API_KEY || null,
|
||||
bscscanApiKey: p.BSCSCAN_API_KEY || null,
|
||||
};
|
||||
|
||||
let vaultToken: string | null = null;
|
||||
@@ -80,11 +87,6 @@ export function getVaultToken(): string | null {
|
||||
}
|
||||
|
||||
export async function initEnv(): Promise<void> {
|
||||
// Fail-fast на отсутствующие критические env vars
|
||||
if (!env.jwt.issuer || !env.jwt.audience) {
|
||||
throw new Error('JWT_ISSUER and JWT_AUDIENCE must be explicitly set (no defaults)');
|
||||
}
|
||||
|
||||
const { addr, roleId, secretId, mount, secretPath } = env.vault;
|
||||
|
||||
if (!addr || !roleId || !secretId) {
|
||||
@@ -92,20 +94,6 @@ export async function initEnv(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// H7 — HTTPS-only Vault enforce. Plaintext HTTP means master-key + AppRole secret_id
|
||||
// travel through WAN unencrypted. Override via VAULT_ALLOW_INSECURE=true (only для local dev).
|
||||
try {
|
||||
const parsed = new URL(addr);
|
||||
if (parsed.protocol !== 'https:' && p.VAULT_ALLOW_INSECURE !== 'true') {
|
||||
throw new Error(`VAULT_ADDR must use https:// (got ${parsed.protocol}). Set VAULT_ALLOW_INSECURE=true only for local dev.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('Invalid URL')) {
|
||||
throw new Error(`VAULT_ADDR is malformed: ${addr}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const token = await vaultAppRoleLogin(addr, roleId, secretId);
|
||||
if (!token) {
|
||||
logger.warn('Vault AppRole login failed, using .env fallback');
|
||||
@@ -123,44 +111,56 @@ export async function initEnv(): Promise<void> {
|
||||
|
||||
logger.info('Loaded DB secrets from Vault');
|
||||
|
||||
const maybeCsrf = secrets.CSRF_SECRET_KEY;
|
||||
if (maybeCsrf && maybeCsrf.length >= 32) {
|
||||
const mod = await import('../services/csrf.service');
|
||||
mod.setCsrfSigningKey(maybeCsrf);
|
||||
logger.info('CSRF signing key loaded from Vault (primary secret)');
|
||||
}
|
||||
|
||||
const s = (key: string) => secrets[key];
|
||||
const si = (key: string, fallback: number) => {
|
||||
const v = secrets[key];
|
||||
return v ? parseInt(v) : fallback;
|
||||
};
|
||||
|
||||
// Vault stores DB secrets in lowercase (host, user, password, name, port).
|
||||
// Accept uppercase DB_* as fallback for compatibility.
|
||||
env = {
|
||||
...env,
|
||||
db: {
|
||||
host: s('host') || s('DB_HOST') || env.db.host,
|
||||
port: si('port', si('DB_PORT', env.db.port)),
|
||||
user: s('user') || s('DB_USER') || env.db.user,
|
||||
password: s('password') || s('DB_PASSWORD') || env.db.password,
|
||||
name: s('name') || s('DB_NAME') || env.db.name,
|
||||
host: s('DB_HOST') || env.db.host,
|
||||
port: si('DB_PORT', env.db.port),
|
||||
user: s('DB_USER') || env.db.user,
|
||||
password: s('DB_PASSWORD') || env.db.password,
|
||||
name: s('DB_NAME') || env.db.name,
|
||||
poolSize: si('DATABASE_POOL_SIZE', env.db.poolSize),
|
||||
maxOverflow: si('DATABASE_MAX_OVERFLOW', env.db.maxOverflow),
|
||||
poolTimeout: si('DATABASE_POOL_TIMEOUT', env.db.poolTimeout),
|
||||
poolRecycle: si('DATABASE_POOL_RECYCLE', env.db.poolRecycle),
|
||||
echo: secrets['DATABASE_ECHO'] === 'true',
|
||||
},
|
||||
jwt: {
|
||||
...env.jwt,
|
||||
// H17 — trim whitespace; пустая строка после trim → fallback на env
|
||||
issuer: (s('JWT_ISSUER')?.trim() || env.jwt.issuer),
|
||||
audience: (s('JWT_AUDIENCE')?.trim() || env.jwt.audience),
|
||||
issuer: s('JWT_ISSUER') || env.jwt.issuer,
|
||||
audience: s('JWT_AUDIENCE') || env.jwt.audience,
|
||||
accessTtl: si('JWT_ACCESS_TTL_SECONDS', env.jwt.accessTtl),
|
||||
refreshTtl: si('JWT_REFRESH_TTL_SECONDS', env.jwt.refreshTtl),
|
||||
},
|
||||
redis: {
|
||||
host: s('REDIS_HOST') || env.redis.host,
|
||||
port: si('REDIS_PORT', env.redis.port),
|
||||
password: s('REDIS_PASSWORD') || env.redis.password,
|
||||
db: si('REDIS_DB', env.redis.db),
|
||||
},
|
||||
cors: {
|
||||
origins: s('CORS_ORIGINS') ? s('CORS_ORIGINS')!.split(',') : env.cors.origins,
|
||||
// H5 — fail-secure consistent with line 53: explicit 'true' required, default false.
|
||||
// Раньше: `!== 'false'` → defaults TRUE if field missing/empty (security inversion).
|
||||
allowCredentials: secrets['CORS_ALLOW_CREDENTIALS'] === 'true',
|
||||
allowCredentials: secrets['CORS_ALLOW_CREDENTIALS'] !== 'false',
|
||||
},
|
||||
rateLimit: {
|
||||
requests: si('RATE_LIMIT_REQUESTS', env.rateLimit.requests),
|
||||
window: si('RATE_LIMIT_WINDOW', env.rateLimit.window),
|
||||
},
|
||||
relayApiKey: s('RELAY_API_KEY') || env.relayApiKey,
|
||||
tronApiKey: s('TRON_API_KEY') || env.tronApiKey,
|
||||
jupiterApiKey: s('JUPITER_API_KEY') || env.jupiterApiKey,
|
||||
};
|
||||
|
||||
// Re-validate after Vault load. Vault мог переписать iss/aud — если они теперь пустые
|
||||
// или невалидные, fail-fast.
|
||||
if (!env.jwt.issuer || !env.jwt.audience) {
|
||||
throw new Error('JWT_ISSUER and JWT_AUDIENCE became empty after Vault load');
|
||||
}
|
||||
logger.info(`JWT validation: iss="${env.jwt.issuer}", aud="${env.jwt.audience}"`);
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* KeyDB / Redis singleton client.
|
||||
*
|
||||
* Используется для idempotency cache (см. `lib/idempotency.ts`).
|
||||
*
|
||||
* Connection:
|
||||
* REDIS_HOST=keydb (docker service name) / REDIS_PORT=6379 / REDIS_PASSWORD / REDIS_DB=0
|
||||
*
|
||||
* Startup contract: `pingRedis()` вызывается из `index.ts` и throws если KeyDB
|
||||
* unreachable — fail-fast, потому что idempotency critical для money flow.
|
||||
*/
|
||||
|
||||
import Redis, { type RedisOptions } from 'ioredis';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
let _client: Redis | null = null;
|
||||
|
||||
function buildClient(): Redis {
|
||||
const host = process.env.REDIS_HOST || 'keydb';
|
||||
const port = parseInt(process.env.REDIS_PORT || '6379', 10);
|
||||
const password = process.env.REDIS_PASSWORD || '';
|
||||
const db = parseInt(process.env.REDIS_DB || '0', 10);
|
||||
|
||||
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid REDIS_PORT ${process.env.REDIS_PORT}`);
|
||||
}
|
||||
if (!Number.isFinite(db) || db < 0 || db > 15) {
|
||||
throw new Error(`Invalid REDIS_DB ${process.env.REDIS_DB} (must be 0-15)`);
|
||||
}
|
||||
|
||||
const opts: RedisOptions = {
|
||||
host,
|
||||
port,
|
||||
db,
|
||||
lazyConnect: true,
|
||||
// Не зависать forever — fail-fast если cache недоступен
|
||||
connectTimeout: 5000,
|
||||
maxRetriesPerRequest: 3,
|
||||
// Reconnect strategy: exponential backoff, max 5s
|
||||
retryStrategy: (times) => Math.min(times * 200, 5000),
|
||||
};
|
||||
if (password) opts.password = password;
|
||||
|
||||
const client = new Redis(opts);
|
||||
|
||||
client.on('error', (err) => {
|
||||
// Не логируем secret в случае конфигурационной ошибки
|
||||
logger.error(`Redis client error: ${err.message}`);
|
||||
});
|
||||
client.on('connect', () => logger.info(`Redis connected (host=${host}:${port} db=${db})`));
|
||||
client.on('reconnecting', (delay: number) => logger.warn(`Redis reconnecting in ${delay}ms`));
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Lazily initialised singleton. */
|
||||
export function getRedis(): Redis {
|
||||
if (!_client) {
|
||||
_client = buildClient();
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup ping. Throws on failure → caller process.exit(1).
|
||||
* Connect-on-demand (lazyConnect=true), .ping() триггерит connect + первый round-trip.
|
||||
*/
|
||||
export async function pingRedis(): Promise<void> {
|
||||
const client = getRedis();
|
||||
try {
|
||||
const pong = await client.ping();
|
||||
if (pong !== 'PONG') {
|
||||
throw new Error(`Redis PING returned ${pong} (expected PONG)`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw new Error(`Redis ping failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Graceful shutdown — closes connection cleanly. */
|
||||
export async function closeRedis(): Promise<void> {
|
||||
if (_client) {
|
||||
await _client.quit().catch(() => _client?.disconnect());
|
||||
_client = null;
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* GET /api/prices — USD prices for selected token symbols.
|
||||
*
|
||||
* Security:
|
||||
* S1 — whitelist через `getCoingeckoId`. Любой symbol вне registry → 400.
|
||||
* S2 — лимит max 50 (symbol, chain) пар. Иначе → 400.
|
||||
* S5 — общий 502 при failure, без stack trace.
|
||||
* S7 — auth provided by router middleware.
|
||||
*/
|
||||
import { Request, Response } from 'express';
|
||||
import { getCoingeckoId } from '../lib/token-registry';
|
||||
import { ALL_CHAINS } from '../services/wallet-generator.service';
|
||||
import { getPricesBySymbols } from '../services/price-oracle.service';
|
||||
// getPricesWithChangeByIds импортируется dynamic'но в getDynamics handler ниже.
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const MAX_SYMBOLS_PER_REQUEST = 50;
|
||||
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
|
||||
const SYMBOL_RE = /^[A-Z0-9]{1,16}$/;
|
||||
|
||||
function isChain(v: unknown): v is ChainCode {
|
||||
return typeof v === 'string' && ALLOWED_CHAINS.has(v as ChainCode);
|
||||
}
|
||||
|
||||
export const PricesController = {
|
||||
/**
|
||||
* GET /api/prices?symbols=BTC,ETH,USDT&chain=ETH
|
||||
*
|
||||
* Params:
|
||||
* - symbols: comma-separated list, max 50. Каждый symbol должен быть в whitelist.
|
||||
* - chain (опционально): chain для disambiguation (USDT на ETH vs USDT на BSC).
|
||||
* Если не указан — для каждого symbol fallback порядок: ETH → BSC → SOL → TRX → BTC.
|
||||
* Native symbol (BTC/ETH/...) всегда matches its chain.
|
||||
*
|
||||
* Response 200:
|
||||
* { success: true, data: { "BTC": { "usd": 67432.12 }, "ETH": { "usd": 3210.45 }, "FOO": { "usd": null } } }
|
||||
*/
|
||||
async getPrices(req: Request, res: Response) {
|
||||
try {
|
||||
const rawSymbols = String(req.query.symbols || '').trim();
|
||||
if (!rawSymbols) {
|
||||
res.status(400).json({ success: false, error: 'symbols query param is required (csv)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedChain = req.query.chain ? String(req.query.chain).toUpperCase() : null;
|
||||
if (requestedChain && !isChain(requestedChain)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid chain parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const symbols = rawSymbols
|
||||
.split(',')
|
||||
.map((s) => s.trim().toUpperCase())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
if (symbols.length === 0) {
|
||||
res.status(400).json({ success: false, error: 'symbols list is empty' });
|
||||
return;
|
||||
}
|
||||
if (symbols.length > MAX_SYMBOLS_PER_REQUEST) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Too many symbols (max ${MAX_SYMBOLS_PER_REQUEST})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Strict symbol shape (S1 belt-and-suspenders).
|
||||
for (const s of symbols) {
|
||||
if (!SYMBOL_RE.test(s)) {
|
||||
res.status(400).json({ success: false, error: `Invalid symbol: ${s}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build (chain, symbol) pairs.
|
||||
// Fallback resolution order при отсутствии явного chain:
|
||||
// native symbol == chain code → that chain;
|
||||
// иначе пробуем ETH, BSC, SOL, TRX, BTC по очереди.
|
||||
const fallbackChains: ChainCode[] = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC'];
|
||||
const pairs: { chain: ChainCode; symbol: string; key: string }[] = [];
|
||||
|
||||
for (const sym of symbols) {
|
||||
if (requestedChain) {
|
||||
pairs.push({ chain: requestedChain as ChainCode, symbol: sym, key: sym });
|
||||
continue;
|
||||
}
|
||||
let resolvedChain: ChainCode | null = null;
|
||||
if (ALLOWED_CHAINS.has(sym as ChainCode)) {
|
||||
resolvedChain = sym as ChainCode;
|
||||
} else {
|
||||
for (const c of fallbackChains) {
|
||||
if (getCoingeckoId(c, sym)) {
|
||||
resolvedChain = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!resolvedChain) {
|
||||
// Symbol не находится ни в одной chain → 400 (S1: whitelist enforcement).
|
||||
res.status(400).json({ success: false, error: `Unknown symbol: ${sym}` });
|
||||
return;
|
||||
}
|
||||
pairs.push({ chain: resolvedChain, symbol: sym, key: sym });
|
||||
}
|
||||
|
||||
// Если явный chain задан — повторная проверка whitelist для каждого symbol
|
||||
// (native symbol для chain'а тоже разрешён).
|
||||
if (requestedChain) {
|
||||
for (const p of pairs) {
|
||||
if (!getCoingeckoId(p.chain, p.symbol)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Unknown symbol ${p.symbol} for chain ${p.chain}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prices = await getPricesBySymbols(
|
||||
pairs.map((p) => ({ chain: p.chain, symbol: p.symbol })),
|
||||
);
|
||||
|
||||
const data: Record<string, { usd: number | null }> = {};
|
||||
for (const p of pairs) {
|
||||
const lookupKey = `${p.chain}:${p.symbol}`;
|
||||
data[p.key] = { usd: prices.get(lookupKey) ?? null };
|
||||
}
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (err: any) {
|
||||
logger.error(`getPrices failed: ${err?.stack || err?.message || 'unknown'}`);
|
||||
res.status(502).json({ success: false, error: 'Upstream price oracle error' });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/prices/dynamics?symbols=BTC,ETH,BNB,SOL,TRX
|
||||
*
|
||||
* Возвращает USD-цену + 24h % изменения для списка symbols.
|
||||
* Default symbols (если query не задан): BTC,ETH,BNB,SOL,TRX.
|
||||
* Source: CoinGecko `include_24hr_change=true` (rolling 24h, не anchored).
|
||||
*
|
||||
* Response 200:
|
||||
* { success: true, data: { "BTC": { "usd": 67432.12, "change24h": -1.38 }, ... } }
|
||||
*/
|
||||
async getDynamics(req: Request, res: Response) {
|
||||
try {
|
||||
const rawSymbols = String(req.query.symbols || '').trim();
|
||||
const symbols = rawSymbols
|
||||
? rawSymbols.split(',').map((s) => s.trim().toUpperCase()).filter((s) => s.length > 0)
|
||||
: ['BTC', 'ETH', 'BNB', 'SOL', 'TRX'];
|
||||
|
||||
if (symbols.length === 0) {
|
||||
res.status(400).json({ success: false, error: 'symbols list is empty' });
|
||||
return;
|
||||
}
|
||||
if (symbols.length > MAX_SYMBOLS_PER_REQUEST) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Too many symbols (max ${MAX_SYMBOLS_PER_REQUEST})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (const s of symbols) {
|
||||
if (!SYMBOL_RE.test(s)) {
|
||||
res.status(400).json({ success: false, error: `Invalid symbol: ${s}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve каждый symbol в CoinGecko id напрямую.
|
||||
// Native tickers: BTC=bitcoin, ETH=ethereum, BNB=binancecoin, SOL=solana, TRX=tron.
|
||||
// Для non-native: пытаемся getCoingeckoId через chain fallback.
|
||||
const NATIVE_TICKER_TO_COINGECKO: Record<string, string> = {
|
||||
BTC: 'bitcoin',
|
||||
ETH: 'ethereum',
|
||||
BNB: 'binancecoin',
|
||||
SOL: 'solana',
|
||||
TRX: 'tron',
|
||||
};
|
||||
const fallbackChains: ChainCode[] = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC'];
|
||||
|
||||
const symbolToCgId = new Map<string, string>();
|
||||
for (const sym of symbols) {
|
||||
let cgId: string | null = NATIVE_TICKER_TO_COINGECKO[sym] ?? null;
|
||||
if (!cgId) {
|
||||
for (const c of fallbackChains) {
|
||||
const id = getCoingeckoId(c, sym);
|
||||
if (id) { cgId = id; break; }
|
||||
}
|
||||
}
|
||||
if (!cgId) {
|
||||
res.status(400).json({ success: false, error: `Unknown symbol: ${sym}` });
|
||||
return;
|
||||
}
|
||||
symbolToCgId.set(sym, cgId);
|
||||
}
|
||||
|
||||
const { getPricesWithChangeByIds } = await import('../services/price-oracle.service');
|
||||
const rich = await getPricesWithChangeByIds(Array.from(new Set(symbolToCgId.values())));
|
||||
|
||||
const data: Record<string, { usd: number | null; change24h: number | null }> = {};
|
||||
for (const sym of symbols) {
|
||||
const cgId = symbolToCgId.get(sym)!;
|
||||
const v = rich[cgId];
|
||||
data[sym] = {
|
||||
usd: v?.usd ?? null,
|
||||
change24h: v?.change24h ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (err: any) {
|
||||
logger.error(`getDynamics failed: ${err?.stack || err?.message || 'unknown'}`);
|
||||
res.status(502).json({ success: false, error: 'Upstream price oracle error' });
|
||||
}
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
23
apps/api/src/db/knexfile.ts
Normal file
23
apps/api/src/db/knexfile.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Knex } from 'knex';
|
||||
import path from 'path';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load .env from repo root when running migrations directly
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
|
||||
|
||||
const config: Knex.Config = {
|
||||
client: 'pg',
|
||||
connection: {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'cryptowallet_v2',
|
||||
},
|
||||
migrations: {
|
||||
directory: path.resolve(__dirname, 'migrations'),
|
||||
extension: __filename.endsWith('.js') ? 'js' : 'ts',
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
28
apps/api/src/db/migrations/001_create_users.ts
Normal file
28
apps/api/src/db/migrations/001_create_users.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.createTable('users', (t) => {
|
||||
t.string('id', 26).primary();
|
||||
t.string('email', 255).notNullable().unique();
|
||||
t.string('password_hash', 255).notNullable();
|
||||
t.string('last_name', 128).nullable();
|
||||
t.string('first_name', 128).nullable();
|
||||
t.string('middle_name', 128).nullable();
|
||||
t.date('birth_date').nullable();
|
||||
t.string('crypto_wallet', 255).nullable();
|
||||
t.string('phone', 16).nullable();
|
||||
t.string('bik', 9).nullable();
|
||||
t.string('account_number', 20).nullable();
|
||||
t.string('card_number', 19).nullable();
|
||||
t.string('inn', 12).nullable();
|
||||
t.boolean('kyc_verified').notNullable().defaultTo(false);
|
||||
t.timestamp('kyc_verified_at', { useTz: true }).nullable();
|
||||
t.boolean('is_deleted').notNullable().defaultTo(false);
|
||||
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists('users');
|
||||
}
|
||||
20
apps/api/src/db/migrations/002_create_wallets.ts
Normal file
20
apps/api/src/db/migrations/002_create_wallets.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.createTable('wallets', (t) => {
|
||||
t.string('id', 26).primary();
|
||||
t.string('user_id', 26).notNullable().references('id').inTable('users').onDelete('CASCADE');
|
||||
t.string('chain', 10).notNullable();
|
||||
t.string('address', 256).notNullable();
|
||||
t.string('derivation_path', 64).notNullable();
|
||||
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
t.unique(['user_id', 'chain']);
|
||||
});
|
||||
|
||||
await knex.schema.raw('CREATE INDEX idx_wallets_user_id ON wallets(user_id)');
|
||||
await knex.schema.raw('CREATE INDEX idx_wallets_address ON wallets(address)');
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists('wallets');
|
||||
}
|
||||
26
apps/api/src/db/migrations/003_create_sessions.ts
Normal file
26
apps/api/src/db/migrations/003_create_sessions.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.createTable('sessions', (t) => {
|
||||
t.string('id', 26).primary();
|
||||
t.string('sid', 26).notNullable().unique();
|
||||
t.string('user_id', 26).notNullable().references('id').inTable('users').onDelete('CASCADE');
|
||||
t.string('device_id', 26).nullable();
|
||||
t.string('user_agent', 500).nullable();
|
||||
t.string('first_ip', 64).nullable();
|
||||
t.string('last_ip', 64).nullable();
|
||||
t.timestamp('last_seen_at', { useTz: true }).nullable();
|
||||
t.timestamp('revoked_at', { useTz: true }).nullable();
|
||||
t.string('refresh_jti_hash', 255).nullable();
|
||||
t.timestamp('refresh_expires_at', { useTz: true }).nullable();
|
||||
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
await knex.schema.raw('CREATE INDEX idx_sessions_user_id ON sessions(user_id)');
|
||||
await knex.schema.raw('CREATE INDEX idx_sessions_sid ON sessions(sid)');
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists('sessions');
|
||||
}
|
||||
@@ -1,121 +1,43 @@
|
||||
import knex from 'knex';
|
||||
import knexConfig from './db/knexfile';
|
||||
import app from './app';
|
||||
import { env, initEnv } from './config/env';
|
||||
import { refreshAllKeys, startKeyRotation, stopKeyRotation } from './services/key-rotation.service';
|
||||
import { isCryptoReady, decryptMnemonic, encryptMnemonic } from './services/crypto.service';
|
||||
import { db } from './config/database';
|
||||
import { pingRedis, closeRedis } from './config/redis';
|
||||
import { env, initEnv, getVaultToken } from './config/env';
|
||||
import { loadJwtKeysFromVault } from './services/jwt.service';
|
||||
import { loadCsrfSecretFromVault, finalizeCsrfConfigFromEnv } from './services/csrf.service';
|
||||
import { logger } from './lib/logger';
|
||||
|
||||
// Global error handlers — иначе unhandled errors идут в stderr без sanitization (leak secrets)
|
||||
process.on('unhandledRejection', (reason: any) => {
|
||||
logger.error(`Unhandled rejection: ${reason?.stack || reason?.message || reason}`);
|
||||
});
|
||||
process.on('uncaughtException', (err: Error) => {
|
||||
logger.error(`Uncaught exception: ${err.stack || err.message}`);
|
||||
// Process state could be corrupt — exit cleanly
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function main() {
|
||||
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
|
||||
|
||||
await initEnv();
|
||||
const refreshResult = await refreshAllKeys();
|
||||
if (!refreshResult.ok) {
|
||||
logger.error(`Initial Vault refresh failed: ${refreshResult.reason}. Refusing to start.`);
|
||||
process.exit(1);
|
||||
await loadCsrfSecretFromVault();
|
||||
finalizeCsrfConfigFromEnv();
|
||||
|
||||
// Load JWT public keys from Vault if available
|
||||
const vaultToken = getVaultToken();
|
||||
if (vaultToken && env.vault.addr) {
|
||||
await loadJwtKeysFromVault(
|
||||
env.vault.addr,
|
||||
vaultToken,
|
||||
env.vault.mount,
|
||||
env.vault.jwtKidPath,
|
||||
env.vault.jwtKidsPrefix,
|
||||
);
|
||||
} else {
|
||||
logger.warn('JWT keys not loaded: Vault not available');
|
||||
}
|
||||
|
||||
// Custodial: без master-key сервис не может расшифровать ни одну мнемонику — fail fast.
|
||||
if (!isCryptoReady()) {
|
||||
logger.error('Crypto master key not loaded — refusing to start (custodial wallets require it)');
|
||||
process.exit(1);
|
||||
}
|
||||
const db = knex(knexConfig);
|
||||
|
||||
// Integrity self-test: если в БД уже есть encrypted_mnemonic, master-key должен их декриптить.
|
||||
// Иначе мы стартовали с НЕПРАВИЛЬНЫМ ключом (например после потери Vault dev-mode state) —
|
||||
// и любой /send или /sign будет тихо валиться с GCM auth error. Лучше упасть сразу.
|
||||
await runCryptoIntegritySelfTest();
|
||||
logger.info('Running migrations...');
|
||||
await db.migrate.latest();
|
||||
logger.info('Migrations complete');
|
||||
|
||||
// KeyDB / Redis ping — idempotency critical для money flow; fail-fast если недоступен.
|
||||
try {
|
||||
await pingRedis();
|
||||
logger.info('KeyDB/Redis self-test: PASSED');
|
||||
} catch (err: any) {
|
||||
logger.error(`KeyDB/Redis ping failed: ${err.message}. Refusing to start (idempotency unavailable).`);
|
||||
process.exit(1);
|
||||
}
|
||||
await db.destroy();
|
||||
|
||||
startKeyRotation();
|
||||
|
||||
const server = app.listen(env.port, () => {
|
||||
app.listen(env.port, () => {
|
||||
logger.info(`Server running on port ${env.port}`);
|
||||
});
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
logger.info(`${signal} received, shutting down gracefully`);
|
||||
stopKeyRotation();
|
||||
void closeRedis();
|
||||
server.close(() => process.exit(0));
|
||||
// Force exit if shutdown takes too long
|
||||
setTimeout(() => process.exit(1), 10_000).unref();
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
}
|
||||
|
||||
async function runCryptoIntegritySelfTest(): Promise<void> {
|
||||
// H4 — Two checks:
|
||||
// (a) AES round-trip ALWAYS (даже на пустой DB): encrypt PROBE → decrypt → equal.
|
||||
// Защита от corrupted master-key buffer (wrong size, zeros, etc.)
|
||||
// (b) Если в БД есть encrypted_mnemonic — пытаемся декриптить (sample 3 rows).
|
||||
// Защита от master-key drift (Vault rotation accidental).
|
||||
|
||||
// (a) AES round-trip
|
||||
try {
|
||||
const probe = 'self-test-PROBE-string-with-some-length-for-realism';
|
||||
const ct = encryptMnemonic(probe);
|
||||
const pt = decryptMnemonic(ct);
|
||||
if (pt !== probe) {
|
||||
logger.error('Crypto self-test: AES round-trip MISMATCH — master-key buffer corrupt');
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error(`Crypto self-test: AES round-trip failed: ${err?.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// (b) Existing-blob compatibility
|
||||
try {
|
||||
const rows = await db('users')
|
||||
.whereNotNull('encrypted_mnemonic')
|
||||
.select('encrypted_mnemonic')
|
||||
.limit(3);
|
||||
|
||||
if (rows.length === 0) {
|
||||
logger.info('Crypto self-test: PASSED (round-trip OK, no existing blobs to check)');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
decryptMnemonic(row.encrypted_mnemonic);
|
||||
} catch (err: any) {
|
||||
logger.error(
|
||||
'Crypto self-test: FAILED — master-key DOES NOT match stored encrypted_mnemonic. ' +
|
||||
'Likely cause: Vault state was lost (dev-mode in-memory) and a different key was seeded. ' +
|
||||
'Recovery: restore seed-cache.env from .seed-backup/ OR wipe DB with `docker compose down -v`. ' +
|
||||
`Underlying: ${err?.message}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
logger.info(`Crypto self-test: PASSED (round-trip OK + ${rows.length} existing blob(s) decryptable)`);
|
||||
} catch (err: any) {
|
||||
logger.error(`Crypto self-test: could not query DB: ${err?.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Chain-specific address validators с CHECKSUM проверкой.
|
||||
* Принципиально: regex/length недостаточно — TRX/BTC используют base58check,
|
||||
* один испорченный символ может пройти regex, но кошелёк по такому адресу
|
||||
* не восстановим → funds permanently lost.
|
||||
*/
|
||||
import { ethers } from 'ethers';
|
||||
import { createHash } from 'crypto';
|
||||
import bs58 from 'bs58';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { PublicKey } from '@solana/web3.js';
|
||||
|
||||
const TRX_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
|
||||
|
||||
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||
|
||||
export function isValidAddress(chain: ChainCode, address: string): boolean {
|
||||
// Любой блокчейн-адрес помещается в ~64 chars. 256 был оверкилл и open vector
|
||||
// для DoS (тратим CPU на bs58.decode 200-char garbage).
|
||||
if (typeof address !== 'string' || address.length === 0 || address.length > 64) return false;
|
||||
|
||||
switch (chain) {
|
||||
case 'BTC':
|
||||
return isValidBtcAddress(address);
|
||||
case 'TRX':
|
||||
return isValidTrxAddress(address);
|
||||
case 'ETH':
|
||||
case 'BSC':
|
||||
return ethers.utils.isAddress(address);
|
||||
case 'SOL':
|
||||
return isValidSolAddress(address);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── BTC: bitcoinjs-lib проверяет version byte + checksum (P2PKH/P2SH/bech32) ──
|
||||
function isValidBtcAddress(address: string): boolean {
|
||||
try {
|
||||
bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── TRX: base58check + первый байт 0x41 ──
|
||||
function isValidTrxAddress(address: string): boolean {
|
||||
if (!TRX_RE.test(address)) return false;
|
||||
let decoded: Uint8Array;
|
||||
try {
|
||||
decoded = bs58.decode(address);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (decoded.length !== 25) return false; // 1 prefix + 20 payload + 4 checksum
|
||||
if (decoded[0] !== 0x41) return false; // TRX mainnet prefix
|
||||
const payload = decoded.subarray(0, 21);
|
||||
const checksum = decoded.subarray(21);
|
||||
const h1 = createHash('sha256').update(payload).digest();
|
||||
const h2 = createHash('sha256').update(h1).digest();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (h2[i] !== checksum[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── SOL: реальное base58-декодирование через PublicKey ──
|
||||
function isValidSolAddress(address: string): boolean {
|
||||
try {
|
||||
const pk = new PublicKey(address);
|
||||
// PublicKey принимает 32-байтовое значение; isOnCurve дополнительный sanity
|
||||
return pk.toBytes().length === 32;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidAmount(amount: string): boolean {
|
||||
if (typeof amount !== 'string' || amount.length === 0 || amount.length > 78) return false;
|
||||
if (!/^\d+$/.test(amount)) return false;
|
||||
try {
|
||||
return BigInt(amount) > 0n;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* Amount unit utilities.
|
||||
*
|
||||
* API контракт исторически — `amount: string` в smallest-units (wei/lamports/sun/satoshi).
|
||||
* Этот файл добавляет ОПЦИОНАЛЬНЫЙ парсинг `amountHuman: "0.01"` через token decimals из
|
||||
* `token-registry`. Старое поле `amount` остаётся 100% backward-compatible.
|
||||
*
|
||||
* Все вычисления — BigInt-based, без float'ов (для finance: precision critical).
|
||||
*
|
||||
* Используется в:
|
||||
* - sendFromChain (body: {amount | amountHuman, token?})
|
||||
* - quoteSwap / swapOnChain legacy (body: {from/inputMint, amount | amountHuman})
|
||||
* - relay-proxy /quote preprocessing (body: {originCurrency, amount | amountHuman})
|
||||
* - cost-estimate endpoints (body: same)
|
||||
*/
|
||||
|
||||
import type { ChainCode } from './address-validators';
|
||||
import {
|
||||
getTokenInfo,
|
||||
getEvmTokens,
|
||||
TRX_TOKENS,
|
||||
SOL_TOKENS,
|
||||
} from './token-registry';
|
||||
|
||||
/**
|
||||
* Native decimals per chain. Дублирует `NATIVE_DECIMALS` из `wallet-ops.service.ts`
|
||||
* чтобы избежать circular dep (wallet-ops импортирует address-validators которое
|
||||
* импортирует этот файл косвенно).
|
||||
*/
|
||||
export const NATIVE_DECIMALS: Record<ChainCode, number> = {
|
||||
ETH: 18,
|
||||
BSC: 18,
|
||||
BTC: 8,
|
||||
TRX: 6,
|
||||
SOL: 9,
|
||||
};
|
||||
|
||||
const SOL_WRAPPED_NATIVE_MINT = 'So11111111111111111111111111111111111111112';
|
||||
|
||||
/**
|
||||
* Парсит "0.01" → "10000000" (для SOL 9 decimals) через BigInt.
|
||||
*
|
||||
* Правила:
|
||||
* - "10" + dec=6 → "10000000" (integer, без точки)
|
||||
* - "0.01" + dec=6 → "10000" (1 + 4 zeros)
|
||||
* - "1.5" + dec=6 → "1500000"
|
||||
* - "0.000001" + dec=6 → "1"
|
||||
* - "0.1234567" + dec=6 → "123456" (truncate — не round; consistent с frontend parseAmount)
|
||||
* - "0" + → throw (zero amount = error per existing isValidAmount)
|
||||
* - "-1" / "1e3" + → throw
|
||||
* - "0.1" + dec=0 → throw "no fractional digits for 0-decimal token"
|
||||
*/
|
||||
export function parseHumanAmount(human: string, decimals: number): string {
|
||||
if (typeof human !== 'string') {
|
||||
throw new Error('amountHuman must be a string');
|
||||
}
|
||||
const s = human.trim();
|
||||
if (!s) throw new Error('amountHuman is empty');
|
||||
// Defense-in-depth: длинная строка (например `"1" + "0".repeat(10000)`) форсирует
|
||||
// O(n) парсинг + BigInt round-trip. 64KB body-limit (express.json) — общий gate;
|
||||
// этот check — specific для нового парсера. Legit amount'ы укладываются в 80 chars
|
||||
// (36-decimal fractional + integer + dot). Атакующий не сможет drain CPU.
|
||||
if (s.length > 80) {
|
||||
throw new Error('amountHuman too long (max 80 chars)');
|
||||
}
|
||||
if (!/^\d+(\.\d+)?$/.test(s)) {
|
||||
throw new Error(`amountHuman invalid format "${s}" (expected "1" or "0.01")`);
|
||||
}
|
||||
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) {
|
||||
throw new Error(`Invalid decimals ${decimals}`);
|
||||
}
|
||||
|
||||
const [whole, frac = ''] = s.split('.');
|
||||
if (decimals === 0 && frac.length > 0) {
|
||||
throw new Error(`This token has 0 decimals, use integer amount (got "${s}")`);
|
||||
}
|
||||
// Truncate (not round) лишние цифры дробной части.
|
||||
const fracTrunc = frac.slice(0, decimals);
|
||||
const padded = fracTrunc.padEnd(decimals, '0');
|
||||
// Strip leading zeros чтобы получился чистый BigInt-friendly string.
|
||||
const result = (whole + padded).replace(/^0+/, '') || '0';
|
||||
|
||||
if (result === '0') {
|
||||
throw new Error(`amountHuman "${s}" evaluates to 0 smallest units`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of parseHumanAmount. "10000000" + dec=6 → "10".
|
||||
* Используется для логирования / formatted output в cost-estimate.
|
||||
*/
|
||||
export function formatSmallestUnits(smallest: string, decimals: number): string {
|
||||
if (typeof smallest !== 'string' || !/^\d+$/.test(smallest)) {
|
||||
return '0';
|
||||
}
|
||||
if (decimals === 0) return smallest;
|
||||
if (smallest.length <= decimals) {
|
||||
const padded = smallest.padStart(decimals + 1, '0');
|
||||
const whole = padded.slice(0, padded.length - decimals);
|
||||
const frac = padded.slice(padded.length - decimals).replace(/0+$/, '');
|
||||
return frac ? `${whole}.${frac}` : whole;
|
||||
}
|
||||
const whole = smallest.slice(0, smallest.length - decimals);
|
||||
const frac = smallest.slice(smallest.length - decimals).replace(/0+$/, '');
|
||||
return frac ? `${whole}.${frac}` : whole;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decimals для send endpoint'а.
|
||||
* - token задан → token-registry lookup (case-insensitive)
|
||||
* - token пуст → native decimals
|
||||
*/
|
||||
export function resolveSendDecimals(chain: ChainCode, token?: string | null): number {
|
||||
if (!token) return NATIVE_DECIMALS[chain];
|
||||
const info = getTokenInfo(chain, token);
|
||||
if (!info) {
|
||||
throw new Error(`Unknown token "${token}" on ${chain} — cannot resolve decimals`);
|
||||
}
|
||||
return info.decimals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decimals для BSC/TRX swap (`from` symbol).
|
||||
* - "BNB" / "TRX" → native (18 / 6)
|
||||
* - "USDT" / etc → registry lookup
|
||||
*/
|
||||
export function resolveSwapDecimalsBscTrx(chain: 'BSC' | 'TRX', symbol: string): number {
|
||||
const upper = symbol.toUpperCase();
|
||||
if (upper === chain) return NATIVE_DECIMALS[chain]; // BSC→BNB через chain code не сработает,
|
||||
// но BNB→18 и TRX→6 совпадают с NATIVE_DECIMALS, поэтому fallback ниже работает.
|
||||
if (chain === 'BSC' && upper === 'BNB') return NATIVE_DECIMALS.BSC;
|
||||
if (chain === 'TRX' && upper === 'TRX') return NATIVE_DECIMALS.TRX;
|
||||
const info = getTokenInfo(chain, upper);
|
||||
if (!info) {
|
||||
throw new Error(`Unknown ${chain} token "${symbol}" — cannot resolve decimals`);
|
||||
}
|
||||
return info.decimals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decimals для SOL swap (`inputMint`).
|
||||
* Wrapped SOL = 9; иначе SOL_TOKENS lookup по mint.
|
||||
*/
|
||||
export function resolveSwapDecimalsSol(mint: string): number {
|
||||
if (mint === SOL_WRAPPED_NATIVE_MINT) return NATIVE_DECIMALS.SOL;
|
||||
const t = SOL_TOKENS.find((x) => x.mint === mint);
|
||||
if (!t) {
|
||||
throw new Error(`Unknown SOL mint "${mint}" — cannot resolve decimals`);
|
||||
}
|
||||
return t.decimals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decimals по contract address (для Relay /quote где body содержит
|
||||
* `originCurrency: "0x..."` вместо symbol). Returns null если не найден —
|
||||
* caller решает: 400 vs fallback.
|
||||
*/
|
||||
export function getDecimalsByContract(chain: ChainCode, contractOrMint: string): number | null {
|
||||
const addr = contractOrMint.trim();
|
||||
if (!addr) return null;
|
||||
|
||||
// Native sentinels (Relay использует 0xeeee... для native EVM).
|
||||
if (chain === 'SOL' && addr === SOL_WRAPPED_NATIVE_MINT) return NATIVE_DECIMALS.SOL;
|
||||
if ((chain === 'ETH' || chain === 'BSC') &&
|
||||
/^0xee+/i.test(addr) || addr === '0x0000000000000000000000000000000000000000') {
|
||||
return NATIVE_DECIMALS[chain];
|
||||
}
|
||||
if (chain === 'TRX' && (addr === 'TRX' || addr === '0x0000000000000000000000000000000000000000')) {
|
||||
return NATIVE_DECIMALS.TRX;
|
||||
}
|
||||
|
||||
if (chain === 'ETH' || chain === 'BSC') {
|
||||
const lower = addr.toLowerCase();
|
||||
const t = getEvmTokens(chain).find((x) => x.contractAddress.toLowerCase() === lower);
|
||||
return t?.decimals ?? null;
|
||||
}
|
||||
if (chain === 'TRX') {
|
||||
const t = TRX_TOKENS.find((x) => x.contractAddress === addr);
|
||||
return t?.decimals ?? null;
|
||||
}
|
||||
if (chain === 'SOL') {
|
||||
const t = SOL_TOKENS.find((x) => x.mint === addr);
|
||||
return t?.decimals ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main dispatcher. Body содержит ровно ОДНО поле из {amount, amountHuman}.
|
||||
* - оба пусты → throw
|
||||
* - оба заданы → throw "use either … not both" (поведение из плана)
|
||||
* - amount задан → возврат as-is (legacy backward-compat)
|
||||
* - amountHuman задан → parseHumanAmount(value, decimals)
|
||||
*
|
||||
* Caller передаёт `decimals` (уже resolved через resolveSendDecimals / resolveSwapDecimals*).
|
||||
*/
|
||||
export function resolveAmountFromBody(
|
||||
body: { amount?: unknown; amountHuman?: unknown },
|
||||
decimals: number,
|
||||
): string {
|
||||
const hasAmount = body?.amount !== undefined && body?.amount !== null && body?.amount !== '';
|
||||
const hasAmountHuman = body?.amountHuman !== undefined && body?.amountHuman !== null && body?.amountHuman !== '';
|
||||
|
||||
if (hasAmount && hasAmountHuman) {
|
||||
throw new Error('Use either "amount" (smallest units) OR "amountHuman" (human form), not both');
|
||||
}
|
||||
if (!hasAmount && !hasAmountHuman) {
|
||||
throw new Error('Either "amount" or "amountHuman" is required');
|
||||
}
|
||||
|
||||
if (hasAmount) {
|
||||
const a = String(body.amount);
|
||||
if (!/^\d+$/.test(a) || BigInt(a) <= 0n) {
|
||||
throw new Error('amount must be positive integer string (smallest units)');
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// amountHuman path
|
||||
return parseHumanAmount(String(body.amountHuman), decimals);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* App fee 0.7% — single source of truth для всех chains.
|
||||
*
|
||||
* Применяется в:
|
||||
* - swap-orchestrator.service.ts:executeBsc/Sol/Trx (custodial swap, atomic fee tx ДО main swap)
|
||||
* - bridge-execute.service.ts:executeEvm/Sol/Tron (bridge atomic fee)
|
||||
* - controllers/wallet.controller.ts:signRawEvmTx (Relay EVM bridge, when client передаёт bridgeAmount)
|
||||
* - controllers/wallet.controller.ts:appFeeTransfer (NEW endpoint /wallets/{chain}/app-fee для Relay frontend hook)
|
||||
*
|
||||
* Wallets захардкожены — НЕ через env. Security: нельзя переопределить через body или env,
|
||||
* нельзя перенаправить fee на adversary'ский адрес. Single source of truth для аудита.
|
||||
*
|
||||
* Address per chain family:
|
||||
* EVM (ETH+BSC) → 0xeb9f... (один EVM wallet, оба chain'а)
|
||||
* SOL → DQkQ... (Solana base58)
|
||||
* TRX → TRwp... (Tron base58)
|
||||
* BTC → bc1q... (bech32 P2WPKH, отдельная fee tx перед bridge)
|
||||
*
|
||||
* Изменение wallet → требует code review + новый release.
|
||||
*/
|
||||
|
||||
import type { ChainCode } from './address-validators';
|
||||
|
||||
/** EVM (ETH + BSC). Single address для обеих chain. Заменил старый BSC-only 0xeDEb... */
|
||||
export const APP_FEE_WALLET_EVM = '0xeb9fbf0d137ef5ea7b9959044c2ed44ec1206c68';
|
||||
|
||||
/** Solana base58. */
|
||||
export const APP_FEE_WALLET_SOL = 'DQkQegoX698XkcXZ6VX9P1qUpbV64Sgjz1BCPFgfWpjD';
|
||||
|
||||
/** Tron base58 (с T-prefix). */
|
||||
export const APP_FEE_WALLET_TRX = 'TRwpFjnfMBe4aDJbHYEqeUVCG1auF8wFXP';
|
||||
|
||||
/** Bitcoin bech32 (P2WPKH). */
|
||||
export const APP_FEE_WALLET_BTC = 'bc1qwzm9e4qun4tptecalq9zwxshdnwmvcxtz27znm';
|
||||
|
||||
/** 70 bps = 0.7%. Изменение требует code review. */
|
||||
export const APP_FEE_BPS = 70n;
|
||||
|
||||
/** 10000 = 100% в bps notation. */
|
||||
export const APP_FEE_DENOMINATOR = 10000n;
|
||||
|
||||
/**
|
||||
* Resolve fee recipient для chain.
|
||||
*/
|
||||
export function getAppFeeWallet(chain: ChainCode): string {
|
||||
if (chain === 'ETH' || chain === 'BSC') return APP_FEE_WALLET_EVM;
|
||||
if (chain === 'SOL') return APP_FEE_WALLET_SOL;
|
||||
if (chain === 'TRX') return APP_FEE_WALLET_TRX;
|
||||
if (chain === 'BTC') return APP_FEE_WALLET_BTC;
|
||||
throw new Error(`getAppFeeWallet: unsupported chain '${chain}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check если для chain есть fee wallet.
|
||||
*/
|
||||
export function hasAppFee(chain: ChainCode): boolean {
|
||||
return chain === 'ETH' || chain === 'BSC' || chain === 'SOL' || chain === 'TRX' || chain === 'BTC';
|
||||
}
|
||||
|
||||
/**
|
||||
* computeAppFee(amountSmallest): возвращает 0.7% от amount в smallest units (BigInt).
|
||||
*
|
||||
* Использует BigInt — никаких float precision losses.
|
||||
* Если amount < 144 (= 10000/70) → fee округляется до 0 (BigInt integer division).
|
||||
* Callers должны skip fee tx если result = 0n.
|
||||
*
|
||||
* @param amountSmallest строка цифр (positive integer in smallest units)
|
||||
* @returns BigInt fee amount
|
||||
* @throws если amount не валидный positive integer string
|
||||
*/
|
||||
export function computeAppFee(amountSmallest: string): bigint {
|
||||
if (typeof amountSmallest !== 'string' || !/^\d+$/.test(amountSmallest)) {
|
||||
throw new Error(`computeAppFee: invalid amount "${amountSmallest}" (must be positive integer string)`);
|
||||
}
|
||||
return (BigInt(amountSmallest) * APP_FEE_BPS) / APP_FEE_DENOMINATOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* computeAmountAfterFee(amount): возвращает (amount - fee) — то что реально пойдёт
|
||||
* в swap/bridge после удержания 0.7%.
|
||||
*/
|
||||
export function computeAmountAfterFee(amountSmallest: string): bigint {
|
||||
const fee = computeAppFee(amountSmallest);
|
||||
return BigInt(amountSmallest) - fee;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Audit log — STDOUT ONLY (best-effort).
|
||||
*
|
||||
* ⚠️ DURABLE AUDIT REMOVED. Per design choice: `audit_log` DB-таблица убрана,
|
||||
* pre-mutation INSERT pattern → not used. Audit-trail доступен только в Docker
|
||||
* stdout (`level=audit` JSON lines), который log-aggregator (Loki/CloudWatch/etc.)
|
||||
* подбирает.
|
||||
*
|
||||
* Trade-off: stdout не обеспечивает strict fail-secure семантику. Если Docker
|
||||
* log driver buffer переполнится или log-aggregator down — записи могут потеряться.
|
||||
* Если потребуется restore compliance-grade audit — вернуть `audit_log` table
|
||||
* и pre-mutation INSERT/UPDATE pattern (см. git history).
|
||||
*
|
||||
* Public API сохраняет signatures из предыдущей DB-версии для backward compat
|
||||
* без рефакторинга wallet.controller.ts callers:
|
||||
* - `auditLog(entry)` — best-effort, returns void
|
||||
* - `auditLogStrict(entry)` — now == auditLog + returns dummy ID для compat
|
||||
* - `completeAudit(id, ...)` — теперь stdout-mirror update event
|
||||
*/
|
||||
|
||||
import { getTraceId } from './trace-store';
|
||||
|
||||
export interface AuditEntry {
|
||||
event: string;
|
||||
userId: string;
|
||||
ip?: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
result?: 'success' | 'failure';
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
function buildLine(entry: AuditEntry, status: string): string {
|
||||
return JSON.stringify({
|
||||
level: 'audit',
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
trace_id: getTraceId(),
|
||||
...entry,
|
||||
}) + '\n';
|
||||
}
|
||||
|
||||
function writeStdoutBestEffort(line: string): void {
|
||||
try {
|
||||
process.stdout.write(line);
|
||||
} catch {
|
||||
// EPIPE / closed — swallow
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort: stdout only. */
|
||||
export async function auditLog(entry: AuditEntry): Promise<void> {
|
||||
const status: 'success' | 'failure' = entry.result === 'failure' ? 'failure' : 'success';
|
||||
writeStdoutBestEffort(buildLine(entry, status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compat shim. Раньше это был pre-mutation DB INSERT (fail-secure).
|
||||
* Сейчас — просто stdout audit + возвращает opaque ID для совместимости с callers
|
||||
* которые передают его в `completeAudit()`.
|
||||
*
|
||||
* Никогда не throws (раньше throw'ил при DB failure → caller отказывал в operation).
|
||||
* Returns timestamp-based ID; не reliable identifier, чисто для completeAudit pairing.
|
||||
*/
|
||||
export async function auditLogStrict(entry: AuditEntry & { status?: string }): Promise<string> {
|
||||
const status = entry.status ?? 'pending';
|
||||
writeStdoutBestEffort(buildLine(entry, status));
|
||||
// Opaque ID: timestamp-ms + random suffix. Не store'им — только для symmetry call-site.
|
||||
return `audit-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compat: завершающий event audit. Раньше — DB UPDATE row.
|
||||
* Сейчас — просто stdout write parallel event.
|
||||
*
|
||||
* `auditId` параметр игнорируется (его не было где writer'у искать в БД).
|
||||
*/
|
||||
export async function completeAudit(
|
||||
auditId: string,
|
||||
result: 'success' | 'failure',
|
||||
meta?: Record<string, unknown>,
|
||||
errorCode?: string,
|
||||
): Promise<void> {
|
||||
writeStdoutBestEffort(
|
||||
buildLine(
|
||||
{ event: `audit.complete:${auditId}`, userId: '<see-original-event>', meta, errorCode, result },
|
||||
result,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* BSC fee — backwards-compat re-export shim для `app-fee.ts`.
|
||||
*
|
||||
* Раньше (до multi-chain fee feature) этот файл содержал hardcoded
|
||||
* `BSC_FEE_WALLET = '0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718'` и `computeBscFee`.
|
||||
* Теперь fee унифицирован — `app-fee.ts` source of truth с тремя wallets (EVM/SOL/TRX),
|
||||
* а здесь — shim чтобы существующие callers (swap-orchestrator, wallet-signer.service,
|
||||
* wallet.controller) продолжали компилироваться без edit'ов.
|
||||
*
|
||||
* Behavior change: после этого rebuild старый wallet `0xeDEb...` НЕ используется. Все
|
||||
* EVM fees идут на новый `0xeb9fbf0d137ef5ea7b9959044c2ed44ec1206c68` (см. app-fee.ts).
|
||||
*
|
||||
* Существующие callers НЕ нуждаются в code change — они импортируют `BSC_FEE_WALLET`
|
||||
* который теперь = `APP_FEE_WALLET_EVM`. Single source of truth.
|
||||
*
|
||||
* Note: пока оставляем shim для backwards-compat. Если позже захотим — refactor callers
|
||||
* на прямой import из `app-fee.ts`.
|
||||
*/
|
||||
|
||||
export {
|
||||
APP_FEE_WALLET_EVM as BSC_FEE_WALLET,
|
||||
APP_FEE_BPS as BSC_FEE_BPS,
|
||||
APP_FEE_DENOMINATOR as BSC_FEE_DENOMINATOR,
|
||||
computeAppFee as computeBscFee,
|
||||
computeAmountAfterFee as computeSwapAmountAfterFee,
|
||||
} from './app-fee';
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* Security policy для `signAndBroadcastRawEvm`.
|
||||
*
|
||||
* Защита от drain-вектора: stolen JWT + один POST = пустой кошелёк, если подписывать
|
||||
* произвольный `to`+`data`. Этот модуль применяет несколько слоёв защиты:
|
||||
*
|
||||
* 1. **Selector blacklist** — `approve()`, `permit()`, `setApprovalForAll()` etc.
|
||||
* Безопасный swap НЕ требует approve через наш sign-raw — клиент должен делать
|
||||
* approve через другой кастодиальный flow (если бы был такой), но самый чистый
|
||||
* дизайн — никогда не давать sign-raw отозвать approval из bridge-quote.
|
||||
*
|
||||
* 2. **`to` allowlist** — только Relay router-адреса для каждого chainId.
|
||||
* Native send (`data === '0x'`) тоже whitelist'ит `to` чтобы атакер не мог
|
||||
* drain native через sign-raw-evm-tx.
|
||||
*
|
||||
* 3. **Caps** — `gas`, `value`, `gas*maxFeePerGas` — против poisoned quote.
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
/** Relay-protocol router/depository contract addresses per chainId.
|
||||
*
|
||||
* Relay deploys new router contracts периодически (несколько раз в год).
|
||||
* Если запрос к /sign-raw-evm-tx падает с "not in allowlist" — посмотри `to` адрес
|
||||
* в Relay /execute response и добавь сюда. Relay использует deterministic deployer,
|
||||
* так что один и тот же router обычно деплоится на ETH и BSC с тем же адресом.
|
||||
*
|
||||
* Полный список: https://docs.relay.link/references/contract-addresses
|
||||
*/
|
||||
const RELAY_ROUTERS: Record<number, Set<string>> = {
|
||||
// Ethereum mainnet — Relay router contracts (lowercase for canonical match)
|
||||
1: new Set([
|
||||
'0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 (cross-chain bridge lock)
|
||||
'0xf70da97812cb96acdf810712aa562db8dfa3dbef', // Relay Router v1 (legacy intra-chain entry point)
|
||||
'0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', // Relay Router v2 (current intra-chain swap entry point, since ~2025)
|
||||
]),
|
||||
// BSC mainnet (Relay использует тот же deterministic-deployed address для router v2)
|
||||
56: new Set([
|
||||
'0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1
|
||||
'0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', // Relay Router v2 (deterministic deploy)
|
||||
]),
|
||||
};
|
||||
|
||||
/** Method selectors which are NEVER allowed via sign-raw-evm-tx (drain vectors). */
|
||||
const FORBIDDEN_SELECTORS: Record<string, string> = {
|
||||
'0x095ea7b3': 'approve(address,uint256)',
|
||||
'0x39509351': 'increaseAllowance(address,uint256)',
|
||||
'0xd505accf': 'permit(address,address,uint256,uint256,uint8,bytes32,bytes32)',
|
||||
'0x8fcbaf0c': 'permit(address,address,uint256,uint256,bool,uint8,bytes32,bytes32)',
|
||||
'0xa22cb465': 'setApprovalForAll(address,bool)',
|
||||
'0x42842e0e': 'safeTransferFrom(address,address,uint256)', // NFT transferFrom
|
||||
'0xb88d4fde': 'safeTransferFrom(address,address,uint256,bytes)',
|
||||
};
|
||||
|
||||
export interface PolicyParams {
|
||||
chainId: number;
|
||||
to: string;
|
||||
data: string;
|
||||
value: string;
|
||||
gas: string;
|
||||
maxFeePerGas: string;
|
||||
/**
|
||||
* Dynamic trusted addresses из Redis cache (`relay-trusted:{chainId}`).
|
||||
* Объединяются с статическим `RELAY_ROUTERS[chainId]` whitelist'ом.
|
||||
* Каждое /relay/execute response добавляет туда `to` + approve spender'ы.
|
||||
* Если caller не передаёт (legacy) — используется только static whitelist.
|
||||
*/
|
||||
dynamicTrusted?: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps (per-chain budget). Стоит выше любого realistic Relay tx, но защищает
|
||||
* от absurd-poisoned quote, которые сжигают весь баланс на gas.
|
||||
*/
|
||||
const CAPS = {
|
||||
// gas: max 1.5M (типичный complex swap ~300-500k; cap есть запас)
|
||||
maxGas: ethers.BigNumber.from('1500000'),
|
||||
// gas budget: gas × maxFeePerGas ≤ 0.05 native (= 0.05 ETH ≈ $130 worst case)
|
||||
// Уровень который покрывает legitimate bridge/swap но не drain
|
||||
maxGasBudgetWei: ethers.utils.parseEther('0.05'),
|
||||
// value: max 100 native в одной tx (защита от случайного drain через value)
|
||||
// Native send большего объёма через sign-raw-evm-tx — explicit user confirmation
|
||||
// нужен. Через /send route (структурированный) — ограничения другие.
|
||||
maxValueWei: ethers.utils.parseEther('100'),
|
||||
};
|
||||
|
||||
const SELECTOR_APPROVE = '0x095ea7b3';
|
||||
|
||||
/**
|
||||
* Применяет security policy. Throws if disallowed.
|
||||
*
|
||||
* Два разрешённых пути:
|
||||
*
|
||||
* **A) Прямой call к Relay router:**
|
||||
* `to` ∈ RELAY_ROUTERS[chainId] AND selector ∉ FORBIDDEN_SELECTORS
|
||||
* Используется для основной swap/bridge tx через Relay.
|
||||
*
|
||||
* **B) Approve к Relay router:**
|
||||
* selector == approve(address,uint256) AND
|
||||
* spender (первый параметр approve) ∈ RELAY_ROUTERS[chainId]
|
||||
* `to` может быть любым ERC20 token контрактом (USDT/USDC/etc).
|
||||
* Используется для первого шага в multi-step Relay swap (token → X).
|
||||
* Защита: attacker не может через sign-raw сделать approve на свой контракт —
|
||||
* spender обязан быть Relay router из whitelist.
|
||||
*
|
||||
* Возвращает info-объект для логов.
|
||||
*/
|
||||
export function applyEvmTxPolicy(p: PolicyParams): { routerName?: string; selectorName?: string; flowKind: 'router-call' | 'approve-to-relay' } {
|
||||
const toLower = p.to.toLowerCase();
|
||||
const staticRouters = RELAY_ROUTERS[p.chainId];
|
||||
if (!staticRouters) {
|
||||
throw new Error(`Sign-raw policy: chainId ${p.chainId} not in router allowlist`);
|
||||
}
|
||||
// Combined trust set = static whitelist ∪ dynamic cache (from /relay/execute responses)
|
||||
const isTrusted = (addr: string): boolean =>
|
||||
staticRouters.has(addr) || (p.dynamicTrusted?.has(addr) ?? false);
|
||||
|
||||
const selector = p.data.length >= 10 ? p.data.slice(0, 10).toLowerCase() : '';
|
||||
let flowKind: 'router-call' | 'approve-to-relay';
|
||||
let selectorName: string | undefined;
|
||||
|
||||
if (selector === SELECTOR_APPROVE) {
|
||||
// ─── Path B: approve(spender, amount), spender must be Relay router ───
|
||||
flowKind = 'approve-to-relay';
|
||||
selectorName = 'approve(address,uint256)';
|
||||
// calldata layout: 4-byte selector + 32-byte spender + 32-byte amount = 68 bytes = 136 hex + '0x'
|
||||
if (p.data.length < 138) {
|
||||
throw new Error('Sign-raw policy: malformed approve calldata (too short)');
|
||||
}
|
||||
// spender = lower 20 bytes of first 32-byte parameter (left-padded with zeros)
|
||||
const spenderHex = '0x' + p.data.slice(10 + 24, 10 + 64).toLowerCase();
|
||||
if (!isTrusted(spenderHex)) {
|
||||
throw new Error(`Sign-raw policy: approve spender ${spenderHex} not in Relay router allowlist for chainId ${p.chainId}`);
|
||||
}
|
||||
// `to` (token contract) может быть любым — это разрешённый flow.
|
||||
// value для approve() должен быть 0 (стандартный ERC20 approve не принимает value)
|
||||
if (p.value !== '0' && p.value !== '0x0' && ethers.BigNumber.from(p.value).gt(0)) {
|
||||
throw new Error('Sign-raw policy: approve() with non-zero value rejected');
|
||||
}
|
||||
} else {
|
||||
// ─── Path A: direct call to Relay router ───
|
||||
flowKind = 'router-call';
|
||||
if (!isTrusted(toLower)) {
|
||||
throw new Error(`Sign-raw policy: 'to' address ${p.to} not in Relay router allowlist for chainId ${p.chainId}`);
|
||||
}
|
||||
// Forbidden selectors (drain vectors) проверяются ТОЛЬКО для router-call path —
|
||||
// потому что для approve у нас отдельный (более строгий) check на spender выше.
|
||||
if (selector && FORBIDDEN_SELECTORS[selector]) {
|
||||
throw new Error(`Sign-raw policy: forbidden selector ${selector} (${FORBIDDEN_SELECTORS[selector]}) — drain vector`);
|
||||
}
|
||||
selectorName = selector ? `selector ${selector}` : undefined;
|
||||
}
|
||||
|
||||
// ─── Common caps (для обоих путей) ───
|
||||
const gas = ethers.BigNumber.from(p.gas);
|
||||
if (gas.gt(CAPS.maxGas)) {
|
||||
throw new Error(`Sign-raw policy: gas ${gas.toString()} exceeds cap ${CAPS.maxGas.toString()}`);
|
||||
}
|
||||
const maxFee = ethers.BigNumber.from(p.maxFeePerGas);
|
||||
const budget = gas.mul(maxFee);
|
||||
if (budget.gt(CAPS.maxGasBudgetWei)) {
|
||||
throw new Error(`Sign-raw policy: gas budget ${ethers.utils.formatEther(budget)} ETH exceeds cap ${ethers.utils.formatEther(CAPS.maxGasBudgetWei)} ETH`);
|
||||
}
|
||||
const value = ethers.BigNumber.from(p.value);
|
||||
if (value.gt(CAPS.maxValueWei)) {
|
||||
throw new Error(`Sign-raw policy: value ${ethers.utils.formatEther(value)} exceeds cap ${ethers.utils.formatEther(CAPS.maxValueWei)} native units`);
|
||||
}
|
||||
|
||||
return {
|
||||
routerName: flowKind === 'router-call' ? `relay-router-${p.chainId}` : `relay-approve-${p.chainId}`,
|
||||
selectorName,
|
||||
flowKind,
|
||||
};
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* Idempotency-Key handling — anti-double-spend на retry.
|
||||
*
|
||||
* Storage: KeyDB / Redis (см. `config/redis.ts`).
|
||||
*
|
||||
* Контракт:
|
||||
* Client передаёт header `Idempotency-Key: <opaque-string-up-to-128-chars>`.
|
||||
* Server:
|
||||
* 1. `SET NX EX 600 idem:{userId}:{key} '{requestHash,status:null,body:null}'`
|
||||
* - NX (only-if-not-exists) → atomic claim
|
||||
* - EX 600 → 10 минут TTL
|
||||
* 2. Если NX вернул OK → fresh claim, caller proceed'ит mutation.
|
||||
* 3. Если NX вернул null → retry detected. GET значение и:
|
||||
* - request_hash отличается → 409 "key reuse with different body"
|
||||
* - status null → 409 "in-flight, retry after a few seconds"
|
||||
* - status set → return cached response (no double-broadcast)
|
||||
*
|
||||
* После mutation client вызывает `saveIdempotencyResponse(userId, key, status, body)`
|
||||
* чтобы cache последующих retry'ев на тот же key.
|
||||
*
|
||||
* Trade-off vs DB:
|
||||
* + Latency <1ms (single Redis round-trip vs ~5ms DB)
|
||||
* + No DB pressure
|
||||
* + Auto-expiry via Redis EX
|
||||
* + Distributed (multi-replica work через shared cache)
|
||||
* – KeyDB single point of failure → API падает на startup ping (fail-fast)
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import { getRedis } from '../config/redis';
|
||||
|
||||
const TTL_SECONDS = 10 * 60; // 10 minutes
|
||||
|
||||
interface CacheEntry {
|
||||
requestHash: string;
|
||||
responseStatus: number | null; // null = in-flight
|
||||
responseBody: string | null;
|
||||
}
|
||||
|
||||
export interface IdempotencyClaim {
|
||||
fresh: boolean;
|
||||
cached?: { status: number; body: string };
|
||||
}
|
||||
|
||||
function cacheKey(userId: string, key: string): string {
|
||||
return `idem:${userId}:${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic claim. Returns:
|
||||
* - fresh=true → caller обязан proceed mutation и save response
|
||||
* - fresh=false + cached → return cached response без mutation (retry case)
|
||||
*
|
||||
* Throws при:
|
||||
* - in-flight (другой attempt ещё не save'нул response)
|
||||
* - body hash mismatch (replay с другим body на тот же key)
|
||||
*/
|
||||
export async function claimIdempotency(
|
||||
userId: string,
|
||||
key: string,
|
||||
requestBody: unknown,
|
||||
): Promise<IdempotencyClaim> {
|
||||
const requestHash = createHash('sha256')
|
||||
.update(JSON.stringify(requestBody ?? {}))
|
||||
.digest('hex');
|
||||
|
||||
const redis = getRedis();
|
||||
const k = cacheKey(userId, key);
|
||||
const initial: CacheEntry = {
|
||||
requestHash,
|
||||
responseStatus: null,
|
||||
responseBody: null,
|
||||
};
|
||||
|
||||
// SET key value NX EX seconds — atomic claim. Returns 'OK' if set, null if existed.
|
||||
const setResult = await redis.set(k, JSON.stringify(initial), 'EX', TTL_SECONDS, 'NX');
|
||||
if (setResult === 'OK') {
|
||||
return { fresh: true };
|
||||
}
|
||||
|
||||
// Already exists — это retry. Читаем.
|
||||
const raw = await redis.get(k);
|
||||
if (!raw) {
|
||||
// Race: между NX и GET значение expired. Перепопытка как fresh.
|
||||
const retry = await redis.set(k, JSON.stringify(initial), 'EX', TTL_SECONDS, 'NX');
|
||||
if (retry === 'OK') return { fresh: true };
|
||||
throw new Error('Idempotency cache race; retry after a few seconds.');
|
||||
}
|
||||
|
||||
let entry: CacheEntry;
|
||||
try {
|
||||
entry = JSON.parse(raw) as CacheEntry;
|
||||
} catch {
|
||||
throw new Error('Idempotency cache entry corrupt');
|
||||
}
|
||||
|
||||
if (entry.requestHash !== requestHash) {
|
||||
throw new Error('Idempotency-Key reuse with different request body. Use a new key.');
|
||||
}
|
||||
|
||||
if (entry.responseStatus === null) {
|
||||
throw new Error('Operation already in flight; retry after a few seconds.');
|
||||
}
|
||||
|
||||
return {
|
||||
fresh: false,
|
||||
cached: {
|
||||
status: entry.responseStatus,
|
||||
body: entry.responseBody ?? '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить response в cache после mutation (success или failure).
|
||||
* Best-effort: если Redis недоступен — log error, не throw (mutation уже произошла,
|
||||
* cache update — UX optimization для retry'ев).
|
||||
*/
|
||||
export async function saveIdempotencyResponse(
|
||||
userId: string,
|
||||
key: string,
|
||||
status: number,
|
||||
body: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const redis = getRedis();
|
||||
const k = cacheKey(userId, key);
|
||||
const raw = await redis.get(k);
|
||||
if (!raw) return; // expired — skip
|
||||
let entry: CacheEntry;
|
||||
try {
|
||||
entry = JSON.parse(raw) as CacheEntry;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
entry.responseStatus = status;
|
||||
entry.responseBody = body;
|
||||
// Re-set with refreshed TTL чтобы retry мог получить cached response
|
||||
await redis.set(k, JSON.stringify(entry), 'EX', TTL_SECONDS);
|
||||
} catch {
|
||||
// Cache update — non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate header format. Returns null if missing/invalid. */
|
||||
export function extractIdempotencyKey(headerValue: unknown): string | null {
|
||||
if (typeof headerValue !== 'string') return null;
|
||||
const v = headerValue.trim();
|
||||
if (!v) return null;
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(v)) return null;
|
||||
return v;
|
||||
}
|
||||
@@ -30,31 +30,6 @@ function getCallerInfo(): { file: string; line: number } {
|
||||
return { file: 'unknown', line: 0 };
|
||||
}
|
||||
|
||||
// Удаляем потенциально чувствительные значения из лог-сообщений.
|
||||
// Защита от случайной утечки Vault tokens / passwords / secrets через сообщения ошибок.
|
||||
const SENSITIVE_PATTERNS: { regex: RegExp; replace: string }[] = [
|
||||
{ regex: /(password|passwd|pwd)\s*[:=]\s*\S+/gi, replace: '$1=***' },
|
||||
{ regex: /(token|secret|api[_-]?key|auth)\s*[:=]\s*\S+/gi, replace: '$1=***' },
|
||||
{ regex: /(role[_-]?id|secret[_-]?id)\s*[:=]\s*\S+/gi, replace: '$1=***' },
|
||||
{ regex: /(mnemonic|seed[_-]?phrase|private[_-]?key|priv[_-]?key)\s*[:=]\s*\S+/gi, replace: '$1=***' },
|
||||
{ regex: /\bhvs\.[A-Za-z0-9]+/g, replace: 'hvs.***' },
|
||||
{ regex: /\bs\.[A-Za-z0-9]{20,}/g, replace: 's.***' },
|
||||
{ regex: /Bearer\s+[A-Za-z0-9._-]+/gi, replace: 'Bearer ***' },
|
||||
{ regex: /Authorization:\s*\S+/gi, replace: 'Authorization: ***' },
|
||||
// BIP39 mnemonic phrase (12-24 lowercase английских слов через пробел) — case-insensitive
|
||||
{ regex: /\b([a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/gi, replace: '[REDACTED_MNEMONIC]' },
|
||||
// Hex privkey (64 hex chars подряд, optional 0x)
|
||||
{ regex: /\b(0x)?[0-9a-fA-F]{64}\b/g, replace: '[REDACTED_HEX64]' },
|
||||
];
|
||||
|
||||
function sanitize(msg: string): string {
|
||||
let out = msg;
|
||||
for (const { regex, replace } of SENSITIVE_PATTERNS) {
|
||||
out = out.replace(regex, replace);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function log(level: string, message: string): void {
|
||||
if ((LEVELS[level] ?? 0) < MIN_LEVEL) return;
|
||||
|
||||
@@ -66,7 +41,7 @@ function log(level: string, message: string): void {
|
||||
file: caller.file,
|
||||
line: caller.line,
|
||||
trace_id: getTraceId(),
|
||||
message: sanitize(message),
|
||||
message,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
/**
|
||||
* NearIntents 1Click API client — direct integration (bypasses LiFi).
|
||||
*
|
||||
* Flow:
|
||||
* 1. POST /v0/quote { originAsset, destinationAsset, amount, refundTo, recipient, deadline, ... }
|
||||
* → returns { depositAddress, minAmountOut, deadline, timeWhenInactive, ... }
|
||||
* 2. User sends `amount` of `originAsset` to `depositAddress` (regular transfer on source chain).
|
||||
* 3. POST /v0/deposit/submit { depositAddress, txHash } — optional best-effort notification.
|
||||
* 4. GET /v0/status?depositAddress=... → polls intent execution.
|
||||
*
|
||||
* Why we use this instead of LiFi для TRX:
|
||||
* - LiFi для TRX возвращает pre-built protobuf raw_data_hex с NearIntents intent_id внутри,
|
||||
* у которого 30-60s off-chain TTL. Наш pipeline превышает TTL → on-chain revert + burn fees.
|
||||
* - NearIntents direct API даёт нам чистый "transfer на адрес" flow без contract calls,
|
||||
* mr deadline ≥ 30 минут (мы сами выбираем). Используем existing battle-tested sendTrx.
|
||||
*
|
||||
* Security: ВСЕ outbound calls через `proxiedFetch` (HTTPS-only, 20s timeout, IP rotation).
|
||||
* NO mnemonic / wallet access в этом модуле — это чистый HTTP клиент.
|
||||
*/
|
||||
|
||||
import { proxiedFetch } from './outbound-proxy';
|
||||
import { logger } from './logger';
|
||||
import type { ChainCode } from './address-validators';
|
||||
|
||||
const NEARINTENTS_API_URL = 'https://1click.chaindefuser.com';
|
||||
const NEARINTENTS_TIMEOUT_MS = 20_000;
|
||||
|
||||
// ─── Dynamic asset map: ChainCode + (null|contract) → NearIntents assetId ──────
|
||||
//
|
||||
// Раньше был hardcoded map с угаданными assetId — это привело к "tokenOut is not valid"
|
||||
// для BSC (BSC assets имеют формат nep245:v2_1.omni.hot.tg:56_<id>, а не nep141:bsc.omft.near
|
||||
// как я предположил). Теперь fetch /v0/tokens напрямую от NearIntents — authoritative source.
|
||||
//
|
||||
// Cache: in-memory, TTL 1 час, lazy refresh на miss. NearIntents tokens list стабилен
|
||||
// (обновляется при добавлении новых chains, не часто).
|
||||
|
||||
interface NearIntentsToken {
|
||||
blockchain: string; // 'tron', 'sol', 'eth', 'bsc', 'btc', 'aptos', 'arb', etc.
|
||||
symbol: string;
|
||||
contractAddress: string | null; // null для native; lowercased contract для EVM/SOL/TRX
|
||||
decimals: number;
|
||||
assetId: string; // 'nep141:...' или 'nep245:...' (depends on bridge provider)
|
||||
price?: number;
|
||||
priceUpdatedAt?: string;
|
||||
}
|
||||
|
||||
interface AssetMapCache {
|
||||
map: Map<string, string>; // key = '<blockchain>:<contract|native>' → assetId
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let _assetMapCache: AssetMapCache | null = null;
|
||||
const ASSET_MAP_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
// Наш ChainCode → NearIntents `blockchain` field.
|
||||
const CHAIN_CODE_TO_NEARINTENTS_BLOCKCHAIN: Partial<Record<ChainCode, string>> = {
|
||||
ETH: 'eth',
|
||||
BSC: 'bsc',
|
||||
TRX: 'tron',
|
||||
SOL: 'sol',
|
||||
BTC: 'btc',
|
||||
};
|
||||
|
||||
function assetMapKey(blockchain: string, contract: string | null): string {
|
||||
return `${blockchain.toLowerCase()}:${(contract ?? 'native').toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch full asset map от NearIntents. Cached in-memory 1h.
|
||||
* On cache miss или expiry — refetch. On HTTP error — throws.
|
||||
*/
|
||||
async function fetchAssetMap(): Promise<Map<string, string>> {
|
||||
if (_assetMapCache && _assetMapCache.expiresAt > Date.now()) {
|
||||
return _assetMapCache.map;
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), NEARINTENTS_TIMEOUT_MS);
|
||||
let res: globalThis.Response;
|
||||
try {
|
||||
res = await proxiedFetch(`${NEARINTENTS_API_URL}/v0/tokens`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`NearIntents /v0/tokens fetch failed (${res.status}): ${text.slice(0, 200)}`);
|
||||
}
|
||||
const tokens = (await res.json()) as NearIntentsToken[];
|
||||
if (!Array.isArray(tokens)) {
|
||||
throw new Error('NearIntents /v0/tokens returned non-array');
|
||||
}
|
||||
const map = new Map<string, string>();
|
||||
for (const t of tokens) {
|
||||
if (!t.blockchain || !t.assetId) continue;
|
||||
map.set(assetMapKey(t.blockchain, t.contractAddress), t.assetId);
|
||||
}
|
||||
_assetMapCache = { map, expiresAt: Date.now() + ASSET_MAP_TTL_MS };
|
||||
logger.info(`NearIntents asset map loaded: ${map.size} entries across ${new Set(tokens.map((t) => t.blockchain)).size} chains`);
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ChainCode + token → NearIntents assetId via dynamic /v0/tokens lookup.
|
||||
* Returns null если pair не supported (caller должен throw clear error).
|
||||
*
|
||||
* `token === null` → native. Contract addresses lowercased internally для matching.
|
||||
*/
|
||||
export async function resolveAsset(chain: ChainCode, token: string | null): Promise<string | null> {
|
||||
const blockchain = CHAIN_CODE_TO_NEARINTENTS_BLOCKCHAIN[chain];
|
||||
if (!blockchain) {
|
||||
logger.warn(`NearIntents: chain ${chain} not in CHAIN_CODE_TO_NEARINTENTS_BLOCKCHAIN map`);
|
||||
return null;
|
||||
}
|
||||
const map = await fetchAssetMap();
|
||||
return map.get(assetMapKey(blockchain, token)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh asset map cache (для тестов / admin debug). Не используется в production flow.
|
||||
*/
|
||||
export function _invalidateAssetMapCache(): void {
|
||||
_assetMapCache = null;
|
||||
}
|
||||
|
||||
// ─── Quote ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface NearIntentsQuoteInput {
|
||||
/** Pre-resolved NearIntents assetId (e.g. 'nep141:tron.omft.near'). Используй `resolveAsset()` для получения. */
|
||||
originAssetId: string;
|
||||
/** Pre-resolved NearIntents assetId для destination. */
|
||||
destinationAssetId: string;
|
||||
amount: string; // smallest units of origin asset
|
||||
/** Slippage tolerance в bps (basis points). 50 = 0.5%. Hard-cap 500 (5%) на server. */
|
||||
slippageBps: number;
|
||||
/** User's wallet на origin chain — куда NearIntents вернёт средства если intent fails */
|
||||
refundTo: string;
|
||||
/** User's wallet на destination chain — куда solver доставит destination asset */
|
||||
recipient: string;
|
||||
/** Сколько минут (от сейчас) intent остаётся valid. Default 30, max 60. */
|
||||
deadlineMinutes?: number;
|
||||
}
|
||||
|
||||
export interface NearIntentsQuoteResult {
|
||||
/** Tron base58 address куда юзеру отправить amount */
|
||||
depositAddress: string;
|
||||
amountIn: string;
|
||||
amountInFormatted: string;
|
||||
amountInUsd?: string;
|
||||
minAmountIn: string;
|
||||
amountOut: string;
|
||||
amountOutFormatted: string;
|
||||
amountOutUsd?: string;
|
||||
minAmountOut: string;
|
||||
deadline: string; // ISO timestamp
|
||||
deadlineMs: number; // parsed convenience
|
||||
timeWhenInactive: string; // ISO — когда solver перестаёт обрабатывать
|
||||
timeWhenInactiveMs: number;
|
||||
timeEstimateSec: number;
|
||||
signature: string; // ed25519:... — anti-MEV proof, store в audit
|
||||
correlationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a NearIntents 1Click quote (real, not dry-run — returns depositAddress).
|
||||
*/
|
||||
export async function fetchNearIntentsQuote(input: NearIntentsQuoteInput): Promise<NearIntentsQuoteResult> {
|
||||
if (!input.originAssetId || !input.destinationAssetId) {
|
||||
throw new Error('NearIntents: originAssetId + destinationAssetId required (use resolveAsset() first)');
|
||||
}
|
||||
|
||||
// Server-side slippage cap (anti-foot-gun)
|
||||
const slippageBps = Math.min(Math.max(input.slippageBps, 10), 500);
|
||||
|
||||
// Deadline — мы выбираем сами, NearIntents примет любой reasonable timestamp.
|
||||
// 30 минут default (хватает на user reaction + broadcast + solver delivery).
|
||||
const deadlineMinutes = Math.min(Math.max(input.deadlineMinutes ?? 30, 5), 60);
|
||||
const deadline = new Date(Date.now() + deadlineMinutes * 60_000).toISOString();
|
||||
|
||||
const body = {
|
||||
dry: false,
|
||||
swapType: 'EXACT_INPUT',
|
||||
slippageTolerance: slippageBps,
|
||||
originAsset: input.originAssetId,
|
||||
depositType: 'ORIGIN_CHAIN',
|
||||
destinationAsset: input.destinationAssetId,
|
||||
amount: input.amount,
|
||||
refundTo: input.refundTo,
|
||||
refundType: 'ORIGIN_CHAIN',
|
||||
recipient: input.recipient,
|
||||
recipientType: 'DESTINATION_CHAIN',
|
||||
deadline,
|
||||
};
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), NEARINTENTS_TIMEOUT_MS);
|
||||
let res: globalThis.Response;
|
||||
try {
|
||||
res = await proxiedFetch(`${NEARINTENTS_API_URL}/v0/quote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
logger.warn(`NearIntents /v0/quote ${res.status}: ${text.slice(0, 200)}`);
|
||||
throw new Error(`NearIntents quote failed (${res.status}): ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
let json: any;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('NearIntents returned non-JSON');
|
||||
}
|
||||
|
||||
const q = json.quote;
|
||||
if (!q || !q.depositAddress) {
|
||||
throw new Error(`NearIntents quote missing depositAddress: ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
depositAddress: String(q.depositAddress),
|
||||
amountIn: String(q.amountIn),
|
||||
amountInFormatted: String(q.amountInFormatted || ''),
|
||||
amountInUsd: q.amountInUsd ? String(q.amountInUsd) : undefined,
|
||||
minAmountIn: String(q.minAmountIn || q.amountIn),
|
||||
amountOut: String(q.amountOut),
|
||||
amountOutFormatted: String(q.amountOutFormatted || ''),
|
||||
amountOutUsd: q.amountOutUsd ? String(q.amountOutUsd) : undefined,
|
||||
minAmountOut: String(q.minAmountOut),
|
||||
deadline: String(q.deadline),
|
||||
deadlineMs: new Date(q.deadline).getTime(),
|
||||
timeWhenInactive: String(q.timeWhenInactive || q.deadline),
|
||||
timeWhenInactiveMs: new Date(q.timeWhenInactive || q.deadline).getTime(),
|
||||
timeEstimateSec: Number(q.timeEstimate || 60),
|
||||
signature: String(json.signature || ''),
|
||||
correlationId: String(json.correlationId || ''),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Deposit notification ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Best-effort: notify NearIntents что мы отправили tx. Solver всё равно мониторит
|
||||
* на-chain — поэтому если этот POST fails, intent всё ещё процессится.
|
||||
*
|
||||
* Errors NOT thrown (caller should ignore failures).
|
||||
*/
|
||||
export async function submitNearIntentsDeposit(depositAddress: string, txHash: string): Promise<void> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), NEARINTENTS_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await proxiedFetch(`${NEARINTENTS_API_URL}/v0/deposit/submit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ depositAddress, txHash }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '');
|
||||
logger.warn(`NearIntents deposit submit ${res.status}: ${txt.slice(0, 200)}`);
|
||||
} else {
|
||||
logger.info(`NearIntents deposit submitted: ${depositAddress} ← ${txHash}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn(`NearIntents deposit submit network error: ${err?.message}`);
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status polling helper (для frontend tracker) ────────────────────────────
|
||||
|
||||
export function nearIntentsTrackerUrl(depositAddress: string): string {
|
||||
return `${NEARINTENTS_API_URL}/v0/status?depositAddress=${encodeURIComponent(depositAddress)}`;
|
||||
}
|
||||
|
||||
// ─── depositAddress validation (security: ensure NearIntents возвращает
|
||||
// валидный Tron address, не attacker-controlled garbage)
|
||||
|
||||
const TRON_BASE58_REGEX = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
|
||||
const BTC_BECH32_REGEX = /^bc1[ac-hj-np-z02-9]{6,}$/;
|
||||
|
||||
/**
|
||||
* Throws если depositAddress не соответствует ожидаемому формату для chain.
|
||||
*/
|
||||
export function assertValidDepositAddress(chain: ChainCode, depositAddress: string): void {
|
||||
if (chain === 'TRX') {
|
||||
if (!TRON_BASE58_REGEX.test(depositAddress)) {
|
||||
throw new Error(`NearIntents depositAddress ${depositAddress} не валидный Tron base58 — abort`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (chain === 'BTC') {
|
||||
if (!BTC_BECH32_REGEX.test(depositAddress)) {
|
||||
throw new Error(`NearIntents depositAddress ${depositAddress} не валидный Bitcoin bech32 — abort`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* Outbound HTTP/HTTPS proxy для swap + bridge endpoints (только).
|
||||
*
|
||||
* Когда `OUTBOUND_PROXY_URL` задан в env — все calls к Jupiter / Relay / EVM RPC /
|
||||
* Solana RPC / TronGrid из:
|
||||
* - swap-orchestrator.service.ts (custodial swap BSC/TRX/SOL)
|
||||
* - routes/relay-proxy.routes.ts (Relay /quote, /execute, /intents/status)
|
||||
* - wallet-signer.service.ts:signAndBroadcastRawEvm (bridge sign-raw)
|
||||
* - wallet-signer.service.ts:signAndBroadcastSolanaTx + signAndBroadcastSolanaInstructions
|
||||
* идут через proxy.
|
||||
*
|
||||
* НЕ через proxy (direct outbound):
|
||||
* - /balance, /transactions, /send (basic)
|
||||
* - /prices (CoinGecko)
|
||||
* - gas-suggestions
|
||||
* - legacy /api/{btc,tron,sol/swap,tron/swap,bsc/swap}/* proxy routes
|
||||
*
|
||||
* Proxy формат (squid-style, без auth по дефолту):
|
||||
* OUTBOUND_PROXY_URL=http://37.220.84.34:3128
|
||||
* OUTBOUND_PROXY_URL=http://user:pass@host:port (если нужен auth)
|
||||
* OUTBOUND_PROXY_URL=https://host:port (если TLS до прокси)
|
||||
*
|
||||
* Если env пустой — fallback на native fetch / прямой Connection.
|
||||
*/
|
||||
|
||||
import { ProxyAgent, fetch as undiciFetch } from 'undici';
|
||||
import { ethers } from 'ethers';
|
||||
import { Connection, type Commitment } from '@solana/web3.js';
|
||||
import { logger } from './logger';
|
||||
|
||||
let _agent: ProxyAgent | null = null;
|
||||
let _agentChecked = false;
|
||||
|
||||
/**
|
||||
* Lazy-init `ProxyAgent` from `OUTBOUND_PROXY_URL` env. Returns `null` if env is empty
|
||||
* (callers should fallback to native fetch).
|
||||
*/
|
||||
export function getProxyAgent(): ProxyAgent | null {
|
||||
if (_agentChecked) return _agent;
|
||||
_agentChecked = true;
|
||||
const url = process.env.OUTBOUND_PROXY_URL?.trim();
|
||||
if (!url) return null;
|
||||
try {
|
||||
_agent = new ProxyAgent({
|
||||
uri: url,
|
||||
// Некоторые RPC endpoints используют неполные cert chains через прокси;
|
||||
// для swap/bridge transport-level MITM не критичен (sigs проверяются on-chain).
|
||||
requestTls: { rejectUnauthorized: false },
|
||||
});
|
||||
// Маскируем basic-auth credentials в логе
|
||||
const masked = url.replace(/:\/\/[^@]+@/, '://***:***@');
|
||||
logger.info(`Outbound proxy enabled (swap+bridge only): ${masked}`);
|
||||
return _agent;
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to init OUTBOUND_PROXY_URL=${url}: ${err?.message || 'unknown'}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch() через proxy если задан, иначе обычный globalThis.fetch.
|
||||
* Сигнатура совместима с native fetch.
|
||||
*/
|
||||
export async function proxiedFetch(
|
||||
input: string | URL,
|
||||
init?: RequestInit & { signal?: AbortSignal },
|
||||
): Promise<Response> {
|
||||
const agent = getProxyAgent();
|
||||
if (!agent) {
|
||||
return fetch(input as any, init as any);
|
||||
}
|
||||
// undici.fetch поддерживает `dispatcher` для per-call routing через ProxyAgent.
|
||||
// Возвращаемый тип Response совместим с native — приводим через unknown для TS.
|
||||
return undiciFetch(input as any, {
|
||||
...(init as any),
|
||||
dispatcher: agent,
|
||||
}) as unknown as Response;
|
||||
}
|
||||
|
||||
/**
|
||||
* ethers v5 JsonRpcProvider с overridden `send()` — отправляет JSON-RPC через proxiedFetch.
|
||||
*
|
||||
* ethers v5 internal fetchJson не использует globalThis.fetch + не поддерживает proxy agent,
|
||||
* поэтому override `send()` — единственный надёжный путь.
|
||||
*/
|
||||
export class ProxiedJsonRpcProvider extends ethers.providers.StaticJsonRpcProvider {
|
||||
private _id = 1;
|
||||
|
||||
async send(method: string, params: unknown[]): Promise<any> {
|
||||
const url = (this.connection as any).url as string;
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: this._id++,
|
||||
method,
|
||||
params,
|
||||
});
|
||||
const res = await proxiedFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`RPC ${method} HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
const json = (await res.json()) as { result?: unknown; error?: { code?: number; message?: string; data?: unknown } };
|
||||
if (json.error) {
|
||||
const e: any = new Error(json.error.message || `RPC ${method} error`);
|
||||
e.code = json.error.code;
|
||||
e.data = json.error.data;
|
||||
throw e;
|
||||
}
|
||||
return json.result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Failover: пробуем `rpcs` последовательно через proxied JSON-RPC, возвращаем первый-живой.
|
||||
* Замена `pickProvider` для swap/bridge code paths.
|
||||
*/
|
||||
export async function pickProxiedEvmProvider(
|
||||
rpcs: string[],
|
||||
chainId: number,
|
||||
): Promise<ProxiedJsonRpcProvider> {
|
||||
let lastErr: any;
|
||||
for (const url of rpcs) {
|
||||
const p = new ProxiedJsonRpcProvider(url, chainId);
|
||||
try {
|
||||
await Promise.race([
|
||||
p.getBlockNumber(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('rpc_alive_timeout')), 5000),
|
||||
),
|
||||
]);
|
||||
return p;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`All proxied RPCs failed (chainId=${chainId}, n=${rpcs.length}): ${(lastErr as any)?.message || 'unknown'}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Solana Connection с custom fetch = proxiedFetch.
|
||||
* @solana/web3.js ConnectionConfig поддерживает поле `fetch` — наш entry point.
|
||||
*/
|
||||
export function getProxiedSolConnection(
|
||||
rpcUrl: string,
|
||||
commitment: Commitment = 'confirmed',
|
||||
): Connection {
|
||||
return new Connection(rpcUrl, {
|
||||
commitment,
|
||||
fetch: proxiedFetch as unknown as typeof fetch,
|
||||
});
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Dynamic cache "trusted EVM addresses from Relay /execute responses".
|
||||
*
|
||||
* Каждый раз когда юзер делает /relay/execute/* → ответ Relay содержит unsigned tx'ы
|
||||
* (`steps[].items[].data.to` + selector/parameter в `data` для approve).
|
||||
* Эти адреса — официальные от Relay (т.к. идут через наш proxy к api.relay.link),
|
||||
* безопасно довериться им для последующего sign-raw-evm-tx за короткое окно.
|
||||
*
|
||||
* Хранилище: KeyDB Redis set `relay-trusted:{chainId}` с TTL 30 минут.
|
||||
* При sign-raw-evm-tx `applyEvmTxPolicy` объединяет static whitelist + этот cache.
|
||||
*
|
||||
* Защита от drain:
|
||||
* - addresses попадают в cache ТОЛЬКО через /relay/execute response (наш proxy fetch'ит
|
||||
* api.relay.link — компрометированный upstream может потенциально подсунуть свой
|
||||
* адрес, но Relay уже trusted в security-модели; если они скомпрометированы,
|
||||
* мы тоже).
|
||||
* - TTL 30 минут — addresses сами протухают.
|
||||
* - Set deduplicates, размер каждого set'а ≤ ~50 (Relay routers стабильные).
|
||||
*/
|
||||
|
||||
import { getRedis } from '../config/redis';
|
||||
import { logger } from './logger';
|
||||
|
||||
const CACHE_TTL_SECONDS = 30 * 60; // 30 минут
|
||||
const SELECTOR_APPROVE = '0x095ea7b3';
|
||||
const SET_PREFIX = 'relay-trusted:';
|
||||
|
||||
/**
|
||||
* Parse Relay execute response и записать обнаруженные EVM-адреса в KeyDB set.
|
||||
* Не-EVM steps (chainId не указан / 0 / SOL=792703809) скипаем.
|
||||
*/
|
||||
export async function indexRelayExecuteResponse(payload: unknown): Promise<void> {
|
||||
try {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
const steps = (payload as any).steps;
|
||||
if (!Array.isArray(steps)) return;
|
||||
|
||||
// Группируем addresses по chainId — один pipeline на chain
|
||||
const perChain = new Map<number, Set<string>>();
|
||||
for (const step of steps) {
|
||||
const items = step?.items;
|
||||
if (!Array.isArray(items)) continue;
|
||||
for (const item of items) {
|
||||
const data = item?.data;
|
||||
if (!data || typeof data !== 'object') continue;
|
||||
const chainId = Number(data.chainId);
|
||||
if (!Number.isFinite(chainId) || chainId <= 0) continue;
|
||||
// Skip non-EVM (SOL = 792703809, и т.п.) — у них не EVM `to`/`data`.
|
||||
if (chainId > 1_000_000) continue;
|
||||
|
||||
const set = perChain.get(chainId) ?? new Set<string>();
|
||||
perChain.set(chainId, set);
|
||||
|
||||
// 1) сам `to` контракт
|
||||
const to = String(data.to || '').toLowerCase();
|
||||
if (/^0x[0-9a-f]{40}$/.test(to)) set.add(to);
|
||||
|
||||
// 2) approve spender — если selector approve, parse first param
|
||||
const calldata = String(data.data || '').toLowerCase();
|
||||
if (calldata.startsWith(SELECTOR_APPROVE) && calldata.length >= 138) {
|
||||
const spender = '0x' + calldata.slice(10 + 24, 10 + 64);
|
||||
if (/^0x[0-9a-f]{40}$/.test(spender)) set.add(spender);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (perChain.size === 0) return;
|
||||
|
||||
const redis = getRedis();
|
||||
const pipeline = redis.pipeline();
|
||||
for (const [chainId, addrs] of perChain.entries()) {
|
||||
const key = `${SET_PREFIX}${chainId}`;
|
||||
pipeline.sadd(key, ...Array.from(addrs));
|
||||
pipeline.expire(key, CACHE_TTL_SECONDS);
|
||||
}
|
||||
await pipeline.exec();
|
||||
} catch (err: any) {
|
||||
// Не валим запрос — это enrichment, основной flow продолжается
|
||||
logger.warn(`indexRelayExecuteResponse skipped: ${err?.message || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить set trusted addresses для chainId. Никогда не throws.
|
||||
* Возвращает пустой Set если cache недоступен / пустой.
|
||||
*/
|
||||
export async function getRelayTrustedAddresses(chainId: number): Promise<Set<string>> {
|
||||
try {
|
||||
const redis = getRedis();
|
||||
const members = await redis.smembers(`${SET_PREFIX}${chainId}`);
|
||||
return new Set(members.map((m) => m.toLowerCase()));
|
||||
} catch (err: any) {
|
||||
logger.warn(`getRelayTrustedAddresses(${chainId}) failed: ${err?.message || 'unknown'}`);
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Per-user-per-chain mutex для send / sign operations.
|
||||
*
|
||||
* C3 — nonce race: `provider.getTransactionCount('pending')` без lock'а возвращает
|
||||
* одинаковый nonce для двух parallel send'ов → один tx replaces другой, или оба
|
||||
* с разными gas → mempool collision. Plus retry после ECONNRESET = double-spend.
|
||||
*
|
||||
* Этот lock — in-process Map+queue. Для multi-replica deployment нужен Redis.
|
||||
*/
|
||||
|
||||
type ReleaseFn = () => void;
|
||||
|
||||
const locks = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Acquire lock для `userId:chain`. Возвращает release function.
|
||||
* Если другой await уже идёт, наш await ждёт его release.
|
||||
*
|
||||
* Usage:
|
||||
* const release = await acquireSendLock(userId, chain);
|
||||
* try { ... } finally { release(); }
|
||||
*/
|
||||
export async function acquireSendLock(userId: string, chain: string): Promise<ReleaseFn> {
|
||||
const key = `${userId}:${chain}`;
|
||||
// Wait для предыдущего lock
|
||||
while (locks.has(key)) {
|
||||
await locks.get(key);
|
||||
}
|
||||
let release: ReleaseFn = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = () => {
|
||||
locks.delete(key);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
locks.set(key, promise);
|
||||
return release;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Swap quote cache (KeyDB).
|
||||
*
|
||||
* Используется 2-step swap flow: `quoteSwap` сохраняет результат расчёта в Redis
|
||||
* под опаковым `quoteId`, `swapOnChain` читает по `{userId, quoteId}` и выполняет
|
||||
* swap с зафиксированными параметрами (anti-MEV защита от изменения minOut между
|
||||
* quote и execute).
|
||||
*
|
||||
* Cache key:
|
||||
* swap-quote:{userId}:{quoteId}
|
||||
*
|
||||
* TTL: 30 секунд (default). После expire — execute вернёт 410 Gone и юзер
|
||||
* перезапросит quote.
|
||||
*
|
||||
* Anti-replay: успешный execute удаляет cache entry (см. `deleteQuote`).
|
||||
*
|
||||
* Security:
|
||||
* - quoteId сгенерирован через ULID (collision-resistant)
|
||||
* - cache key включает userId — even if quoteId leak'нет, другой юзер не
|
||||
* сможет execute (DB read будет miss)
|
||||
* - extra check на field `userId` внутри cached object — defence-in-depth
|
||||
*/
|
||||
|
||||
import { getRedis } from '../config/redis';
|
||||
import { logger } from './logger';
|
||||
|
||||
const KEY_PREFIX = 'swap-quote:';
|
||||
const DEFAULT_TTL_SECONDS = 30;
|
||||
|
||||
export interface CachedSwapQuote {
|
||||
// Метаданные
|
||||
quoteId: string;
|
||||
userId: string;
|
||||
chain: 'BSC' | 'TRX' | 'SOL';
|
||||
createdAt: number; // unix ms
|
||||
expiresAt: number; // unix ms
|
||||
|
||||
// Параметры execute — locked
|
||||
// Для BSC/TRX: from/to/amount — symbols. SOL: inputMint/outputMint/amount — mints.
|
||||
params: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
inputMint?: string;
|
||||
outputMint?: string;
|
||||
amount: string;
|
||||
slippageBps: number;
|
||||
feeTier?: 'slow' | 'normal' | 'fast';
|
||||
};
|
||||
|
||||
// Locked-in expectation для execute path (защита от MEV-sandwich).
|
||||
// На execute мы передаём `minOut` (BSC/TRX) ИЛИ напрямую `quoteResponse`
|
||||
// (SOL — Jupiter API требует full quote object).
|
||||
locked: {
|
||||
expectedOut: string;
|
||||
minOut: string;
|
||||
// SOL only: serialized Jupiter /quote response (для re-use на /swap step).
|
||||
jupiterQuoteResponse?: any;
|
||||
};
|
||||
|
||||
// Snapshot всех полей quote — возвращается клиенту, попадает в audit_log.
|
||||
preview: any;
|
||||
}
|
||||
|
||||
function buildKey(userId: string, quoteId: string): string {
|
||||
return `${KEY_PREFIX}${userId}:${quoteId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет quote в KeyDB с TTL.
|
||||
* Возвращает true если успешно, false если cache write failed (не критично — caller
|
||||
* может вернуть 503).
|
||||
*/
|
||||
export async function saveQuote(
|
||||
quote: CachedSwapQuote,
|
||||
ttlSeconds: number = DEFAULT_TTL_SECONDS,
|
||||
): Promise<boolean> {
|
||||
if (ttlSeconds < 1 || ttlSeconds > 600) {
|
||||
throw new Error(`saveQuote: ttlSeconds ${ttlSeconds} out of range [1,600]`);
|
||||
}
|
||||
try {
|
||||
const key = buildKey(quote.userId, quote.quoteId);
|
||||
await getRedis().set(key, JSON.stringify(quote), 'EX', ttlSeconds);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
logger.error(`swap-quote-cache.saveQuote failed: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает quote из KeyDB по `{userId, quoteId}`.
|
||||
* Возвращает `null` если:
|
||||
* - не найден (expired или never existed)
|
||||
* - userId mismatch в cached object (defence-in-depth)
|
||||
* - JSON parse error
|
||||
* - Redis unavailable (логируется error)
|
||||
*/
|
||||
export async function getQuote(
|
||||
userId: string,
|
||||
quoteId: string,
|
||||
): Promise<CachedSwapQuote | null> {
|
||||
if (!userId || !quoteId) return null;
|
||||
// Базовая sanity-check: quoteId должен быть alphanumeric (ULID = 26 chars), но
|
||||
// допускаем любые printable для гибкости. Бьём только entrants с обвидно
|
||||
// malformed input.
|
||||
if (quoteId.length > 64 || /[\r\n\s]/.test(quoteId)) return null;
|
||||
|
||||
try {
|
||||
const key = buildKey(userId, quoteId);
|
||||
const raw = await getRedis().get(key);
|
||||
if (!raw) return null;
|
||||
|
||||
let parsed: CachedSwapQuote;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as CachedSwapQuote;
|
||||
} catch {
|
||||
logger.error(`swap-quote-cache.getQuote: JSON parse failed for key=${key}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Defence-in-depth: cache key уже content-binds userId, но проверяем поле.
|
||||
if (parsed.userId !== userId) {
|
||||
logger.error(`swap-quote-cache.getQuote: userId mismatch (key=${userId}, body=${parsed.userId})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (err: any) {
|
||||
logger.error(`swap-quote-cache.getQuote failed: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет quote после успешного execute (anti-replay).
|
||||
* Best-effort — ошибки логируются и swallowed.
|
||||
*/
|
||||
export async function deleteQuote(userId: string, quoteId: string): Promise<void> {
|
||||
try {
|
||||
await getRedis().del(buildKey(userId, quoteId));
|
||||
} catch (err: any) {
|
||||
logger.warn(`swap-quote-cache.deleteQuote failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const QUOTE_TTL_SECONDS = DEFAULT_TTL_SECONDS;
|
||||
@@ -1,312 +0,0 @@
|
||||
/**
|
||||
* Реестр известных токенов per-chain. Используется в getBalance для
|
||||
* параллельного запроса баланса всех токенов адреса.
|
||||
*
|
||||
* Адреса контрактов / mint'ы — из публичных on-chain данных и используются
|
||||
* также в swap-proxy роутах (BSC TOKEN_MAP, SOL ALLOWED_MINTS).
|
||||
*/
|
||||
|
||||
import type { ChainCode } from './address-validators';
|
||||
|
||||
export interface EvmToken {
|
||||
symbol: string;
|
||||
name: string;
|
||||
contractAddress: string;
|
||||
decimals: number;
|
||||
coingeckoId?: string;
|
||||
}
|
||||
|
||||
export interface TrxToken {
|
||||
symbol: string;
|
||||
name: string;
|
||||
contractAddress: string; // T...base58
|
||||
decimals: number;
|
||||
coingeckoId?: string;
|
||||
}
|
||||
|
||||
export interface SolToken {
|
||||
symbol: string;
|
||||
name: string;
|
||||
mint: string; // SPL mint pubkey (base58)
|
||||
decimals: number;
|
||||
coingeckoId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat shape для GET /api/tokens.
|
||||
* Native coins имеют contract = null.
|
||||
*
|
||||
* `decimals` — нужен фронту чтобы конвертировать human-readable amount
|
||||
* ("10") → smallest units ("10000000000000000000") перед отправкой в LiFi/swap API.
|
||||
* Для native берётся из `NATIVE_DECIMALS` (BTC=8, ETH/BSC=18, TRX=6, SOL=9);
|
||||
* для tokens — из EvmToken.decimals / TrxToken.decimals / SolToken.decimals.
|
||||
*/
|
||||
export interface TokenListEntry {
|
||||
chain: ChainCode;
|
||||
symbol: string;
|
||||
name: string;
|
||||
contract: string | null;
|
||||
decimals: number;
|
||||
/** LiFi/Jumper fromToken/toToken для native (BTC = "bitcoin"). */
|
||||
lifiAddress?: string;
|
||||
}
|
||||
|
||||
/** LiFi native sentinel для bridge quote (только BTC отличается от contract:null). */
|
||||
export const LIFI_NATIVE_ADDRESS: Partial<Record<ChainCode, string>> = {
|
||||
BTC: 'bitcoin',
|
||||
};
|
||||
|
||||
/**
|
||||
* Native decimals per chain. Дублирует `NATIVE_DECIMALS` из `lib/amount-units.ts`
|
||||
* и `services/wallet-ops.service.ts` — небольшое количество constants, проще
|
||||
* inline'нуть чем плодить cross-file deps.
|
||||
*/
|
||||
const NATIVE_DECIMALS_LOCAL: Record<ChainCode, number> = {
|
||||
ETH: 18,
|
||||
BSC: 18,
|
||||
BTC: 8,
|
||||
TRX: 6,
|
||||
SOL: 9,
|
||||
};
|
||||
|
||||
/**
|
||||
* CoinGecko coin IDs для native монет каждой chain.
|
||||
* Используется в `price-oracle.service.ts` для USD-цен в `/balance`.
|
||||
*/
|
||||
export const NATIVE_COINGECKO_IDS: Record<ChainCode, string> = {
|
||||
BTC: 'bitcoin',
|
||||
ETH: 'ethereum',
|
||||
BSC: 'binancecoin',
|
||||
TRX: 'tron',
|
||||
SOL: 'solana',
|
||||
};
|
||||
|
||||
/**
|
||||
* Native coin human names + tickers. На BSC ticker = "BNB" (не "BSC").
|
||||
* Используется в GET /api/tokens для native entries.
|
||||
*/
|
||||
export const NATIVE_NAMES: Record<ChainCode, string> = {
|
||||
BTC: 'Bitcoin',
|
||||
ETH: 'Ethereum',
|
||||
BSC: 'BNB',
|
||||
TRX: 'Tron',
|
||||
SOL: 'Solana',
|
||||
};
|
||||
|
||||
export const NATIVE_SYMBOLS: Record<ChainCode, string> = {
|
||||
BTC: 'BTC',
|
||||
ETH: 'ETH',
|
||||
BSC: 'BNB', // ticker отличается от chain code
|
||||
TRX: 'TRX',
|
||||
SOL: 'SOL',
|
||||
};
|
||||
|
||||
export const ETH_TOKENS: EvmToken[] = [
|
||||
{ symbol: 'USDT', name: 'Tether USD', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6, coingeckoId: 'tether' },
|
||||
{ symbol: 'USDC', name: 'USD Coin', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, coingeckoId: 'usd-coin' },
|
||||
{ symbol: 'DAI', name: 'Dai Stablecoin', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18, coingeckoId: 'dai' },
|
||||
{ symbol: 'WBTC', name: 'Wrapped Bitcoin', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8, coingeckoId: 'wrapped-bitcoin' },
|
||||
{ symbol: 'LINK', name: 'Chainlink', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18, coingeckoId: 'chainlink' },
|
||||
{ symbol: 'UNI', name: 'Uniswap', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18, coingeckoId: 'uniswap' },
|
||||
];
|
||||
|
||||
export const BSC_TOKENS: EvmToken[] = [
|
||||
{ symbol: 'USDT', name: 'Tether USD', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18, coingeckoId: 'tether' },
|
||||
{ symbol: 'USDC', name: 'USD Coin', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, coingeckoId: 'usd-coin' },
|
||||
{ symbol: 'DOGE', name: 'Dogecoin', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8, coingeckoId: 'dogecoin' },
|
||||
{ symbol: 'WBNB', name: 'Wrapped BNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18, coingeckoId: 'wbnb' },
|
||||
{ symbol: 'BUSD', name: 'Binance USD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18, coingeckoId: 'binance-usd' },
|
||||
];
|
||||
|
||||
export const TRX_TOKENS: TrxToken[] = [
|
||||
{ symbol: 'USDT', name: 'Tether USD', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6, coingeckoId: 'tether' },
|
||||
];
|
||||
|
||||
export const SOL_TOKENS: SolToken[] = [
|
||||
{ symbol: 'USDT', name: 'Tether USD', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6, coingeckoId: 'tether' },
|
||||
{ symbol: 'USDC', name: 'USD Coin', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6, coingeckoId: 'usd-coin' },
|
||||
{ symbol: 'PUMP', name: 'Pump.fun', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6, coingeckoId: 'pump-fun' },
|
||||
{ symbol: 'JUP', name: 'Jupiter', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6, coingeckoId: 'jupiter-exchange-solana' },
|
||||
{ symbol: 'WIF', name: 'dogwifhat', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6, coingeckoId: 'dogwifcoin' },
|
||||
{ symbol: 'POPCAT', name: 'Popcat', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9, coingeckoId: 'popcat' },
|
||||
{ symbol: 'TRUMP', name: 'Official Trump', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6, coingeckoId: 'official-trump' },
|
||||
{ symbol: 'PYTH', name: 'Pyth Network', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6, coingeckoId: 'pyth-network' },
|
||||
{ symbol: 'JTO', name: 'Jito', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9, coingeckoId: 'jito-governance-token' },
|
||||
{ symbol: 'W', name: 'Wormhole', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6, coingeckoId: 'wormhole' },
|
||||
{ symbol: 'BONK', name: 'Bonk', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5, coingeckoId: 'bonk' },
|
||||
{ symbol: 'ORCA', name: 'Orca', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6, coingeckoId: 'orca' },
|
||||
{ symbol: 'PENGU', name: 'Pudgy Penguins', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6, coingeckoId: 'pudgy-penguins' },
|
||||
{ symbol: 'RAY', name: 'Raydium', mint: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6, coingeckoId: 'raydium' },
|
||||
];
|
||||
|
||||
const ALL_CHAINS_ORDERED: ChainCode[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL'];
|
||||
|
||||
/**
|
||||
* Whitelist of tokens которые реально bridgeable через наш Jumper/NearIntents/Relay path.
|
||||
* Если token не в этом set'е — UI dropdown'ы не показывают его (frontend filter via
|
||||
* `/api/tokens?chain=X&bridgeable=true`).
|
||||
*
|
||||
* Source: cross-reference нашего registry с (а) NearIntents /v0/tokens supported list
|
||||
* (167 assets), (б) LiFi major tokens (USDT/USDC/native + select DeFi), (в) Relay coverage.
|
||||
*
|
||||
* Tokens NOT here (потому что нет ликвидности в bridges):
|
||||
* - SOL: PUMP, JUP, POPCAT, PYTH, JTO, W, BONK, ORCA, RAY — memecoins / DeFi не в NearIntents/LiFi
|
||||
* - BSC: DOGE (BSC-wrapped), WBNB, BUSD — deprecated / no bridge
|
||||
*
|
||||
* Format: 'CHAIN:SYMBOL'. Native всегда included.
|
||||
*/
|
||||
const BRIDGEABLE_TOKENS: Set<string> = new Set([
|
||||
// ETH
|
||||
'ETH:ETH', 'ETH:USDT', 'ETH:USDC', 'ETH:DAI', 'ETH:LINK', 'ETH:UNI', 'ETH:WBTC',
|
||||
// BSC
|
||||
'BSC:BNB', 'BSC:USDT', 'BSC:USDC',
|
||||
// TRX
|
||||
'TRX:TRX', 'TRX:USDT',
|
||||
// SOL
|
||||
'SOL:SOL', 'SOL:USDT', 'SOL:USDC', 'SOL:WIF', 'SOL:TRUMP', 'SOL:PENGU',
|
||||
// BTC
|
||||
'BTC:BTC',
|
||||
]);
|
||||
|
||||
function isBridgeable(chain: ChainCode, symbol: string): boolean {
|
||||
return BRIDGEABLE_TOKENS.has(`${chain}:${symbol.toUpperCase()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает flat-list всех известных активов: native + tokens, для всех (или одного) chain.
|
||||
* Используется в GET /api/tokens.
|
||||
*
|
||||
* @param filterChain — если задан, фильтрует только этот chain
|
||||
* @param bridgeableOnly — если true, возвращает только tokens из `BRIDGEABLE_TOKENS` allowlist
|
||||
* (used by bridge/swap UI чтобы не показывать unsupported memecoins)
|
||||
*/
|
||||
export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean = false): TokenListEntry[] {
|
||||
const out: TokenListEntry[] = [];
|
||||
const chains: ChainCode[] = filterChain ? [filterChain] : ALL_CHAINS_ORDERED;
|
||||
|
||||
for (const chain of chains) {
|
||||
// Native first
|
||||
if (!bridgeableOnly || isBridgeable(chain, NATIVE_SYMBOLS[chain])) {
|
||||
const lifiAddress = LIFI_NATIVE_ADDRESS[chain];
|
||||
out.push({
|
||||
chain,
|
||||
symbol: NATIVE_SYMBOLS[chain],
|
||||
name: NATIVE_NAMES[chain],
|
||||
contract: null,
|
||||
decimals: NATIVE_DECIMALS_LOCAL[chain],
|
||||
...(lifiAddress ? { lifiAddress } : {}),
|
||||
});
|
||||
}
|
||||
// Tokens
|
||||
if (chain === 'ETH' || chain === 'BSC') {
|
||||
for (const tk of getEvmTokens(chain)) {
|
||||
if (bridgeableOnly && !isBridgeable(chain, tk.symbol)) continue;
|
||||
out.push({ chain, symbol: tk.symbol, name: tk.name, contract: tk.contractAddress, decimals: tk.decimals });
|
||||
}
|
||||
} else if (chain === 'TRX') {
|
||||
for (const tk of TRX_TOKENS) {
|
||||
if (bridgeableOnly && !isBridgeable(chain, tk.symbol)) continue;
|
||||
out.push({ chain, symbol: tk.symbol, name: tk.name, contract: tk.contractAddress, decimals: tk.decimals });
|
||||
}
|
||||
} else if (chain === 'SOL') {
|
||||
for (const tk of SOL_TOKENS) {
|
||||
if (bridgeableOnly && !isBridgeable(chain, tk.symbol)) continue;
|
||||
out.push({ chain, symbol: tk.symbol, name: tk.name, contract: tk.mint, decimals: tk.decimals });
|
||||
}
|
||||
}
|
||||
// BTC — только native (уже handled выше)
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-chain variant для `/api/tokens?chains=ETH,BSC,...`.
|
||||
* По умолчанию compact/bridgeable список, потому что endpoint используется UI dropdown'ами.
|
||||
*/
|
||||
export function getTokensForChains(
|
||||
filterChains?: ChainCode[],
|
||||
bridgeableOnly: boolean = true,
|
||||
): TokenListEntry[] {
|
||||
const chains = filterChains && filterChains.length > 0 ? filterChains : ALL_CHAINS_ORDERED;
|
||||
return chains.flatMap((chain) => getAllTokens(chain, bridgeableOnly));
|
||||
}
|
||||
|
||||
export function getEvmTokens(chain: ChainCode): EvmToken[] {
|
||||
if (chain === 'ETH') return ETH_TOKENS;
|
||||
if (chain === 'BSC') return BSC_TOKENS;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getTrxTokens(): TrxToken[] {
|
||||
return TRX_TOKENS;
|
||||
}
|
||||
|
||||
export function getSolTokens(): SolToken[] {
|
||||
return SOL_TOKENS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal lookup для send flow. Returns address+decimals или null если token не в registry.
|
||||
* Symbol comparison case-insensitive.
|
||||
*
|
||||
* Usage:
|
||||
* const info = getTokenInfo('BSC', 'USDC');
|
||||
* // → { address: '0x8AC76a51...', decimals: 18 }
|
||||
*
|
||||
* const info = getTokenInfo('SOL', 'USDT');
|
||||
* // → { address: 'Es9vMFrza...', decimals: 6 } (mint address)
|
||||
*/
|
||||
export function getTokenInfo(chain: ChainCode, symbol: string): { address: string; decimals: number } | null {
|
||||
const upper = String(symbol).toUpperCase();
|
||||
if (chain === 'ETH' || chain === 'BSC') {
|
||||
const t = getEvmTokens(chain).find((x) => x.symbol.toUpperCase() === upper);
|
||||
return t ? { address: t.contractAddress, decimals: t.decimals } : null;
|
||||
}
|
||||
if (chain === 'TRX') {
|
||||
const t = TRX_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
|
||||
return t ? { address: t.contractAddress, decimals: t.decimals } : null;
|
||||
}
|
||||
if (chain === 'SOL') {
|
||||
const t = SOL_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
|
||||
return t ? { address: t.mint, decimals: t.decimals } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the CoinGecko coin id for a given (chain, symbol) pair.
|
||||
*
|
||||
* Если `symbol` совпадает с самим именем chain (BTC/ETH/BSC/TRX/SOL) — возвращает
|
||||
* native id (`NATIVE_COINGECKO_IDS[chain]`).
|
||||
* В остальных случаях ищет токен в реестре сети и возвращает его `coingeckoId`.
|
||||
*
|
||||
* Возвращает `null` если:
|
||||
* - chain неизвестен;
|
||||
* - symbol не найден в реестре сети;
|
||||
* - токен найден, но `coingeckoId` для него не задан.
|
||||
*
|
||||
* Используется исключительно как whitelist для price oracle (см. S1 в плане):
|
||||
* никакой свободный user-input не попадает в CoinGecko URL.
|
||||
*/
|
||||
export function getCoingeckoId(chain: ChainCode, symbol: string): string | null {
|
||||
if (!chain) return null;
|
||||
const upper = String(symbol || '').toUpperCase();
|
||||
if (!upper) return null;
|
||||
|
||||
// Native — symbol === chain code (BTC, ETH, ...).
|
||||
if (upper === chain) return NATIVE_COINGECKO_IDS[chain] ?? null;
|
||||
|
||||
if (chain === 'ETH' || chain === 'BSC') {
|
||||
const t = getEvmTokens(chain).find((x) => x.symbol.toUpperCase() === upper);
|
||||
return t?.coingeckoId ?? null;
|
||||
}
|
||||
if (chain === 'TRX') {
|
||||
const t = TRX_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
|
||||
return t?.coingeckoId ?? null;
|
||||
}
|
||||
if (chain === 'SOL') {
|
||||
const t = SOL_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
|
||||
return t?.coingeckoId ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* JWT ↔ wallet-address binding.
|
||||
*
|
||||
* Защита от drain-вектора: проксируемые endpoint'ы (swap-build, relay-quote) принимают
|
||||
* `userAddress`/`recipient` как body param. Если не привязать к JWT-юзеру —
|
||||
* authenticated user A может set `userAddress=<user B's addr>` и:
|
||||
* - swap output идёт к B (бесплатный slippage steal)
|
||||
* - reentrancy: B's address — malicious contract, callback дрянит
|
||||
* - bridge `recipient=attacker` → victim signs → funds to attacker
|
||||
*
|
||||
* Этот хелпер находит wallet user'а по chain и проверяет match.
|
||||
*/
|
||||
|
||||
import { WalletModel } from '../models/wallet.model';
|
||||
import type { ChainCode } from './address-validators';
|
||||
|
||||
/**
|
||||
* Throws if address ≠ user's wallet для данного chain.
|
||||
* Returns the canonical (DB-stored) address для использования вместо user input.
|
||||
*
|
||||
* Сравнение case-insensitive только для EVM (где checksum mixed-case = same address).
|
||||
* Для BTC/TRX/SOL — strict equality (base58/bech32 case-sensitive).
|
||||
*/
|
||||
export async function assertUserOwnsAddress(
|
||||
userId: string,
|
||||
chain: ChainCode,
|
||||
candidateAddress: string,
|
||||
): Promise<string> {
|
||||
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
||||
if (!wallet) {
|
||||
throw new Error(`No ${chain} wallet for user — POST /wallets/create first`);
|
||||
}
|
||||
const isEvm = chain === 'ETH' || chain === 'BSC';
|
||||
const dbAddr = wallet.address;
|
||||
const candidate = String(candidateAddress ?? '').trim();
|
||||
const match = isEvm
|
||||
? candidate.toLowerCase() === dbAddr.toLowerCase()
|
||||
: candidate === dbAddr;
|
||||
if (!match) {
|
||||
throw new Error(`Address ${candidate} does not match user's ${chain} wallet ${dbAddr}`);
|
||||
}
|
||||
return dbAddr;
|
||||
}
|
||||
@@ -35,9 +35,7 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc
|
||||
req.auth = await verifyAccessToken(token);
|
||||
next();
|
||||
} catch (err: any) {
|
||||
// Лог детали server-side, клиенту — единое generic сообщение.
|
||||
// Иначе err.message distinguishes "expired" vs "bad signature" vs "kid unknown" → info oracle.
|
||||
logger.warn(`Auth failed: ${err.message}`);
|
||||
res.status(401).json({ success: false, error: 'Unauthorized' });
|
||||
res.status(err.status || 401).json({ success: false, error: err.message || 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,23 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
import { verifyCsrfToken, isCsrfConfigured, getCsrfConfigSummary } from '../services/csrf.service';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../lib/logger';
|
||||
import { verifyCsrfPair } from '../services/csrf.service';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
/**
|
||||
* CSRF middleware с double-submit pattern.
|
||||
*
|
||||
* Требует ОБА source'а: cookie `csrf_token` AND header `X-CSRF-Token`,
|
||||
* сравнивает их constant-time. Без обоих или при несовпадении — 403.
|
||||
*
|
||||
* Защита: если attacker украл только cookie (auto-sent при cross-site POST),
|
||||
* он не может выставить header X-CSRF-Token из чужого origin без CORS,
|
||||
* а CORS у нас явный whitelist. Single-source check был bypass'able.
|
||||
*/
|
||||
export function csrfMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
if (SAFE_METHODS.has(req.method)) {
|
||||
|
||||
export async function csrfProtect(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
if (!UNSAFE.has(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// CSRF полностью отключён через ENV (csrfPath пустой) — пропускаем явно.
|
||||
if (!env.vault.csrfPath) {
|
||||
const headerToken = req.get('X-CSRF-Token') ?? undefined;
|
||||
const cookieToken = req.cookies?.csrf_token as string | undefined;
|
||||
|
||||
try {
|
||||
await verifyCsrfPair(cookieToken, headerToken);
|
||||
next();
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'CSRF validation failed';
|
||||
res.status(403).json({ success: false, error: msg });
|
||||
}
|
||||
|
||||
// CSRF включён, но секрет не загружен → fail-secure 503.
|
||||
if (!isCsrfConfigured()) {
|
||||
logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request');
|
||||
res.status(503).json({ success: false, error: 'CSRF protection unavailable, retry later' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cookieToken = req.cookies?.csrf_token;
|
||||
const headerToken = req.headers['x-csrf-token'];
|
||||
|
||||
// Double-submit: ОБА обязательны.
|
||||
if (!cookieToken || typeof cookieToken !== 'string' ||
|
||||
!headerToken || typeof headerToken !== 'string') {
|
||||
res.status(403).json({ success: false, error: 'CSRF token missing (need cookie + header)' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Constant-time сравнение cookie === header (защита от timing oracle).
|
||||
const a = Buffer.from(cookieToken);
|
||||
const b = Buffer.from(headerToken);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
logger.warn('CSRF: cookie/header mismatch');
|
||||
res.status(403).json({ success: false, error: 'CSRF token mismatch' });
|
||||
return;
|
||||
}
|
||||
|
||||
// HMAC verify только после совпадения двух source'ов.
|
||||
const result = verifyCsrfToken(cookieToken);
|
||||
if (!result.valid) {
|
||||
const sigDiag =
|
||||
result.actualSigLen !== undefined && result.expectedSigLen !== undefined
|
||||
? ` (sigLen=${result.actualSigLen} expectedLen=${result.expectedSigLen})`
|
||||
: '';
|
||||
const cfg = getCsrfConfigSummary();
|
||||
const fp = cfg ? ` secret_fp=${cfg.secretFp} salt="${cfg.salt}" digest=${cfg.digest}` : '';
|
||||
logger.warn(`CSRF validation failed: ${result.reason}${sigDiag}${fp}`);
|
||||
res.status(403).json({ success: false, error: 'Invalid CSRF token' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -1,39 +1,7 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
interface HttpError extends Error {
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic error handler. Sanitization of err.message происходит в logger.
|
||||
* Клиенту НИКОГДА не отдаём raw err.message (может содержать sensitive data).
|
||||
*/
|
||||
export function errorHandler(err: HttpError, _req: Request, res: Response, _next: NextFunction): void {
|
||||
const status = err.status || err.statusCode || 500;
|
||||
|
||||
// Standard Express body-parser errors
|
||||
if (err.type === 'entity.too.large') {
|
||||
logger.warn(`Payload too large: ${err.message}`);
|
||||
res.status(413).json({ success: false, error: 'Payload too large' });
|
||||
return;
|
||||
}
|
||||
if (err.type === 'entity.parse.failed') {
|
||||
logger.warn(`Invalid JSON: ${err.message}`);
|
||||
res.status(400).json({ success: false, error: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Известные клиентские ошибки (4xx) — отдаём safe сообщение
|
||||
if (status >= 400 && status < 500) {
|
||||
logger.warn(`Client error ${status}: ${err.message}`);
|
||||
res.status(status).json({ success: false, error: 'Bad request' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Серверные ошибки (5xx) — generic message, детали (со stack) только в логи
|
||||
logger.error(`Server error: ${err.stack || err.message}`);
|
||||
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
|
||||
logger.error(err.message);
|
||||
res.status(500).json({ success: false, error: 'Internal server error' });
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Per-user rate limiting (если auth есть, то по userId; иначе по IP).
|
||||
* Защищает от brute force / DoS / API quota exhaustion.
|
||||
*/
|
||||
function keyByUserOrIp(req: Request, res: any): string {
|
||||
if (req.auth?.userId) return `user:${req.auth.userId}`;
|
||||
// ipKeyGenerator корректно нормализует IPv6 (по /64 префиксу)
|
||||
return ipKeyGenerator(req.ip || '');
|
||||
}
|
||||
|
||||
// Глобальный лимит на любые API запросы — защита от мусорного трафика
|
||||
export const globalLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: 120,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: keyByUserOrIp,
|
||||
message: { success: false, error: 'Too many requests' },
|
||||
});
|
||||
|
||||
// Жёсткий лимит на mutating операции с балансами/wallet
|
||||
export const mutateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: 30,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: keyByUserOrIp,
|
||||
message: { success: false, error: 'Too many mutating requests' },
|
||||
});
|
||||
|
||||
// Самый строгий — для send / wallet create
|
||||
export const sensitiveLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: 10,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: keyByUserOrIp,
|
||||
message: { success: false, error: 'Too many sensitive requests' },
|
||||
});
|
||||
|
||||
// Экстремально строгий — reveal seed phrase, 5/час
|
||||
export const mnemonicRevealLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
limit: 5,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: keyByUserOrIp,
|
||||
message: { success: false, error: 'Too many mnemonic reveal requests' },
|
||||
});
|
||||
@@ -2,15 +2,10 @@ import { Request, Response, NextFunction } from 'express';
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
import { traceStore } from '../lib/trace-store';
|
||||
|
||||
const TRACE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
|
||||
export function traceMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const supplied = (req.headers['x-trace-id'] || req.headers['x-request-id']) as string | undefined;
|
||||
|
||||
// Validate client-supplied trace-ID — иначе log injection / trace forgery
|
||||
const traceId = (typeof supplied === 'string' && TRACE_ID_RE.test(supplied))
|
||||
? supplied
|
||||
: generateUlid();
|
||||
const traceId = req.headers['x-trace-id'] as string
|
||||
|| req.headers['x-request-id'] as string
|
||||
|| generateUlid();
|
||||
|
||||
res.setHeader('X-Trace-ID', traceId);
|
||||
|
||||
|
||||
17
apps/api/src/middleware/validate.ts
Normal file
17
apps/api/src/middleware/validate.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ZodSchema } from 'zod';
|
||||
|
||||
export function validate(schema: ZodSchema) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error.errors.map((e) => e.message).join(', '),
|
||||
});
|
||||
return;
|
||||
}
|
||||
req.body = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
66
apps/api/src/models/session.model.ts
Normal file
66
apps/api/src/models/session.model.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { db } from '../config/database';
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
|
||||
export interface SessionRow {
|
||||
id: string;
|
||||
sid: string;
|
||||
user_id: string;
|
||||
device_id: string | null;
|
||||
user_agent: string | null;
|
||||
first_ip: string | null;
|
||||
last_ip: string | null;
|
||||
last_seen_at: Date | null;
|
||||
revoked_at: Date | null;
|
||||
refresh_jti_hash: string | null;
|
||||
refresh_expires_at: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export const SessionModel = {
|
||||
async findBySid(sid: string): Promise<SessionRow | undefined> {
|
||||
return db('sessions').where({ sid }).whereNull('revoked_at').first();
|
||||
},
|
||||
|
||||
async findByUserId(userId: string): Promise<SessionRow[]> {
|
||||
return db('sessions').where({ user_id: userId }).whereNull('revoked_at');
|
||||
},
|
||||
|
||||
async create(data: {
|
||||
sid: string;
|
||||
user_id: string;
|
||||
device_id?: string;
|
||||
user_agent?: string;
|
||||
first_ip?: string;
|
||||
refresh_jti_hash?: string;
|
||||
refresh_expires_at?: Date;
|
||||
}): Promise<SessionRow> {
|
||||
const [session] = await db('sessions')
|
||||
.insert({
|
||||
id: generateUlid(),
|
||||
...data,
|
||||
last_ip: data.first_ip || null,
|
||||
})
|
||||
.returning('*');
|
||||
return session;
|
||||
},
|
||||
|
||||
async revoke(sid: string): Promise<void> {
|
||||
await db('sessions')
|
||||
.where({ sid })
|
||||
.update({ revoked_at: db.fn.now(), updated_at: db.fn.now() });
|
||||
},
|
||||
|
||||
async revokeAllForUser(userId: string): Promise<void> {
|
||||
await db('sessions')
|
||||
.where({ user_id: userId })
|
||||
.whereNull('revoked_at')
|
||||
.update({ revoked_at: db.fn.now(), updated_at: db.fn.now() });
|
||||
},
|
||||
|
||||
async updateLastSeen(sid: string, ip: string): Promise<void> {
|
||||
await db('sessions')
|
||||
.where({ sid })
|
||||
.update({ last_seen_at: db.fn.now(), last_ip: ip, updated_at: db.fn.now() });
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Knex } from 'knex';
|
||||
import { db } from '../config/database';
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
@@ -9,168 +9,41 @@ export interface UserRow {
|
||||
first_name: string | null;
|
||||
middle_name: string | null;
|
||||
birth_date: string | null;
|
||||
crypto_wallet: string | null; // DEPRECATED — оставлено для backward-compat. ETH = users.erc20.
|
||||
crypto_wallet: string | null;
|
||||
phone: string | null;
|
||||
bik: string | null;
|
||||
account_number: string | null;
|
||||
card_number: string | null;
|
||||
inn: string | null;
|
||||
kyc_verified: boolean;
|
||||
kyc_verified_at: Date | null;
|
||||
is_deleted: boolean;
|
||||
passport_data: string | null;
|
||||
erc20: string | null; // ETH-адрес кастодиального кошелька (заполняется при /wallets/create)
|
||||
encrypted_mnemonic: string | null; // AES-GCM blob (custodial). Extension над user-supplied schema.
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
// Public-safe subset, без encrypted_* / password_hash.
|
||||
// Используется когда results могут попасть в response.
|
||||
const PUBLIC_USER_COLUMNS = [
|
||||
'id',
|
||||
'email',
|
||||
'last_name',
|
||||
'first_name',
|
||||
'middle_name',
|
||||
'birth_date',
|
||||
'phone',
|
||||
'kyc_verified',
|
||||
'kyc_verified_at',
|
||||
'is_deleted',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
] as const;
|
||||
|
||||
export const UserModel = {
|
||||
/**
|
||||
* Public-safe lookup. НЕ возвращает encrypted_* / password_hash.
|
||||
*/
|
||||
async findById(id: string) {
|
||||
return db('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.select(...PUBLIC_USER_COLUMNS)
|
||||
.first();
|
||||
async findByEmail(email: string): Promise<UserRow | undefined> {
|
||||
return db('users').where({ email, is_deleted: false }).first();
|
||||
},
|
||||
|
||||
/**
|
||||
* Создать запись пользователя если её нет.
|
||||
* id — из JWT.sub (валидируется на JWT verify).
|
||||
*/
|
||||
async ensureExists(id: string): Promise<void> {
|
||||
await db('users')
|
||||
.insert({
|
||||
id,
|
||||
email: `${id}@elcsa.local`,
|
||||
password_hash: 'EXTERNAL_AUTH',
|
||||
})
|
||||
.onConflict('id')
|
||||
.ignore();
|
||||
async findById(id: string): Promise<UserRow | undefined> {
|
||||
return db('users').where({ id, is_deleted: false }).first();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set-once: возвращает 'claimed' / 'already_has' / 'no_user' (H32).
|
||||
* Defense-in-depth: distinguishes wrong outcomes так что caller отдаёт правильный код:
|
||||
* - claimed → каждый параллельный call'у вернёт already_has потом (мы выиграли gонку)
|
||||
* - already_has → existing encrypted_mnemonic в DB
|
||||
* - no_user → row not found OR is_deleted=true
|
||||
*/
|
||||
async setEncryptedMnemonicIfAbsent(
|
||||
id: string,
|
||||
blob: string,
|
||||
trx?: Knex.Transaction,
|
||||
): Promise<'claimed' | 'already_has' | 'no_user'> {
|
||||
const k = trx || db;
|
||||
const affected = await k('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.whereNull('encrypted_mnemonic')
|
||||
.update({
|
||||
encrypted_mnemonic: blob,
|
||||
updated_at: k.fn.now(),
|
||||
});
|
||||
if (affected === 1) return 'claimed';
|
||||
// Affected 0 — либо user gone, либо уже есть mnemonic. Distinguish.
|
||||
const row = await k('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.select('encrypted_mnemonic')
|
||||
.first();
|
||||
if (!row) return 'no_user';
|
||||
return row.encrypted_mnemonic ? 'already_has' : 'no_user';
|
||||
async create(data: {
|
||||
email: string;
|
||||
password_hash: string;
|
||||
}): Promise<UserRow> {
|
||||
const [user] = await db('users').insert({ id: generateUlid(), ...data }).returning('*');
|
||||
return user;
|
||||
},
|
||||
|
||||
/**
|
||||
* Claim placeholder row перед derive — экономит CPU + heap-secret для loser race.
|
||||
* Используется как pre-step в createWallet flow (H27).
|
||||
*/
|
||||
async claimWalletSlot(id: string, trx?: Knex.Transaction): Promise<'claimed' | 'already_has' | 'no_user'> {
|
||||
const k = trx || db;
|
||||
const PLACEHOLDER = 'PENDING_DERIVATION';
|
||||
const affected = await k('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.whereNull('encrypted_mnemonic')
|
||||
.update({
|
||||
encrypted_mnemonic: PLACEHOLDER,
|
||||
updated_at: k.fn.now(),
|
||||
});
|
||||
if (affected === 1) return 'claimed';
|
||||
const row = await k('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.select('encrypted_mnemonic')
|
||||
.first();
|
||||
if (!row) return 'no_user';
|
||||
return row.encrypted_mnemonic && row.encrypted_mnemonic !== PLACEHOLDER ? 'already_has' : 'no_user';
|
||||
},
|
||||
|
||||
/** Finalize after claimWalletSlot — overwrite placeholder с real blob. */
|
||||
async finalizeWalletSlot(id: string, blob: string, trx?: Knex.Transaction): Promise<void> {
|
||||
const k = trx || db;
|
||||
const affected = await k('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.update({
|
||||
encrypted_mnemonic: blob,
|
||||
updated_at: k.fn.now(),
|
||||
});
|
||||
if (affected !== 1) {
|
||||
throw new Error(`finalizeWalletSlot: expected 1 row affected, got ${affected} for user ${id}`);
|
||||
}
|
||||
},
|
||||
|
||||
/** Check KYC status (H14) */
|
||||
async isKycVerified(id: string): Promise<boolean> {
|
||||
const row = await db('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.select('kyc_verified', 'kyc_verified_at')
|
||||
.first();
|
||||
return Boolean(row?.kyc_verified && row?.kyc_verified_at);
|
||||
},
|
||||
|
||||
async getEncryptedMnemonic(id: string): Promise<string | null> {
|
||||
const row = await db('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.select('encrypted_mnemonic')
|
||||
.first();
|
||||
return row?.encrypted_mnemonic ?? null;
|
||||
},
|
||||
|
||||
async hasMnemonic(id: string): Promise<boolean> {
|
||||
const row = await db('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.select(db.raw('encrypted_mnemonic IS NOT NULL AS has'))
|
||||
.first();
|
||||
return Boolean(row?.has);
|
||||
},
|
||||
|
||||
/**
|
||||
* Записать ETH-адрес custodial-кошелька в users.erc20.
|
||||
* Throws (rolls back tx) if user не существует / is_deleted (H31).
|
||||
*/
|
||||
async setErc20Address(id: string, address: string, trx?: Knex.Transaction): Promise<void> {
|
||||
const k = trx || db;
|
||||
const affected = await k('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.update({
|
||||
erc20: address,
|
||||
updated_at: k.fn.now(),
|
||||
});
|
||||
if (affected !== 1) {
|
||||
throw new Error(`setErc20Address: user ${id} not found or deleted (affected=${affected})`);
|
||||
}
|
||||
async update(id: string, data: Partial<Omit<UserRow, 'id' | 'created_at'>>): Promise<UserRow | undefined> {
|
||||
const [user] = await db('users')
|
||||
.where({ id })
|
||||
.update({ ...data, updated_at: db.fn.now() })
|
||||
.returning('*');
|
||||
return user;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Knex } from 'knex';
|
||||
import { db } from '../config/database';
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
|
||||
@@ -16,21 +15,10 @@ export const WalletModel = {
|
||||
return db('wallets').where({ user_id: userId });
|
||||
},
|
||||
|
||||
async findByUserAndChain(userId: string, chain: string): Promise<WalletRow | undefined> {
|
||||
return db('wallets').where({ user_id: userId, chain }).first();
|
||||
},
|
||||
|
||||
/**
|
||||
* Insert wallets. UNIQUE(user_id, chain) на уровне DB предотвращает дубликаты —
|
||||
* на конфликт kicks transaction rollback.
|
||||
* Используется только из createWallet (custodial bootstrap, one-shot per user).
|
||||
* НЕ используем upsertMany — нет легитимного пути менять адрес после генерации.
|
||||
*/
|
||||
async createMany(
|
||||
wallets: { user_id: string; chain: string; address: string; derivation_path: string }[],
|
||||
trx?: Knex.Transaction,
|
||||
wallets: { user_id: string; chain: string; address: string; derivation_path: string }[]
|
||||
): Promise<WalletRow[]> {
|
||||
const withIds = wallets.map((w) => ({ id: generateUlid(), ...w }));
|
||||
return (trx || db)('wallets').insert(withIds).returning('*');
|
||||
return db('wallets').insert(withIds).returning('*');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
/**
|
||||
* Unified bridge execute endpoint — one-click "Подтвердить" для bridge через Jumper/Relay.
|
||||
*
|
||||
* Single endpoint POST /api/bridge/execute:
|
||||
* - JWT-bind: fromAddress ≡ user's wallet для source chain (защита от submitting attacker's address)
|
||||
* - Idempotency-Key: anti-double-spend на retry
|
||||
* - Anti-MEV: server повторно квотирует и проверяет toAmountMin ≥ acceptedMinOut
|
||||
* - Audit log: каждый execute = row в audit_log с txid'ами
|
||||
* - Dispatch к executeBridge() который сам выбирает signing path per chain
|
||||
*
|
||||
* Mount: `app.use('/api/bridge', ...protect, mutateLimiter, bridgeRoutes)` в app.ts.
|
||||
*/
|
||||
|
||||
import { Request, Response, Router } from 'express';
|
||||
import { logger } from '../lib/logger';
|
||||
import { WalletModel } from '../models/wallet.model';
|
||||
import { UserModel } from '../models/user.model';
|
||||
import { decryptMnemonic } from '../services/crypto.service';
|
||||
import { auditLog, auditLogStrict, completeAudit } from '../lib/audit-log';
|
||||
import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency';
|
||||
import { executeBridge, type BridgeProvider } from '../services/bridge-execute.service';
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// LiFi/Relay chainId → наш ChainCode. Source chain должен быть из этого map'а
|
||||
// для JWT-bind'а. Destination chain — без ограничений (bridge solver сам доставит куда угодно).
|
||||
const CHAINID_TO_CHAIN: Record<number, ChainCode> = {
|
||||
1: 'ETH',
|
||||
56: 'BSC',
|
||||
1151111081099710: 'SOL',
|
||||
792703809: 'SOL',
|
||||
728126428: 'TRX',
|
||||
20000000000001: 'BTC',
|
||||
8253038: 'BTC',
|
||||
};
|
||||
|
||||
router.post('/execute', executeHandler);
|
||||
|
||||
export default router;
|
||||
|
||||
async function executeHandler(req: Request, res: Response): Promise<void> {
|
||||
const userId = (req as any).auth?.userId;
|
||||
if (!userId) {
|
||||
res.status(401).json({ success: false, error: 'auth required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 1. Parse + validate body ──
|
||||
const body = req.body || {};
|
||||
const provider = String(body.provider || '').toLowerCase() as BridgeProvider;
|
||||
if (provider !== 'jumper' && provider !== 'relay') {
|
||||
res.status(400).json({ success: false, error: 'provider must be "jumper" or "relay"' });
|
||||
return;
|
||||
}
|
||||
|
||||
const fromChain = Number(body.fromChain);
|
||||
const toChain = Number(body.toChain);
|
||||
const fromToken = String(body.fromToken || '');
|
||||
const toToken = String(body.toToken || '');
|
||||
const fromAmount = String(body.fromAmount || '');
|
||||
const fromAddress = String(body.fromAddress || '');
|
||||
const toAddress = String(body.toAddress || '');
|
||||
const acceptedMinOut = String(body.acceptedMinOut || '0');
|
||||
|
||||
if (!Number.isFinite(fromChain) || !Number.isFinite(toChain)) {
|
||||
res.status(400).json({ success: false, error: 'fromChain/toChain must be numeric' });
|
||||
return;
|
||||
}
|
||||
if (!fromToken || !toToken) {
|
||||
res.status(400).json({ success: false, error: 'fromToken/toToken required' });
|
||||
return;
|
||||
}
|
||||
if (!/^\d+$/.test(fromAmount) || fromAmount === '0') {
|
||||
res.status(400).json({ success: false, error: 'fromAmount must be positive integer string (smallest units)' });
|
||||
return;
|
||||
}
|
||||
if (!/^\d+$/.test(acceptedMinOut)) {
|
||||
res.status(400).json({ success: false, error: 'acceptedMinOut must be integer string' });
|
||||
return;
|
||||
}
|
||||
if (!fromAddress || !toAddress) {
|
||||
res.status(400).json({ success: false, error: 'fromAddress/toAddress required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const BTC_NATIVE_FROM_TOKENS = new Set(['bitcoin', 'bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8']);
|
||||
if (CHAINID_TO_CHAIN[fromChain] === 'BTC' && !BTC_NATIVE_FROM_TOKENS.has(fromToken)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'BTC bridge supports native only: fromToken must be "bitcoin" (LiFi sentinel)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 2. JWT-bind: fromAddress = user's source-chain wallet ──
|
||||
const sourceCode = CHAINID_TO_CHAIN[fromChain];
|
||||
if (!sourceCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Unsupported source chainId ${fromChain} (allowed: 1, 56, 1151111081099710, 792703809, 728126428, 20000000000001, 8253038)`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const fromWallet = await WalletModel.findByUserAndChain(userId, sourceCode);
|
||||
if (!fromWallet) {
|
||||
res.status(403).json({ success: false, error: `No ${sourceCode} wallet for user — create wallet first` });
|
||||
return;
|
||||
}
|
||||
const isEvm = sourceCode === 'ETH' || sourceCode === 'BSC';
|
||||
const fromMatch = isEvm
|
||||
? fromAddress.toLowerCase() === fromWallet.address.toLowerCase()
|
||||
: fromAddress === fromWallet.address;
|
||||
if (!fromMatch) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: `fromAddress ${fromAddress} ≠ user's ${sourceCode} wallet ${fromWallet.address}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// toAddress: если destination chain в нашем DB → bind. Иначе — skip (LiFi бридж в Avalanche/Polygon etc.)
|
||||
const destCode = CHAINID_TO_CHAIN[toChain];
|
||||
let expectedToAddress = toAddress; // default = client-provided (для unsupported chains)
|
||||
if (destCode) {
|
||||
const toWallet = await WalletModel.findByUserAndChain(userId, destCode);
|
||||
if (toWallet) {
|
||||
const destEvm = destCode === 'ETH' || destCode === 'BSC';
|
||||
const toMatch = destEvm
|
||||
? toAddress.toLowerCase() === toWallet.address.toLowerCase()
|
||||
: toAddress === toWallet.address;
|
||||
if (!toMatch) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: `toAddress ${toAddress} ≠ user's ${destCode} wallet ${toWallet.address}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
expectedToAddress = toWallet.address;
|
||||
} else {
|
||||
logger.warn(`Bridge execute: dest chain ${destCode} not in user wallets — skip dest bind`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Idempotency claim ──
|
||||
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
|
||||
if (idempKey) {
|
||||
try {
|
||||
const claim = await claimIdempotency(userId, idempKey, body);
|
||||
if (!claim.fresh && claim.cached) {
|
||||
res.status(claim.cached.status).type('application/json').send(claim.cached.body);
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(409).json({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Audit row BEFORE broadcast (strict — must succeed) ──
|
||||
let auditId: string;
|
||||
try {
|
||||
auditId = await auditLogStrict({
|
||||
event: 'bridge.execute',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
meta: {
|
||||
provider,
|
||||
fromChain,
|
||||
toChain,
|
||||
fromToken,
|
||||
toToken,
|
||||
fromAmount,
|
||||
acceptedMinOut,
|
||||
},
|
||||
});
|
||||
} catch (auditErr: any) {
|
||||
logger.error(`Audit DB INSERT MUST succeed for bridge.execute: ${auditErr.message}`);
|
||||
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 5. Decrypt mnemonic ──
|
||||
const blob = await UserModel.getEncryptedMnemonic(userId);
|
||||
if (!blob) {
|
||||
await completeAudit(auditId, 'failure', undefined, 'NO_MNEMONIC');
|
||||
res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' });
|
||||
return;
|
||||
}
|
||||
let mnemonic: string;
|
||||
try {
|
||||
mnemonic = decryptMnemonic(blob);
|
||||
} catch (err: any) {
|
||||
await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED');
|
||||
res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 6. Execute bridge (dispatcher inside) ──
|
||||
try {
|
||||
const result = await executeBridge({
|
||||
provider,
|
||||
fromChain,
|
||||
toChain,
|
||||
fromToken,
|
||||
toToken,
|
||||
fromAmount,
|
||||
fromAddress: fromWallet.address,
|
||||
toAddress,
|
||||
acceptedMinOut,
|
||||
mnemonic,
|
||||
expectedFromAddress: fromWallet.address,
|
||||
expectedToAddress,
|
||||
});
|
||||
|
||||
await completeAudit(auditId, 'success');
|
||||
// Best-effort: extra audit row с txid'ами для удобства audit reports
|
||||
auditLog({
|
||||
event: 'bridge.execute.broadcast',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'success',
|
||||
meta: {
|
||||
provider,
|
||||
fromChain,
|
||||
toChain,
|
||||
approveTxid: result.approveTxid,
|
||||
feeTxid: result.feeTxid,
|
||||
bridgeTxid: result.bridgeTxid,
|
||||
toolName: result.toolName,
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
const respBody = { success: true, data: result };
|
||||
if (idempKey) {
|
||||
try {
|
||||
await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(respBody));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
res.status(200).json(respBody);
|
||||
} catch (err: any) {
|
||||
const code =
|
||||
err?.code === 'PRICE_MOVED' ? 409 :
|
||||
err?.code === 'INSUFFICIENT_BALANCE' ? 400 :
|
||||
err?.code === 'SIMULATION_FAILED' ? 400 :
|
||||
err?.code === 'NO_ROUTE' ? 400 :
|
||||
err?.code === 'NOT_IMPLEMENTED' ? 501 :
|
||||
502;
|
||||
await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80));
|
||||
logger.warn(`Bridge execute failed: provider=${provider} fromChain=${fromChain} → ${err?.message}`);
|
||||
const respBody = {
|
||||
success: false,
|
||||
error: err?.message || 'bridge execute failed',
|
||||
code: err?.code,
|
||||
};
|
||||
if (idempKey) {
|
||||
try {
|
||||
await saveIdempotencyResponse(userId, idempKey, code, JSON.stringify(respBody));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
res.status(code).json(respBody);
|
||||
} finally {
|
||||
// Zeroize sensitive — best effort на JS strings (mostly cosmetic, real protection = process exit)
|
||||
mnemonic = '';
|
||||
}
|
||||
}
|
||||
192
apps/api/src/routes/bsc-swap-proxy.routes.ts
Normal file
192
apps/api/src/routes/bsc-swap-proxy.routes.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Request, Response, Router } from 'express';
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const BSC_RPC = 'https://bsc-dataseed.binance.org';
|
||||
const BSC_CHAIN_ID = 56;
|
||||
const BSC_TIMEOUT_MS = 15_000;
|
||||
|
||||
// PancakeSwap V2 Router
|
||||
const PANCAKE_ROUTER = '0x10ED43C718714eb63d5aA57B78B54704E256024E';
|
||||
const WBNB = '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c';
|
||||
|
||||
// Supported tokens
|
||||
const TOKEN_MAP: Record<string, string> = {
|
||||
BNB: WBNB,
|
||||
USDT: '0x55d398326f99059fF775485246999027B3197955',
|
||||
DOGE: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
|
||||
};
|
||||
|
||||
const TOKEN_DECIMALS: Record<string, number> = {
|
||||
BNB: 18,
|
||||
USDT: 18,
|
||||
DOGE: 8,
|
||||
};
|
||||
|
||||
const ROUTER_ABI = [
|
||||
'function getAmountsOut(uint amountIn, address[] calldata path) external view returns (uint[] memory amounts)',
|
||||
'function swapExactETHForTokensSupportingFeeOnTransferTokens(uint amountOutMin, address[] calldata path, address to, uint deadline) external payable',
|
||||
'function swapExactTokensForETHSupportingFeeOnTransferTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external',
|
||||
];
|
||||
|
||||
const ERC20_ABI = [
|
||||
'function approve(address spender, uint256 amount) external returns (bool)',
|
||||
'function allowance(address owner, address spender) external view returns (uint256)',
|
||||
];
|
||||
|
||||
router.get('/quote', getSwapQuote);
|
||||
router.post('/build', buildSwapTx);
|
||||
|
||||
export default router;
|
||||
|
||||
// ─── GET /quote ───
|
||||
|
||||
async function getSwapQuote(req: Request, res: Response) {
|
||||
const from = String(req.query.from || '').toUpperCase();
|
||||
const to = String(req.query.to || '').toUpperCase();
|
||||
const amount = String(req.query.amount || '');
|
||||
|
||||
if (!TOKEN_MAP[from] || !TOKEN_MAP[to]) {
|
||||
res.status(400).json({ success: false, error: 'Invalid from/to. Supported: BNB, USDT, DOGE' });
|
||||
return;
|
||||
}
|
||||
if (from === to) {
|
||||
res.status(400).json({ success: false, error: 'from and to must be different' });
|
||||
return;
|
||||
}
|
||||
|
||||
const amountBigInt = BigInt(amount || '0');
|
||||
if (amountBigInt <= 0n) {
|
||||
res.status(400).json({ success: false, error: 'amount must be positive' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = new ethers.providers.StaticJsonRpcProvider(BSC_RPC, BSC_CHAIN_ID);
|
||||
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
|
||||
|
||||
const path = [TOKEN_MAP[from], TOKEN_MAP[to]];
|
||||
const amounts: ethers.BigNumber[] = await withTimeout(
|
||||
routerContract.getAmountsOut(amount, path),
|
||||
BSC_TIMEOUT_MS,
|
||||
'PancakeSwap quote timed out'
|
||||
);
|
||||
|
||||
const amountOut = amounts[amounts.length - 1].toString();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
amountIn: amountBigInt.toString(),
|
||||
amountOut,
|
||||
from,
|
||||
to,
|
||||
fromDecimals: TOKEN_DECIMALS[from],
|
||||
toDecimals: TOKEN_DECIMALS[to],
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to get BSC swap quote';
|
||||
res.status(502).json({ success: false, error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST /build ───
|
||||
|
||||
async function buildSwapTx(req: Request, res: Response) {
|
||||
const { from, to, amount, amountOutMin, userAddress } = req.body;
|
||||
|
||||
if (!from || !to || !amount || !amountOutMin || !userAddress) {
|
||||
res.status(400).json({ success: false, error: 'Missing required fields: from, to, amount, amountOutMin, userAddress' });
|
||||
return;
|
||||
}
|
||||
|
||||
const fromUpper = String(from).toUpperCase();
|
||||
const toUpper = String(to).toUpperCase();
|
||||
|
||||
if (!TOKEN_MAP[fromUpper] || !TOKEN_MAP[toUpper] || fromUpper === toUpper) {
|
||||
res.status(400).json({ success: false, error: 'Invalid from/to pair' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ethers.utils.isAddress(userAddress)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid BSC address' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = new ethers.providers.StaticJsonRpcProvider(BSC_RPC, BSC_CHAIN_ID);
|
||||
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
|
||||
const deadline = Math.floor(Date.now() / 1000) + 1200; // 20 minutes
|
||||
const path = [TOKEN_MAP[fromUpper], TOKEN_MAP[toUpper]];
|
||||
|
||||
const transactions: Array<{ type: string; to: string; data: string; value: string }> = [];
|
||||
|
||||
if (fromUpper === 'BNB') {
|
||||
// BNB → Token: swapExactETHForTokensSupportingFeeOnTransferTokens
|
||||
const data = routerContract.interface.encodeFunctionData(
|
||||
'swapExactETHForTokensSupportingFeeOnTransferTokens',
|
||||
[amountOutMin, path, userAddress, deadline]
|
||||
);
|
||||
|
||||
transactions.push({
|
||||
type: 'swap',
|
||||
to: PANCAKE_ROUTER,
|
||||
data,
|
||||
value: amount, // BNB amount in wei
|
||||
});
|
||||
} else {
|
||||
// Token → BNB: check allowance, build approve if needed, then swap
|
||||
const tokenAddress = TOKEN_MAP[fromUpper];
|
||||
const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
|
||||
const currentAllowance: ethers.BigNumber = await withTimeout(
|
||||
tokenContract.allowance(userAddress, PANCAKE_ROUTER),
|
||||
BSC_TIMEOUT_MS,
|
||||
'Allowance check timed out'
|
||||
);
|
||||
|
||||
if (currentAllowance.lt(ethers.BigNumber.from(amount))) {
|
||||
// Build approve tx
|
||||
const approveData = tokenContract.interface.encodeFunctionData(
|
||||
'approve',
|
||||
[PANCAKE_ROUTER, ethers.constants.MaxUint256]
|
||||
);
|
||||
|
||||
transactions.push({
|
||||
type: 'approve',
|
||||
to: tokenAddress,
|
||||
data: approveData,
|
||||
value: '0',
|
||||
});
|
||||
}
|
||||
|
||||
// Build swap tx
|
||||
const swapData = routerContract.interface.encodeFunctionData(
|
||||
'swapExactTokensForETHSupportingFeeOnTransferTokens',
|
||||
[amount, amountOutMin, path, userAddress, deadline]
|
||||
);
|
||||
|
||||
transactions.push({
|
||||
type: 'swap',
|
||||
to: PANCAKE_ROUTER,
|
||||
data: swapData,
|
||||
value: '0',
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, transactions });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to build BSC swap';
|
||||
res.status(502).json({ success: false, error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Utils ───
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
promise
|
||||
.then((value) => { clearTimeout(timeoutId); resolve(value); })
|
||||
.catch((error) => { clearTimeout(timeoutId); reject(error); });
|
||||
});
|
||||
}
|
||||
@@ -111,8 +111,7 @@ async function getFeeEstimates(_req: Request, res: Response) {
|
||||
async function broadcastTx(req: Request, res: Response) {
|
||||
const { hex } = req.body;
|
||||
|
||||
// BTC max tx serialized ~100KB = 200_000 hex chars. Cap чтобы не abuse'или bandwidth.
|
||||
if (!hex || typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex) || hex.length > 200_000) {
|
||||
if (!hex || typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid transaction hex' });
|
||||
return;
|
||||
}
|
||||
@@ -131,8 +130,7 @@ async function broadcastTx(req: Request, res: Response) {
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
// Don't leak Blockstream error body (could contain UTXO state oracle).
|
||||
res.status(502).json({ success: false, error: 'BTC broadcast failed' });
|
||||
res.status(response.status).json({ success: false, error: text || 'Broadcast failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
41
apps/api/src/routes/csrf.routes.ts
Normal file
41
apps/api/src/routes/csrf.routes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { env } from '../config/env';
|
||||
import {
|
||||
CSRF_COOKIE_NAME,
|
||||
CSRF_HEADER_NAME,
|
||||
issueCsrfToken,
|
||||
getCsrfCookieMaxAgeMs,
|
||||
} from '../services/csrf.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/token', async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const token = await issueCsrfToken();
|
||||
const c = env.csrf;
|
||||
const sameSiteRaw = (c.cookieSameSite || 'Lax').toLowerCase();
|
||||
const sameSite = (sameSiteRaw === 'strict' || sameSiteRaw === 'none' ? sameSiteRaw : 'lax') as
|
||||
| 'strict'
|
||||
| 'lax'
|
||||
| 'none';
|
||||
res.cookie(CSRF_COOKIE_NAME, token, {
|
||||
secure: c.cookieSecure,
|
||||
httpOnly: c.cookieHttpOnly,
|
||||
sameSite,
|
||||
path: c.cookiePath || '/',
|
||||
maxAge: getCsrfCookieMaxAgeMs(),
|
||||
...(c.cookieDomain ? { domain: c.cookieDomain } : {}),
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
token,
|
||||
header_name: CSRF_HEADER_NAME,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,456 +0,0 @@
|
||||
/**
|
||||
* Jumper.xyz bridge proxy — forward к LiFi API (li.quest/v1).
|
||||
*
|
||||
* Jumper.xyz использует LiFi как routing engine. Поддерживает bridges/swaps между 50+ chains
|
||||
* включая TRX, BTC, ETH, BSC, SOL — те которые наш Relay proxy не поддерживает (TRX/BTC).
|
||||
*
|
||||
* Pattern идентичен `relay-proxy.routes.ts`:
|
||||
* - Whitelist allowed paths (path traversal guard).
|
||||
* - JWT-binding: `body.fromAddress` (POST) или `?fromAddress` (GET) должен совпадать с
|
||||
* user's wallet на `fromChain` — если этот chain известен в нашем DB. Иначе skip bind.
|
||||
* - Outbound через `proxiedFetch` (если задан OUTBOUND_PROXY_URL).
|
||||
* - Force JSON content-type на response (anti-XSS).
|
||||
* - Все upstream errors прокидываются клиенту с structured envelope.
|
||||
*
|
||||
* Mount: `app.use('/api/jumper', ...protect, mutateLimiter, jumperRoutes)` в app.ts.
|
||||
*/
|
||||
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { logger } from '../lib/logger';
|
||||
import { WalletModel } from '../models/wallet.model';
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
import { proxiedFetch } from '../lib/outbound-proxy';
|
||||
import { getTokensForChains } from '../lib/token-registry';
|
||||
|
||||
const router = Router();
|
||||
const LIFI_API_URL = 'https://li.quest/v1';
|
||||
const LIFI_TIMEOUT_MS = 20_000;
|
||||
|
||||
/**
|
||||
* LiFi chainIds → наш ChainCode. LiFi использует custom IDs для не-EVM:
|
||||
* - SOL: 1151111081099710 (КАРДИНАЛЬНО отличается от Relay's 792703809)
|
||||
* - TRX: 728126428 (стандартный Tron chainId)
|
||||
* - BTC: 20000000000001 (LiFi custom)
|
||||
* EVM как обычно: ETH=1, BSC=56.
|
||||
*
|
||||
* Если в `body.fromChain` придёт что-то НЕ из этого map'а — bind skip
|
||||
* (LiFi поддерживает 50+ chains, у нас wallet'ы только для 5).
|
||||
*/
|
||||
const JUMPER_CHAINID_TO_CHAIN: Record<number, ChainCode> = {
|
||||
1: 'ETH',
|
||||
56: 'BSC',
|
||||
1151111081099710: 'SOL',
|
||||
728126428: 'TRX',
|
||||
20000000000001: 'BTC',
|
||||
};
|
||||
|
||||
const ALLOWED_JUMPER_CHAIN_IDS = new Set(Object.keys(JUMPER_CHAINID_TO_CHAIN).map(Number));
|
||||
const JUMPER_CHAIN_BY_CODE: Partial<Record<ChainCode, number>> = Object.entries(JUMPER_CHAINID_TO_CHAIN)
|
||||
.reduce((acc, [chainId, code]) => ({ ...acc, [code]: Number(chainId) }), {});
|
||||
const JUMPER_NATIVE_SENTINELS: Partial<Record<ChainCode, string>> = {
|
||||
ETH: '0x0000000000000000000000000000000000000000',
|
||||
BSC: '0x0000000000000000000000000000000000000000',
|
||||
SOL: '11111111111111111111111111111111',
|
||||
TRX: 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb',
|
||||
BTC: 'bitcoin',
|
||||
};
|
||||
const LOCAL_JUMPER_CHAINS = [
|
||||
{ key: 'eth', chainType: 'EVM', name: 'Ethereum', coin: 'ETH', id: 1, mainnet: true },
|
||||
{ key: 'bsc', chainType: 'EVM', name: 'BSC', coin: 'BNB', id: 56, mainnet: true },
|
||||
{ key: 'sol', chainType: 'SVM', name: 'Solana', coin: 'SOL', id: 1151111081099710, mainnet: true },
|
||||
{ key: 'trx', chainType: 'TVM', name: 'Tron', coin: 'TRX', id: 728126428, mainnet: true },
|
||||
{ key: 'btc', chainType: 'UTXO', name: 'Bitcoin', coin: 'BTC', id: 20000000000001, mainnet: true },
|
||||
];
|
||||
|
||||
// Whitelist: GET-paths + POST-paths. Запрещён любой другой путь (path traversal).
|
||||
const ALLOWED_GET_PATHS = new Set([
|
||||
'/quote', // single best route
|
||||
'/status', // bridge intent status poll
|
||||
'/chains', // list supported chains
|
||||
'/tools', // list supported bridges/exchanges
|
||||
'/tokens', // list supported tokens
|
||||
'/connections', // routes между конкретной парой
|
||||
'/quote-best', // LOCAL alias — пробует NearIntents, fallback на best route
|
||||
]);
|
||||
const ALLOWED_POST_PATHS = new Set([
|
||||
'/advanced/routes', // multi-route preview (POST body со всем routing prefs)
|
||||
'/advanced/stepTransaction', // get single step tx for a route step
|
||||
]);
|
||||
|
||||
router.use(proxyJumperRequest);
|
||||
|
||||
export default router;
|
||||
|
||||
async function proxyJumperRequest(req: Request, res: Response, _next: NextFunction) {
|
||||
try {
|
||||
const jumperPath = req.path;
|
||||
|
||||
let allowed = false;
|
||||
if (req.method === 'GET' && ALLOWED_GET_PATHS.has(jumperPath)) {
|
||||
allowed = true;
|
||||
} else if (req.method === 'POST' && ALLOWED_POST_PATHS.has(jumperPath)) {
|
||||
allowed = true;
|
||||
}
|
||||
if (!allowed) {
|
||||
res.status(404).json({ success: false, error: 'Jumper endpoint not allowed' });
|
||||
return;
|
||||
}
|
||||
|
||||
// C16 — bind fromAddress to JWT user's wallet (если chain в нашем DB).
|
||||
// Без bind'а authenticated user мог бы построить quote с fromAddress=attacker'а,
|
||||
// подписать через /sign-raw-evm-tx (мы fee-payer'а проверяем там) — двойная защита.
|
||||
if (
|
||||
(req.method === 'POST' && (jumperPath === '/advanced/routes' || jumperPath === '/advanced/stepTransaction')) ||
|
||||
(req.method === 'GET' && (jumperPath === '/quote' || jumperPath === '/quote-best'))
|
||||
) {
|
||||
const userId = (req as any).auth?.userId;
|
||||
if (!userId) {
|
||||
res.status(401).json({ success: false, error: 'auth required' });
|
||||
return;
|
||||
}
|
||||
// GET: query params. POST: body.
|
||||
const fromAddress = req.method === 'GET'
|
||||
? String(req.query.fromAddress || '')
|
||||
: String(req.body?.fromAddress || '');
|
||||
const fromChainRaw = req.method === 'GET'
|
||||
? Number(req.query.fromChain)
|
||||
: Number(req.body?.fromChain);
|
||||
|
||||
if (!fromAddress) {
|
||||
res.status(400).json({ success: false, error: 'Missing fromAddress' });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(fromChainRaw)) {
|
||||
res.status(400).json({ success: false, error: 'Missing or invalid fromChain (numeric)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ourChain = JUMPER_CHAINID_TO_CHAIN[fromChainRaw];
|
||||
if (ourChain) {
|
||||
// Bind на наш wallet
|
||||
try {
|
||||
const wallet = await WalletModel.findByUserAndChain(userId, ourChain);
|
||||
if (!wallet) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: `No ${ourChain} wallet for user — cannot bind fromAddress`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const isEvm = ourChain === 'ETH' || ourChain === 'BSC';
|
||||
const match = isEvm
|
||||
? fromAddress.toLowerCase() === wallet.address.toLowerCase()
|
||||
: fromAddress === wallet.address;
|
||||
if (!match) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: `fromAddress ${fromAddress} ≠ user's ${ourChain} wallet ${wallet.address}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(403).json({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Chain не в нашем DB (Avalanche, Optimism, etc.). LiFi поддерживает 50+ chain.
|
||||
// Bind skip — юзер сам несёт ответственность за корректность адреса.
|
||||
logger.warn(`Jumper proxy: fromChain=${fromChainRaw} not in our wallet DB — skipping bind for userId=${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// /quote-best — local handler. Пробуем NearIntents first → fallback на best route.
|
||||
if (req.method === 'GET' && jumperPath === '/quote-best') {
|
||||
return handleQuoteBest(req, res);
|
||||
}
|
||||
|
||||
// Forward query params.
|
||||
const lifiUrl = new URL(`${LIFI_API_URL}${jumperPath}`);
|
||||
Object.entries(req.query).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => lifiUrl.searchParams.append(key, String(item)));
|
||||
return;
|
||||
}
|
||||
if (typeof value !== 'undefined') {
|
||||
lifiUrl.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// Explicit timeout via AbortController.
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), LIFI_TIMEOUT_MS);
|
||||
|
||||
let upstream: globalThis.Response;
|
||||
try {
|
||||
upstream = await proxiedFetch(lifiUrl.toString(), {
|
||||
method: req.method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(req.method !== 'GET' ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
|
||||
// Force JSON content-type (anti-XSS, S5 в relay-proxy).
|
||||
res.status(upstream.status);
|
||||
res.type('application/json');
|
||||
|
||||
const text = await upstream.text();
|
||||
if (!upstream.ok) {
|
||||
logger.warn(`Jumper (LiFi) upstream ${upstream.status}: ${text.slice(0, 200)}`);
|
||||
let parsed: unknown = null;
|
||||
try { parsed = JSON.parse(text); } catch { /* not JSON */ }
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
res.json({ success: false, error: 'Jumper upstream error', upstream: parsed });
|
||||
} else {
|
||||
res.json({ success: false, error: 'Jumper upstream error' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filtered = filterJumperMetadata(jumperPath, text);
|
||||
if (filtered) {
|
||||
res.json(filtered);
|
||||
return;
|
||||
}
|
||||
res.send(text);
|
||||
} catch {
|
||||
res.json({ success: false, error: 'Jumper returned non-JSON' });
|
||||
return;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
res.status(504).json({ success: false, error: 'Jumper request timeout' });
|
||||
return;
|
||||
}
|
||||
logger.error(`Jumper proxy failed: ${error?.stack || error?.message}`);
|
||||
res.status(502).json({ success: false, error: 'Jumper proxy error' });
|
||||
}
|
||||
}
|
||||
|
||||
function filterJumperMetadata(jumperPath: string, text: string): unknown | null {
|
||||
if (jumperPath !== '/chains' && jumperPath !== '/tokens' && jumperPath !== '/tools') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
if (jumperPath === '/chains') {
|
||||
return filterChainsResponse(parsed);
|
||||
}
|
||||
if (jumperPath === '/tokens') {
|
||||
return filterTokensResponse(parsed);
|
||||
}
|
||||
return filterToolsResponse(parsed);
|
||||
}
|
||||
|
||||
function filterChainsResponse(body: any): any {
|
||||
if (!Array.isArray(body?.chains)) return body;
|
||||
const upstream = body.chains.filter((chain: any) => ALLOWED_JUMPER_CHAIN_IDS.has(Number(chain?.id)));
|
||||
const byId = new Map<number, any>();
|
||||
for (const chain of [...upstream, ...LOCAL_JUMPER_CHAINS]) {
|
||||
byId.set(Number(chain.id), chain);
|
||||
}
|
||||
return {
|
||||
...body,
|
||||
chains: [...ALLOWED_JUMPER_CHAIN_IDS]
|
||||
.map((chainId) => byId.get(chainId))
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function filterTokensResponse(body: any): any {
|
||||
if (!body?.tokens || typeof body.tokens !== 'object') return body;
|
||||
|
||||
const allow = buildAllowedTokenMap();
|
||||
const local = buildLocalTokenMap();
|
||||
const filteredByChain = new Map<number, Map<string, any>>();
|
||||
|
||||
for (const [chainId, tokens] of Object.entries(body.tokens)) {
|
||||
if (!ALLOWED_JUMPER_CHAIN_IDS.has(Number(chainId)) || !Array.isArray(tokens)) continue;
|
||||
const numericChainId = Number(chainId);
|
||||
const allowedForChain = allow.get(numericChainId);
|
||||
if (!allowedForChain) continue;
|
||||
const merged = filteredByChain.get(numericChainId) ?? buildTokenMap(local.get(numericChainId) ?? []);
|
||||
for (const token of tokens) {
|
||||
const key = tokenKey(token);
|
||||
if (allowedForChain.has(key)) {
|
||||
merged.set(key, token);
|
||||
}
|
||||
}
|
||||
filteredByChain.set(numericChainId, merged);
|
||||
}
|
||||
|
||||
// LiFi currently omits SOL/BTC/TRX token lists from /tokens. Add our local whitelist
|
||||
// so frontend can use one metadata contract for quote-best/quote and bridge/execute.
|
||||
for (const chainId of ALLOWED_JUMPER_CHAIN_IDS) {
|
||||
if (!filteredByChain.has(chainId)) {
|
||||
filteredByChain.set(chainId, buildTokenMap(local.get(chainId) ?? []));
|
||||
}
|
||||
}
|
||||
|
||||
const filteredTokens: Record<string, any[]> = {};
|
||||
for (const chainId of ALLOWED_JUMPER_CHAIN_IDS) {
|
||||
const tokens = [...(filteredByChain.get(chainId)?.values() ?? [])];
|
||||
if (tokens.length > 0) {
|
||||
filteredTokens[String(chainId)] = tokens;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...body, tokens: filteredTokens };
|
||||
}
|
||||
|
||||
function filterToolsResponse(body: any): any {
|
||||
const filterPair = (pair: any): boolean =>
|
||||
ALLOWED_JUMPER_CHAIN_IDS.has(Number(pair?.fromChainId)) &&
|
||||
ALLOWED_JUMPER_CHAIN_IDS.has(Number(pair?.toChainId));
|
||||
|
||||
const bridges = Array.isArray(body?.bridges)
|
||||
? body.bridges
|
||||
.map((bridge: any) => {
|
||||
const supportedChains = Array.isArray(bridge?.supportedChains)
|
||||
? bridge.supportedChains.filter(filterPair)
|
||||
: [];
|
||||
return { ...bridge, supportedChains };
|
||||
})
|
||||
.filter((bridge: any) => bridge.supportedChains.length > 0)
|
||||
: body?.bridges;
|
||||
|
||||
const exchanges = Array.isArray(body?.exchanges)
|
||||
? body.exchanges
|
||||
.map((exchange: any) => {
|
||||
const supportedChains = Array.isArray(exchange?.supportedChains)
|
||||
? exchange.supportedChains.filter((chainId: unknown) => ALLOWED_JUMPER_CHAIN_IDS.has(Number(chainId)))
|
||||
: [];
|
||||
return { ...exchange, supportedChains };
|
||||
})
|
||||
.filter((exchange: any) => exchange.supportedChains.length > 0)
|
||||
: body?.exchanges;
|
||||
|
||||
return { ...body, bridges, exchanges };
|
||||
}
|
||||
|
||||
function buildAllowedTokenMap(): Map<number, Set<string>> {
|
||||
const map = new Map<number, Set<string>>();
|
||||
for (const [chainId, tokens] of buildLocalTokenMap()) {
|
||||
map.set(chainId, new Set(tokens.map(tokenKey)));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildLocalTokenMap(): Map<number, any[]> {
|
||||
const map = new Map<number, any[]>();
|
||||
const rows = getTokensForChains(['ETH', 'BSC', 'SOL', 'BTC', 'TRX'], true);
|
||||
|
||||
for (const row of rows) {
|
||||
const chainId = JUMPER_CHAIN_BY_CODE[row.chain];
|
||||
if (!chainId) continue;
|
||||
const address = row.contract || JUMPER_NATIVE_SENTINELS[row.chain] || '';
|
||||
if (!address) continue;
|
||||
const bucket = map.get(chainId) ?? [];
|
||||
bucket.push({
|
||||
chainId,
|
||||
address,
|
||||
symbol: row.symbol,
|
||||
name: row.name,
|
||||
decimals: row.decimals,
|
||||
coinKey: row.symbol,
|
||||
source: 'cryptowallet-whitelist',
|
||||
});
|
||||
map.set(chainId, bucket);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildTokenMap(tokens: any[]): Map<string, any> {
|
||||
return new Map(tokens.map((token) => [tokenKey(token), token]));
|
||||
}
|
||||
|
||||
function tokenKey(token: any): string {
|
||||
const symbol = String(token?.symbol || '').toUpperCase();
|
||||
const address = String(token?.address || '').toLowerCase();
|
||||
return `${symbol}:${address}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/jumper/quote-best — bridge quote с приоритетом NearIntents.
|
||||
*
|
||||
* Логика:
|
||||
* 1. Пытаемся LiFi `/quote?...&allowBridges=near` — если NearIntents поддерживает пару → return.
|
||||
* 2. Если 404/no route → LiFi `/quote?...` без filter → берём best route любого типа.
|
||||
*
|
||||
* Response = upstream LiFi quote + дополнительное поле `_source` ('near' или 'best').
|
||||
*/
|
||||
async function handleQuoteBest(req: Request, res: Response): Promise<void> {
|
||||
const baseParams = new URLSearchParams();
|
||||
Object.entries(req.query).forEach(([key, value]) => {
|
||||
if (key === 'allowBridges' || key === 'denyBridges') return; // ignore client filter — мы сами управляем
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => baseParams.append(key, String(item)));
|
||||
} else if (typeof value !== 'undefined') {
|
||||
baseParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// Helper для одного LiFi call.
|
||||
async function tryLiFiQuote(extraParam?: { key: string; value: string }): Promise<{ ok: boolean; status: number; body: any }> {
|
||||
const params = new URLSearchParams(baseParams);
|
||||
if (extraParam) params.set(extraParam.key, extraParam.value);
|
||||
const url = `${LIFI_API_URL}/quote?${params.toString()}`;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), LIFI_TIMEOUT_MS);
|
||||
try {
|
||||
const upstream = await proxiedFetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
const text = await upstream.text();
|
||||
let parsed: any = null;
|
||||
try { parsed = JSON.parse(text); } catch { /* not JSON */ }
|
||||
return { ok: upstream.ok, status: upstream.status, body: parsed ?? { _raw: text.slice(0, 300) } };
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
res.type('application/json');
|
||||
|
||||
try {
|
||||
// Step 1 — NearIntents only.
|
||||
const nearRes = await tryLiFiQuote({ key: 'allowBridges', value: 'near' });
|
||||
if (nearRes.ok && nearRes.body && (nearRes.body.estimate || nearRes.body.action)) {
|
||||
res.status(200).json({ ...nearRes.body, _source: 'near' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Jumper /quote-best: NearIntents unavailable (status=${nearRes.status}); falling back to best route`);
|
||||
|
||||
// Step 2 — fallback на любой best route.
|
||||
const bestRes = await tryLiFiQuote();
|
||||
if (bestRes.ok && bestRes.body && (bestRes.body.estimate || bestRes.body.action)) {
|
||||
res.status(200).json({ ...bestRes.body, _source: 'best' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Оба варианта не дали валидный route.
|
||||
res.status(bestRes.status || 502).json({
|
||||
success: false,
|
||||
error: 'No bridge route found (tried NearIntents + best)',
|
||||
upstream: bestRes.body ?? nearRes.body,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
res.status(504).json({ success: false, error: 'LiFi quote timeout' });
|
||||
return;
|
||||
}
|
||||
logger.error(`handleQuoteBest failed: ${error?.stack || error?.message}`);
|
||||
res.status(502).json({ success: false, error: 'Quote-best failed' });
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { PricesController } from '../controllers/prices.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// IMPORTANT: /dynamics ПЕРЕД / (Express specific-first)
|
||||
router.get('/dynamics', PricesController.getDynamics);
|
||||
router.get('/', PricesController.getPrices);
|
||||
|
||||
export default router;
|
||||
@@ -1,35 +1,9 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../lib/logger';
|
||||
import { WalletModel } from '../models/wallet.model';
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
import { indexRelayExecuteResponse } from '../lib/relay-trusted-cache';
|
||||
import { proxiedFetch } from '../lib/outbound-proxy';
|
||||
import { getDecimalsByContract, parseHumanAmount } from '../lib/amount-units';
|
||||
|
||||
const router = Router();
|
||||
const RELAY_API_URL = 'https://api.relay.link';
|
||||
const RELAY_TIMEOUT_MS = 20_000;
|
||||
|
||||
// chainId → ChainCode. Relay использует EVM chainIds + custom большие для не-EVM.
|
||||
const RELAY_CHAINID_TO_CHAIN: Record<number, ChainCode> = {
|
||||
1: 'ETH',
|
||||
56: 'BSC',
|
||||
792703809: 'SOL',
|
||||
};
|
||||
|
||||
// Whitelist: GET-paths + POST-paths + allowed `/execute/<action>` actions.
|
||||
// Без него `req.path` после `/execute/` свободен → `/execute/../admin` обходит startsWith-check.
|
||||
// Relay API: `POST /quote` (раньше был `GET /quote/v2` — upstream сменили в 2025).
|
||||
const ALLOWED_GET_PATHS = new Set(['/intents/status/v3']);
|
||||
// `/cost-estimate` — LOCAL alias (not a Relay endpoint). Internally calls Relay /quote и
|
||||
// фильтрует response — отдаёт только fees + details (без steps[]).
|
||||
const ALLOWED_POST_PATHS = new Set(['/quote', '/cost-estimate']);
|
||||
const ALLOWED_EXECUTE_ACTIONS = new Set([
|
||||
'swap',
|
||||
'bridge',
|
||||
// добавлять по мере необходимости
|
||||
]);
|
||||
const ALLOWED_PATHS = new Set(['/quote/v2', '/intents/status/v3']);
|
||||
|
||||
router.use(proxyRelayRequest);
|
||||
|
||||
@@ -38,245 +12,41 @@ export default router;
|
||||
async function proxyRelayRequest(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const relayPath = req.path;
|
||||
|
||||
// Whitelist matching — никакого freeform после `/execute/`.
|
||||
// Каждый path matches только своему method'у, чтобы клиент не мог GET-ом дёрнуть POST endpoint.
|
||||
let allowed = false;
|
||||
if (req.method === 'GET' && ALLOWED_GET_PATHS.has(relayPath)) {
|
||||
allowed = true;
|
||||
} else if (req.method === 'POST' && ALLOWED_POST_PATHS.has(relayPath)) {
|
||||
allowed = true;
|
||||
} else if (req.method === 'POST' && relayPath.startsWith('/execute/')) {
|
||||
const action = relayPath.slice('/execute/'.length);
|
||||
// action: только alphanumeric, никаких слешей/дотов
|
||||
if (/^[a-z][a-z0-9-]{0,32}$/i.test(action) && ALLOWED_EXECUTE_ACTIONS.has(action)) {
|
||||
allowed = true;
|
||||
}
|
||||
}
|
||||
if (!allowed) {
|
||||
if (!relayPath.startsWith('/execute/') && !ALLOWED_PATHS.has(relayPath)) {
|
||||
res.status(404).json({ success: false, error: 'Relay endpoint not allowed' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect local-only /cost-estimate endpoint — internally forwarded к Relay /quote,
|
||||
// response trimmed (без steps[]).
|
||||
const isCostEstimate = req.method === 'POST' && relayPath === '/cost-estimate';
|
||||
|
||||
// C16 — bind body.user / body.recipient to JWT user's wallet.
|
||||
// Без этого authenticated user может set recipient=attacker → Relay строит quote →
|
||||
// victim signs → bridge funds к attacker'у.
|
||||
if (req.method === 'POST' && (relayPath === '/quote' || relayPath === '/cost-estimate' || relayPath.startsWith('/execute/'))) {
|
||||
const userId = (req as any).auth?.userId;
|
||||
if (!userId) {
|
||||
res.status(401).json({ success: false, error: 'auth required' });
|
||||
return;
|
||||
}
|
||||
const bodyUser = req.body?.user;
|
||||
const bodyRecipient = req.body?.recipient;
|
||||
const originChainId = Number(req.body?.originChainId);
|
||||
const destinationChainId = Number(req.body?.destinationChainId);
|
||||
|
||||
if (typeof bodyUser !== 'string' || !bodyUser) {
|
||||
res.status(400).json({ success: false, error: 'Missing body.user' });
|
||||
return;
|
||||
}
|
||||
const originChain = RELAY_CHAINID_TO_CHAIN[originChainId];
|
||||
if (!originChain) {
|
||||
res.status(400).json({ success: false, error: `originChainId ${originChainId} not in allowlist (1=ETH, 56=BSC, 792703809=SOL)` });
|
||||
return;
|
||||
}
|
||||
// Bind: body.user must equal user's wallet for originChain
|
||||
try {
|
||||
const wallet = await WalletModel.findByUserAndChain(userId, originChain);
|
||||
if (!wallet) throw new Error(`No ${originChain} wallet for user`);
|
||||
const isEvm = originChain === 'ETH' || originChain === 'BSC';
|
||||
const match = isEvm
|
||||
? bodyUser.toLowerCase() === wallet.address.toLowerCase()
|
||||
: bodyUser === wallet.address;
|
||||
if (!match) throw new Error(`body.user ${bodyUser} ≠ user's ${originChain} wallet ${wallet.address}`);
|
||||
} catch (err: any) {
|
||||
res.status(403).json({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
// Bind recipient (if provided) — must equal user's wallet for destinationChain.
|
||||
// Если destinationChainId не в whitelist — recipient мы проверить не можем; reject.
|
||||
if (bodyRecipient !== undefined && bodyRecipient !== null) {
|
||||
const destChain = RELAY_CHAINID_TO_CHAIN[destinationChainId];
|
||||
if (!destChain) {
|
||||
res.status(400).json({ success: false, error: `destinationChainId ${destinationChainId} not in allowlist` });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dstWallet = await WalletModel.findByUserAndChain(userId, destChain);
|
||||
if (!dstWallet) throw new Error(`No ${destChain} wallet for user (cannot validate recipient)`);
|
||||
const isEvm = destChain === 'ETH' || destChain === 'BSC';
|
||||
const match = isEvm
|
||||
? String(bodyRecipient).toLowerCase() === dstWallet.address.toLowerCase()
|
||||
: String(bodyRecipient) === dstWallet.address;
|
||||
if (!match) throw new Error(`body.recipient ${bodyRecipient} ≠ user's ${destChain} wallet ${dstWallet.address}`);
|
||||
} catch (err: any) {
|
||||
res.status(403).json({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ADDITIVE: amountHuman preprocessing для /quote, /cost-estimate, /execute/*.
|
||||
// Если body содержит amountHuman → разрешаем через originCurrency contract → decimals.
|
||||
// Старое поле `amount` (smallest units) продолжает работать unchanged.
|
||||
if (req.method === 'POST' &&
|
||||
(relayPath === '/quote' || relayPath === '/cost-estimate' || relayPath.startsWith('/execute/'))) {
|
||||
const body = req.body ?? {};
|
||||
const hasAmount = body.amount !== undefined && body.amount !== null && body.amount !== '';
|
||||
const hasAmountHuman = body.amountHuman !== undefined && body.amountHuman !== null && body.amountHuman !== '';
|
||||
if (hasAmount && hasAmountHuman) {
|
||||
res.status(400).json({ success: false, error: 'Use either "amount" or "amountHuman", not both' });
|
||||
return;
|
||||
}
|
||||
if (hasAmountHuman) {
|
||||
const originCurrency = String(body.originCurrency ?? '');
|
||||
const originChainId = Number(body.originChainId);
|
||||
const originChain = RELAY_CHAINID_TO_CHAIN[originChainId];
|
||||
if (!originChain) {
|
||||
res.status(400).json({ success: false, error: `originChainId ${originChainId} not in allowlist (needed для amountHuman → decimals)` });
|
||||
return;
|
||||
}
|
||||
const dec = getDecimalsByContract(originChain, originCurrency);
|
||||
if (dec == null) {
|
||||
res.status(400).json({ success: false, error: `Unknown originCurrency "${originCurrency}" — supply "amount" (smallest units) directly` });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resolved = parseHumanAmount(String(body.amountHuman), dec);
|
||||
req.body.amount = resolved;
|
||||
delete req.body.amountHuman;
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map local /cost-estimate → real Relay /quote endpoint.
|
||||
const upstreamPath = isCostEstimate ? '/quote' : relayPath;
|
||||
const relayUrl = new URL(`${RELAY_API_URL}${upstreamPath}`);
|
||||
const relayUrl = new URL(`${RELAY_API_URL}${relayPath}`);
|
||||
|
||||
Object.entries(req.query).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => relayUrl.searchParams.append(key, String(item)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'undefined') {
|
||||
relayUrl.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// Explicit timeout via AbortController — иначе hang upstream'а держит request indefinitely.
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS);
|
||||
const response = await fetch(relayUrl.toString(), {
|
||||
method: req.method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(req.method !== 'GET' ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(env.relayApiKey ? { Authorization: `Bearer ${env.relayApiKey}` } : {}),
|
||||
},
|
||||
body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}),
|
||||
});
|
||||
|
||||
let upstream: globalThis.Response;
|
||||
try {
|
||||
// Через OUTBOUND_PROXY_URL если задан (bridge path) — Relay calls идут через proxy.
|
||||
// Fallback на native fetch если env пустой.
|
||||
upstream = await proxiedFetch(relayUrl.toString(), {
|
||||
method: req.method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(req.method !== 'GET' ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(env.relayApiKey ? { Authorization: `Bearer ${env.relayApiKey}` } : {}),
|
||||
},
|
||||
body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
const contentType = response.headers.get('content-type') ?? 'application/json';
|
||||
const payload = await response.text();
|
||||
|
||||
// Force JSON content-type — иначе compromised upstream может вернуть text/html
|
||||
// → reflected XSS если frontend рендерит ответ напрямую.
|
||||
res.status(upstream.status);
|
||||
res.type('application/json');
|
||||
|
||||
const text = await upstream.text();
|
||||
if (!upstream.ok) {
|
||||
logger.warn(`Relay upstream ${upstream.status}: ${text.slice(0, 200)}`);
|
||||
// Пробрасываем Relay error JSON клиенту — он сам пишет structured payload
|
||||
// {message, errorCode, requestId, ...}. Content-Type уже forced на JSON выше,
|
||||
// так что HTML-injection невозможен. Parsable наружу — клиент видит реальную причину.
|
||||
let parsed: unknown = null;
|
||||
try { parsed = JSON.parse(text); } catch { /* not JSON — wrap in safe envelope */ }
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
res.json({ success: false, error: 'Relay upstream error', upstream: parsed });
|
||||
} else {
|
||||
res.json({ success: false, error: 'Relay upstream error' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// /cost-estimate — trim response (только fees + details, без steps[]).
|
||||
if (isCostEstimate) {
|
||||
let trimmed: any;
|
||||
try {
|
||||
const full = JSON.parse(text);
|
||||
const fees = full?.fees ?? {};
|
||||
let totalUsd: number | null = 0;
|
||||
for (const k of ['gas', 'relayer', 'app']) {
|
||||
const u = Number(fees?.[k]?.amountUsd);
|
||||
if (Number.isFinite(u)) totalUsd += u;
|
||||
else { totalUsd = null; break; }
|
||||
}
|
||||
trimmed = {
|
||||
success: true,
|
||||
data: {
|
||||
fees: {
|
||||
gas: fees.gas ?? null,
|
||||
relayer: fees.relayer ?? null,
|
||||
app: fees.app ?? null,
|
||||
total: { amountUsd: totalUsd },
|
||||
},
|
||||
rate: full?.details?.rate ?? null,
|
||||
priceImpactPct: full?.details?.totalImpact?.percent ?? null,
|
||||
priceImpactUsd: full?.details?.totalImpact?.usd ?? null,
|
||||
timeEstimate: full?.details?.timeEstimate ?? null,
|
||||
currencyIn: full?.details?.currencyIn ?? null,
|
||||
currencyOut: full?.details?.currencyOut ?? null,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
trimmed = { success: false, error: 'Relay returned non-JSON for /cost-estimate' };
|
||||
}
|
||||
res.json(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send raw text если это валидный JSON, иначе обернём
|
||||
try {
|
||||
res.send(text);
|
||||
} catch {
|
||||
res.json({ success: false, error: 'Relay returned non-JSON' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Indexing trusted Relay addresses в KeyDB (для последующего sign-raw-evm-tx).
|
||||
// Только для /execute/* — там steps[].items[].data.to/data парсятся.
|
||||
// Fire-and-forget — не блокирует response.
|
||||
if (req.method === 'POST' && relayPath.startsWith('/execute/')) {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
indexRelayExecuteResponse(parsed).catch((err) =>
|
||||
logger.warn(`indexRelayExecuteResponse error (ignored): ${err?.message || 'unknown'}`),
|
||||
);
|
||||
} catch {
|
||||
// ignore — already shipped response к юзеру
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
res.status(504).json({ success: false, error: 'Relay request timeout' });
|
||||
return;
|
||||
}
|
||||
logger.error(`Relay proxy failed: ${error?.stack || error?.message}`);
|
||||
res.status(502).json({ success: false, error: 'Relay proxy error' });
|
||||
res.status(response.status);
|
||||
res.type(contentType);
|
||||
res.send(payload);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
166
apps/api/src/routes/sol-swap-proxy.routes.ts
Normal file
166
apps/api/src/routes/sol-swap-proxy.routes.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Request, Response, Router } from 'express';
|
||||
import { env } from '../config/env';
|
||||
|
||||
const router = Router();
|
||||
const JUPITER_BASE = 'https://api.jup.ag/swap/v1';
|
||||
const JUPITER_TIMEOUT_MS = 15_000;
|
||||
|
||||
const ALLOWED_MINTS = new Set([
|
||||
'So11111111111111111111111111111111111111112', // SOL
|
||||
'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', // USDT
|
||||
'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC
|
||||
'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', // PUMP
|
||||
'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', // JUP
|
||||
'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', // WIF
|
||||
'7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', // POPCAT
|
||||
'6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', // TRUMP
|
||||
'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', // PYTH
|
||||
'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', // JTO
|
||||
'85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', // W
|
||||
'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', // BONK
|
||||
'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', // ORCA
|
||||
'2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', // PENGU
|
||||
'4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', // RAY
|
||||
]);
|
||||
|
||||
router.get('/quote', getQuote);
|
||||
router.post('/build', buildSwap);
|
||||
|
||||
export default router;
|
||||
|
||||
/**
|
||||
* GET /api/sol/swap/quote
|
||||
* Proxies to Jupiter GET /v6/quote
|
||||
*/
|
||||
async function getQuote(req: Request, res: Response) {
|
||||
const { inputMint, outputMint, amount, slippageBps } = req.query;
|
||||
|
||||
if (!inputMint || !outputMint || !amount || !slippageBps) {
|
||||
res.status(400).json({ success: false, error: 'Missing required params: inputMint, outputMint, amount, slippageBps' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ALLOWED_MINTS.has(String(inputMint)) || !ALLOWED_MINTS.has(String(outputMint))) {
|
||||
res.status(400).json({ success: false, error: 'Token mint not in whitelist' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputMint === outputMint) {
|
||||
res.status(400).json({ success: false, error: 'inputMint and outputMint must be different' });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedAmount = parseInt(String(amount), 10);
|
||||
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
||||
res.status(400).json({ success: false, error: 'amount must be a positive integer' });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const url = new URL(`${JUPITER_BASE}/quote`);
|
||||
url.searchParams.set('inputMint', String(inputMint));
|
||||
url.searchParams.set('outputMint', String(outputMint));
|
||||
url.searchParams.set('amount', String(parsedAmount));
|
||||
url.searchParams.set('slippageBps', String(slippageBps));
|
||||
|
||||
// Platform fee (0.7%) — Jupiter deducts this natively
|
||||
if (env.jupiterFeeBps > 0) {
|
||||
url.searchParams.set('platformFeeBps', String(env.jupiterFeeBps));
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (env.jupiterApiKey) {
|
||||
headers['x-api-key'] = env.jupiterApiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { headers, signal: controller.signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => 'Unknown error');
|
||||
res.status(response.status).json({ success: false, error: `Jupiter API error: ${text}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
res.status(504).json({ success: false, error: 'Jupiter quote request timed out' });
|
||||
return;
|
||||
}
|
||||
res.status(502).json({ success: false, error: 'Failed to reach Jupiter API' });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sol/swap/build
|
||||
* Proxies to Jupiter POST /v6/swap — returns serialized transaction for client signing
|
||||
*/
|
||||
async function buildSwap(req: Request, res: Response) {
|
||||
const { quoteResponse, userPublicKey } = req.body;
|
||||
|
||||
if (!quoteResponse || typeof quoteResponse !== 'object') {
|
||||
res.status(400).json({ success: false, error: 'Missing quoteResponse object' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userPublicKey || typeof userPublicKey !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'Missing userPublicKey string' });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
if (env.jupiterApiKey) {
|
||||
headers['x-api-key'] = env.jupiterApiKey;
|
||||
}
|
||||
|
||||
const swapBody: Record<string, unknown> = {
|
||||
quoteResponse,
|
||||
userPublicKey,
|
||||
wrapAndUnwrapSol: true,
|
||||
dynamicComputeUnitLimit: true,
|
||||
prioritizationFeeLamports: 'auto',
|
||||
};
|
||||
|
||||
// Attach referral fee account for Jupiter to route platform fees
|
||||
if (env.jupiterReferralAccount) {
|
||||
swapBody.feeAccount = env.jupiterReferralAccount;
|
||||
}
|
||||
|
||||
const response = await fetch(`${JUPITER_BASE}/swap`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify(swapBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => 'Unknown error');
|
||||
res.status(response.status).json({ success: false, error: `Jupiter swap build error: ${text}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
res.status(504).json({ success: false, error: 'Jupiter swap build timed out' });
|
||||
return;
|
||||
}
|
||||
res.status(502).json({ success: false, error: 'Failed to reach Jupiter API' });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* GET /api/tokens — compact allowlist активов для bridge/swap UI.
|
||||
*
|
||||
* Read-only. Источник — `lib/token-registry.ts`. Никаких RPC calls,
|
||||
* никаких user-specific данных — только статический list контрактов с symbol + name.
|
||||
*
|
||||
* Optional query params:
|
||||
* ?chain=ETH|BSC|BTC|TRX|SOL — filter одной сетью.
|
||||
* ?chains=ETH,BSC,BTC,TRX,SOL — filter нескольких сетей.
|
||||
* ?includeUnsupported=true — debug/full registry mode; default = только usable tokens.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getTokensForChains } from '../lib/token-registry';
|
||||
import { ALL_CHAINS } from '../services/wallet-generator.service';
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
|
||||
const router = Router();
|
||||
const ALLOWED = new Set<ChainCode>(ALL_CHAINS);
|
||||
const ALLOWED_LABEL = 'ETH, BSC, BTC, TRX, SOL';
|
||||
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
const parseChain = (raw: string): ChainCode | null => {
|
||||
const upper = raw.trim().toUpperCase();
|
||||
if (!upper) return null;
|
||||
return ALLOWED.has(upper as ChainCode) ? (upper as ChainCode) : null;
|
||||
};
|
||||
|
||||
const requested = new Set<ChainCode>();
|
||||
const addChain = (raw: unknown): string | null => {
|
||||
const chain = parseChain(String(raw));
|
||||
if (!chain) return String(raw);
|
||||
requested.add(chain);
|
||||
return null;
|
||||
};
|
||||
|
||||
const chainParam = req.query.chain;
|
||||
if (chainParam !== undefined && chainParam !== null && chainParam !== '') {
|
||||
const invalid = addChain(Array.isArray(chainParam) ? chainParam[0] : chainParam);
|
||||
if (invalid) {
|
||||
res.status(400).json({ success: false, error: `Invalid chain "${invalid}" (allowed: ${ALLOWED_LABEL})` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const chainsParam = req.query.chains;
|
||||
if (chainsParam !== undefined && chainsParam !== null && chainsParam !== '') {
|
||||
const rawValues = Array.isArray(chainsParam) ? chainsParam : [chainsParam];
|
||||
for (const raw of rawValues.flatMap((value) => String(value).split(','))) {
|
||||
if (!raw.trim()) continue;
|
||||
const invalid = addChain(raw);
|
||||
if (invalid) {
|
||||
res.status(400).json({ success: false, error: `Invalid chain "${invalid}" (allowed: ${ALLOWED_LABEL})` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default = compact UI whitelist. Full registry only by explicit debug opt-in.
|
||||
const includeUnsupported = String(req.query.includeUnsupported || '').toLowerCase() === 'true' ||
|
||||
String(req.query.bridgeable || '').toLowerCase() === 'false';
|
||||
const data = getTokensForChains([...requested], !includeUnsupported);
|
||||
res.json({ success: true, data });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -193,86 +193,15 @@ async function createTransaction(req: Request, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
// Whitelist contracts and functions accepted via /triggersmartcontract.
|
||||
// Defence in depth: иначе клиент мог бы вызвать любой контракт (e.g. drain pool).
|
||||
const ALLOWED_TRC_CONTRACTS = new Set<string>([
|
||||
USDT_CONTRACT, // USDT TRC20
|
||||
'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax', // SunSwap router
|
||||
'TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E', // FeeSwapRouter_TRX
|
||||
'TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR', // WTRX
|
||||
]);
|
||||
|
||||
const ALLOWED_TRC_FUNCTIONS = new Set<string>([
|
||||
'transfer(address,uint256)',
|
||||
'approve(address,uint256)',
|
||||
'balanceOf(address)',
|
||||
'allowance(address,address)',
|
||||
'swapExactETHForTokens(uint256,address[],address,uint256)',
|
||||
'swapExactTokensForETH(uint256,uint256,address[],address,uint256)',
|
||||
'swapExactTokensForETHSupportingFeeOnTransferTokens(uint256,uint256,address[],address,uint256)',
|
||||
'swapNativeWithFee(bytes)',
|
||||
'swapTokenWithFee(address,uint256,bytes)',
|
||||
'getAmountsOut(uint256,address[])',
|
||||
]);
|
||||
|
||||
/**
|
||||
* POST /api/tron/triggersmartcontract
|
||||
* Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction.
|
||||
* Whitelisted contracts + function selectors only.
|
||||
* Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction
|
||||
*/
|
||||
// Максимальный fee_limit для TriggerSmartContract: 1000 TRX = 1_000_000_000 sun.
|
||||
// Без этого attacker с whitelist-проходящим контрактом мог бы выкачать ресурсы аккаунта.
|
||||
const MAX_FEE_LIMIT_SUN = 1_000_000_000;
|
||||
|
||||
async function triggerSmartContract(req: Request, res: Response) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const body = req.body ?? {};
|
||||
const contractAddress = String(body.contract_address || '');
|
||||
const functionSelector = String(body.function_selector || '');
|
||||
const ownerAddress = String(body.owner_address || '');
|
||||
const parameter = String(body.parameter || '');
|
||||
const callValueRaw = body.call_value;
|
||||
const feeLimitRaw = body.fee_limit;
|
||||
|
||||
if (!ALLOWED_TRC_CONTRACTS.has(contractAddress)) {
|
||||
res.status(403).json({ success: false, error: 'Contract address not allowed' });
|
||||
return;
|
||||
}
|
||||
if (!ALLOWED_TRC_FUNCTIONS.has(functionSelector)) {
|
||||
res.status(403).json({ success: false, error: 'Function selector not allowed' });
|
||||
return;
|
||||
}
|
||||
if (!TRON_ADDRESS_RE.test(ownerAddress)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid owner_address' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate parameter — hex (0-9a-f), без 0x prefix, length определена selector'ом.
|
||||
if (!/^[0-9a-fA-F]*$/.test(parameter)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid parameter (must be hex)' });
|
||||
return;
|
||||
}
|
||||
// Лимит длины — самый длинный whitelist'нутый ABI принимает ~3-4 параметра = 256-512 hex chars
|
||||
if (parameter.length > 1024) {
|
||||
res.status(400).json({ success: false, error: 'parameter too long' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Bound fee_limit + call_value
|
||||
const feeLimit = Number(feeLimitRaw ?? 0);
|
||||
if (!Number.isFinite(feeLimit) || feeLimit < 0 || feeLimit > MAX_FEE_LIMIT_SUN) {
|
||||
res.status(400).json({ success: false, error: `fee_limit out of bounds (max ${MAX_FEE_LIMIT_SUN})` });
|
||||
return;
|
||||
}
|
||||
const callValue = Number(callValueRaw ?? 0);
|
||||
if (!Number.isFinite(callValue) || callValue < 0) {
|
||||
res.status(400).json({ success: false, error: 'Invalid call_value' });
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
@@ -281,22 +210,11 @@ async function triggerSmartContract(req: Request, res: Response) {
|
||||
headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
}
|
||||
|
||||
// ВАЖНО: НЕ forward'им req.body целиком — только validated fields.
|
||||
const forwardBody = {
|
||||
owner_address: ownerAddress,
|
||||
contract_address: contractAddress,
|
||||
function_selector: functionSelector,
|
||||
parameter,
|
||||
fee_limit: feeLimit,
|
||||
call_value: callValue,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
const response = await fetch(`${TRONGRID_BASE}/wallet/triggersmartcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify(forwardBody),
|
||||
body: JSON.stringify(req.body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
471
apps/api/src/routes/tron-swap-proxy.routes.ts
Normal file
471
apps/api/src/routes/tron-swap-proxy.routes.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { Request, Response, Router } from 'express';
|
||||
import { env } from '../config/env';
|
||||
|
||||
const router = Router();
|
||||
const TRONGRID_BASE = 'https://api.trongrid.io';
|
||||
const TRON_TIMEOUT_MS = 15_000;
|
||||
const TRON_ADDRESS_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
|
||||
|
||||
// Contracts
|
||||
const SUNSWAP_SMART_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax';
|
||||
const USDT_CONTRACT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
||||
const WTRX_CONTRACT = 'TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR';
|
||||
|
||||
// FeeSwapRouter_TRX — deployed contract, 0.7% fee
|
||||
const FEE_SWAP_ROUTER_TRX = 'TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E';
|
||||
const FEE_BPS = 70n;
|
||||
const BPS_DENOMINATOR = 10_000n;
|
||||
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
// Token map
|
||||
const TOKEN_MAP: Record<string, string> = {
|
||||
TRX: WTRX_CONTRACT,
|
||||
USDT: USDT_CONTRACT,
|
||||
};
|
||||
|
||||
const TOKEN_DECIMALS: Record<string, number> = {
|
||||
TRX: 6,
|
||||
USDT: 6,
|
||||
};
|
||||
|
||||
router.get('/quote', getSwapQuote);
|
||||
router.post('/build', buildSwapTx);
|
||||
router.post('/broadcast', broadcastTx);
|
||||
|
||||
export default router;
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
function tronAddressToHex(address: string): string {
|
||||
let num = 0n;
|
||||
for (const char of address) {
|
||||
const index = BASE58_ALPHABET.indexOf(char);
|
||||
if (index === -1) throw new Error('Invalid base58 character');
|
||||
num = num * 58n + BigInt(index);
|
||||
}
|
||||
const hex = num.toString(16).padStart(50, '0');
|
||||
return hex.slice(2, 42); // skip 0x41, take 20 bytes
|
||||
}
|
||||
|
||||
function encodeUint256(value: bigint): string {
|
||||
return value.toString(16).padStart(64, '0');
|
||||
}
|
||||
|
||||
function encodeAddress(tronAddress: string): string {
|
||||
const hex = tronAddressToHex(tronAddress);
|
||||
return hex.padStart(64, '0');
|
||||
}
|
||||
|
||||
function tronHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
if (env.tronApiKey) {
|
||||
headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Encode bytes calldata as ABI dynamic bytes parameter
|
||||
function encodeDynamicBytes(hexData: string): string {
|
||||
// Remove 0x prefix if present
|
||||
const data = hexData.startsWith('0x') ? hexData.slice(2) : hexData;
|
||||
const byteLength = data.length / 2;
|
||||
const lengthEncoded = encodeUint256(BigInt(byteLength));
|
||||
// Pad data to 32-byte boundary
|
||||
const paddedData = data.padEnd(Math.ceil(data.length / 64) * 64, '0');
|
||||
return lengthEncoded + paddedData;
|
||||
}
|
||||
|
||||
// ─── GET /quote ───
|
||||
|
||||
async function getSwapQuote(req: Request, res: Response) {
|
||||
const from = String(req.query.from || '').toUpperCase();
|
||||
const to = String(req.query.to || '').toUpperCase();
|
||||
const amount = String(req.query.amount || '');
|
||||
|
||||
if (!TOKEN_MAP[from] || !TOKEN_MAP[to]) {
|
||||
res.status(400).json({ success: false, error: 'Invalid from/to. Use TRX or USDT' });
|
||||
return;
|
||||
}
|
||||
if (from === to) {
|
||||
res.status(400).json({ success: false, error: 'from and to must be different' });
|
||||
return;
|
||||
}
|
||||
|
||||
const amountBigInt = BigInt(amount || '0');
|
||||
if (amountBigInt <= 0n) {
|
||||
res.status(400).json({ success: false, error: 'amount must be positive' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduct 0.7% fee — SunSwap will only receive 99.3%
|
||||
const feeAmount = (amountBigInt * FEE_BPS) / BPS_DENOMINATOR;
|
||||
const amountAfterFee = amountBigInt - feeAmount;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const fromToken = TOKEN_MAP[from];
|
||||
const toToken = TOKEN_MAP[to];
|
||||
|
||||
// ABI: getAmountsOut(uint256 amountIn, address[] path)
|
||||
const amountHex = encodeUint256(amountAfterFee);
|
||||
const offsetHex = encodeUint256(64n);
|
||||
const lengthHex = encodeUint256(2n);
|
||||
const addr0Hex = encodeAddress(fromToken);
|
||||
const addr1Hex = encodeAddress(toToken);
|
||||
const parameter = amountHex + offsetHex + lengthHex + addr0Hex + addr1Hex;
|
||||
|
||||
const response = await fetch(`${TRONGRID_BASE}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers: tronHeaders(),
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
owner_address: SUNSWAP_SMART_ROUTER,
|
||||
contract_address: SUNSWAP_SMART_ROUTER,
|
||||
function_selector: 'getAmountsOut(uint256,address[])',
|
||||
parameter,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
res.status(response.status).json({ success: false, error: 'TronGrid error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
constant_result?: string[];
|
||||
result?: { result?: boolean; message?: string };
|
||||
};
|
||||
|
||||
if (!body.constant_result?.[0]) {
|
||||
const errorMsg = body.result?.message
|
||||
? Buffer.from(body.result.message, 'hex').toString('utf8')
|
||||
: 'No result from getAmountsOut';
|
||||
res.status(502).json({ success: false, error: errorMsg });
|
||||
return;
|
||||
}
|
||||
|
||||
const resultHex = body.constant_result[0];
|
||||
const amountOutHex = resultHex.slice(-64);
|
||||
const amountOut = BigInt('0x' + amountOutHex).toString();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
amountIn: amountBigInt.toString(),
|
||||
amountOut,
|
||||
fee: feeAmount.toString(),
|
||||
from,
|
||||
to,
|
||||
fromDecimals: TOKEN_DECIMALS[from],
|
||||
toDecimals: TOKEN_DECIMALS[to],
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
res.status(504).json({ success: false, error: 'TronGrid quote request timed out' });
|
||||
return;
|
||||
}
|
||||
res.status(502).json({ success: false, error: 'Failed to reach TronGrid' });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST /build ───
|
||||
|
||||
async function buildSwapTx(req: Request, res: Response) {
|
||||
const { from, to, amount, amountOutMin, userAddress } = req.body;
|
||||
|
||||
if (!from || !to || !amount || !amountOutMin || !userAddress) {
|
||||
res.status(400).json({ success: false, error: 'Missing required fields: from, to, amount, amountOutMin, userAddress' });
|
||||
return;
|
||||
}
|
||||
|
||||
const fromUpper = String(from).toUpperCase();
|
||||
const toUpper = String(to).toUpperCase();
|
||||
|
||||
if (!TOKEN_MAP[fromUpper] || !TOKEN_MAP[toUpper] || fromUpper === toUpper) {
|
||||
res.status(400).json({ success: false, error: 'Invalid from/to pair' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TRON_ADDRESS_RE.test(userAddress)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid TRON address' });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const transactions: Array<{ txID: string; raw_data: unknown; raw_data_hex: string; type: string }> = [];
|
||||
const amountBigInt = BigInt(amount);
|
||||
const minOutBigInt = BigInt(amountOutMin);
|
||||
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // 20 minutes
|
||||
|
||||
// Calculate fee and swap amounts
|
||||
const feeAmount = (amountBigInt * FEE_BPS) / BPS_DENOMINATOR;
|
||||
const swapAmount = amountBigInt - feeAmount;
|
||||
|
||||
if (fromUpper === 'TRX') {
|
||||
// ═══ TRX → USDT: through FeeSwapRouter.swapNativeWithFee(bytes routerCalldata) ═══
|
||||
|
||||
// Step 1: Build the SunSwap calldata for swapExactETHForTokens
|
||||
// SunSwap will receive swapAmount (99.3%) of TRX from FeeSwapRouter
|
||||
// SunSwap sends output tokens to `to` address — must be userAddress
|
||||
const sunswapCalldata = buildSwapExactETHForTokensCalldata(
|
||||
minOutBigInt,
|
||||
[WTRX_CONTRACT, USDT_CONTRACT],
|
||||
userAddress,
|
||||
deadline,
|
||||
);
|
||||
|
||||
// Step 2: Wrap in swapNativeWithFee(bytes routerCalldata)
|
||||
// ABI: swapNativeWithFee(bytes) — single dynamic bytes param
|
||||
const offsetToBytes = encodeUint256(32n); // offset to dynamic bytes
|
||||
const feeRouterParam = offsetToBytes + encodeDynamicBytes(sunswapCalldata);
|
||||
|
||||
const swapTx = await buildTriggerSmartContract({
|
||||
ownerAddress: userAddress,
|
||||
contractAddress: FEE_SWAP_ROUTER_TRX,
|
||||
functionSelector: 'swapNativeWithFee(bytes)',
|
||||
parameter: feeRouterParam,
|
||||
callValue: Number(amountBigInt), // full amount — contract takes 0.7%
|
||||
feeLimit: 200_000_000, // 200 TRX
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (swapTx) {
|
||||
transactions.push({ ...swapTx, type: 'swap' });
|
||||
}
|
||||
|
||||
} else {
|
||||
// ═══ USDT → TRX: through FeeSwapRouter.swapTokenWithFee(address, uint256, bytes) ═══
|
||||
|
||||
// Step 1: Approve USDT to FeeSwapRouter (not SunSwap!)
|
||||
const allowance = await checkAllowance(userAddress, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, controller.signal);
|
||||
|
||||
if (allowance < amountBigInt) {
|
||||
const approveTx = await buildTriggerSmartContract({
|
||||
ownerAddress: userAddress,
|
||||
contractAddress: USDT_CONTRACT,
|
||||
functionSelector: 'approve(address,uint256)',
|
||||
parameter: encodeAddress(FEE_SWAP_ROUTER_TRX) + encodeUint256(BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff')),
|
||||
callValue: 0,
|
||||
feeLimit: 100_000_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (approveTx) {
|
||||
transactions.push({ ...approveTx, type: 'approve' });
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Build SunSwap calldata for swapExactTokensForETH
|
||||
// FeeSwapRouter will approve swapAmount (99.3%) to SunSwap and forward this calldata
|
||||
const sunswapCalldata = buildSwapExactTokensForETHCalldata(
|
||||
swapAmount, // 99.3% — what SunSwap actually receives
|
||||
minOutBigInt,
|
||||
[USDT_CONTRACT, WTRX_CONTRACT],
|
||||
userAddress, // output TRX goes to user
|
||||
deadline,
|
||||
);
|
||||
|
||||
// Step 3: Wrap in swapTokenWithFee(address tokenIn, uint256 amountIn, bytes routerCalldata)
|
||||
const tokenInEncoded = encodeAddress(USDT_CONTRACT);
|
||||
const amountInEncoded = encodeUint256(amountBigInt); // full amount — contract takes 0.7%
|
||||
const offsetToBytes = encodeUint256(96n); // offset to dynamic bytes (3 * 32)
|
||||
const feeRouterParam = tokenInEncoded + amountInEncoded + offsetToBytes + encodeDynamicBytes(sunswapCalldata);
|
||||
|
||||
const swapTx = await buildTriggerSmartContract({
|
||||
ownerAddress: userAddress,
|
||||
contractAddress: FEE_SWAP_ROUTER_TRX,
|
||||
functionSelector: 'swapTokenWithFee(address,uint256,bytes)',
|
||||
parameter: feeRouterParam,
|
||||
callValue: 0,
|
||||
feeLimit: 200_000_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (swapTx) {
|
||||
transactions.push({ ...swapTx, type: 'swap' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!transactions.length) {
|
||||
res.status(502).json({ success: false, error: 'Failed to build swap transactions' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, transactions });
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
res.status(504).json({ success: false, error: 'Build request timed out' });
|
||||
return;
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : 'Failed to build swap';
|
||||
res.status(502).json({ success: false, error: msg });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST /broadcast ───
|
||||
|
||||
async function broadcastTx(req: Request, res: Response) {
|
||||
const { signedTransaction } = req.body;
|
||||
|
||||
if (!signedTransaction) {
|
||||
res.status(400).json({ success: false, error: 'Missing signedTransaction' });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${TRONGRID_BASE}/wallet/broadcasttransaction`, {
|
||||
method: 'POST',
|
||||
headers: tronHeaders(),
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify(signedTransaction),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
res.status(response.ok ? 200 : 502).json(data);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
res.status(504).json({ success: false, error: 'Broadcast timed out' });
|
||||
return;
|
||||
}
|
||||
res.status(502).json({ success: false, error: 'Failed to broadcast transaction' });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SunSwap Calldata Builders ───
|
||||
|
||||
// Build raw calldata hex for swapExactETHForTokens(uint256,address[],address,uint256)
|
||||
function buildSwapExactETHForTokensCalldata(
|
||||
amountOutMin: bigint,
|
||||
path: string[], // TRON base58 addresses
|
||||
to: string, // TRON base58 address
|
||||
deadline: bigint,
|
||||
): string {
|
||||
// Function selector: keccak256("swapExactETHForTokens(uint256,address[],address,uint256)") first 4 bytes
|
||||
const selector = 'b6f9de95';
|
||||
|
||||
const amountOutMinEnc = encodeUint256(amountOutMin);
|
||||
const offsetToPath = encodeUint256(128n); // 4 * 32 bytes offset
|
||||
const toEnc = encodeAddress(to);
|
||||
const deadlineEnc = encodeUint256(deadline);
|
||||
const pathLenEnc = encodeUint256(BigInt(path.length));
|
||||
const pathElements = path.map((addr) => encodeAddress(addr)).join('');
|
||||
|
||||
return selector + amountOutMinEnc + offsetToPath + toEnc + deadlineEnc + pathLenEnc + pathElements;
|
||||
}
|
||||
|
||||
// Build raw calldata hex for swapExactTokensForETH(uint256,uint256,address[],address,uint256)
|
||||
function buildSwapExactTokensForETHCalldata(
|
||||
amountIn: bigint,
|
||||
amountOutMin: bigint,
|
||||
path: string[], // TRON base58 addresses
|
||||
to: string, // TRON base58 address
|
||||
deadline: bigint,
|
||||
): string {
|
||||
// Function selector: keccak256("swapExactTokensForETH(uint256,uint256,address[],address,uint256)") first 4 bytes
|
||||
const selector = '18cbafe5';
|
||||
|
||||
const amountInEnc = encodeUint256(amountIn);
|
||||
const amountOutMinEnc = encodeUint256(amountOutMin);
|
||||
const offsetToPath = encodeUint256(160n); // 5 * 32 bytes offset
|
||||
const toEnc = encodeAddress(to);
|
||||
const deadlineEnc = encodeUint256(deadline);
|
||||
const pathLenEnc = encodeUint256(BigInt(path.length));
|
||||
const pathElements = path.map((addr) => encodeAddress(addr)).join('');
|
||||
|
||||
return selector + amountInEnc + amountOutMinEnc + offsetToPath + toEnc + deadlineEnc + pathLenEnc + pathElements;
|
||||
}
|
||||
|
||||
// ─── Internal Helpers ───
|
||||
|
||||
async function checkAllowance(
|
||||
owner: string,
|
||||
tokenContract: string,
|
||||
spender: string,
|
||||
signal: AbortSignal
|
||||
): Promise<bigint> {
|
||||
const parameter = encodeAddress(owner) + encodeAddress(spender);
|
||||
|
||||
const response = await fetch(`${TRONGRID_BASE}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers: tronHeaders(),
|
||||
signal,
|
||||
body: JSON.stringify({
|
||||
owner_address: owner,
|
||||
contract_address: tokenContract,
|
||||
function_selector: 'allowance(address,address)',
|
||||
parameter,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) return 0n;
|
||||
|
||||
const body = (await response.json()) as { constant_result?: string[] };
|
||||
const hex = body.constant_result?.[0];
|
||||
if (!hex || /^0+$/.test(hex)) return 0n;
|
||||
|
||||
return BigInt('0x' + hex);
|
||||
}
|
||||
|
||||
interface TriggerSmartContractParams {
|
||||
ownerAddress: string;
|
||||
contractAddress: string;
|
||||
functionSelector: string;
|
||||
parameter: string;
|
||||
callValue: number;
|
||||
feeLimit: number;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
async function buildTriggerSmartContract(
|
||||
params: TriggerSmartContractParams
|
||||
): Promise<{ txID: string; raw_data: unknown; raw_data_hex: string } | null> {
|
||||
const response = await fetch(`${TRONGRID_BASE}/wallet/triggersmartcontract`, {
|
||||
method: 'POST',
|
||||
headers: tronHeaders(),
|
||||
signal: params.signal,
|
||||
body: JSON.stringify({
|
||||
owner_address: params.ownerAddress,
|
||||
contract_address: params.contractAddress,
|
||||
function_selector: params.functionSelector,
|
||||
parameter: params.parameter,
|
||||
call_value: params.callValue,
|
||||
fee_limit: params.feeLimit,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const body = (await response.json()) as {
|
||||
result?: { result?: boolean; message?: string };
|
||||
transaction?: { txID: string; raw_data: unknown; raw_data_hex: string };
|
||||
};
|
||||
|
||||
if (!body.result?.result || !body.transaction) {
|
||||
const errorMsg = body.result?.message
|
||||
? Buffer.from(body.result.message, 'hex').toString('utf8')
|
||||
: 'Transaction build failed';
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return body.transaction;
|
||||
}
|
||||
@@ -3,27 +3,6 @@ import { WalletController } from '../controllers/wallet.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/create', WalletController.createWallet);
|
||||
router.get('/', WalletController.getWallets);
|
||||
router.post('/mnemonic/reveal', WalletController.revealMnemonic);
|
||||
|
||||
// IMPORTANT: /portfolio ДОЛЖЕН быть ПЕРЕД /:chain/... иначе express матчит chain='portfolio'.
|
||||
router.get('/portfolio', WalletController.getPortfolio);
|
||||
|
||||
router.get('/:chain/balance', WalletController.getChainBalance);
|
||||
router.get('/:chain/transactions', WalletController.getChainTransactions);
|
||||
router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions);
|
||||
// IMPORTANT: more specific paths ДОЛЖНЫ быть зарегистрированы РАНЬШЕ — Express сматчит first.
|
||||
// /:chain/send/cost-estimate ПЕРЕД /:chain/send
|
||||
// /:chain/swap/quote ПЕРЕД /:chain/swap
|
||||
// /:chain/swap/cost-estimate ПЕРЕД /:chain/swap
|
||||
router.post('/:chain/send/cost-estimate', WalletController.estimateSendCost);
|
||||
router.post('/:chain/send', WalletController.sendFromChain);
|
||||
router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx);
|
||||
router.post('/:chain/swap/quote', WalletController.quoteSwap);
|
||||
router.post('/:chain/swap/cost-estimate', WalletController.estimateSwapCost);
|
||||
router.post('/:chain/swap', WalletController.swapOnChain);
|
||||
router.post('/:chain/app-fee', WalletController.appFeeTransfer);
|
||||
router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx);
|
||||
|
||||
export default router;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,120 +0,0 @@
|
||||
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
|
||||
import { fetchVaultKV2 } from '../config/vault';
|
||||
|
||||
/**
|
||||
* Symmetric encryption (AES-256-GCM) для хранения мнемоник юзеров.
|
||||
* Master-key читается из Vault при старте.
|
||||
*
|
||||
* Storage layout (base64): IV(12) || ciphertext(N) || authTag(16)
|
||||
*
|
||||
* Ключ — 32 байта (256 бит), храним в Buffer, нигде на диск не пишем.
|
||||
* Если ключ не загружен — encrypt/decrypt бросают ошибку (fail-secure).
|
||||
*/
|
||||
|
||||
const KEY_LEN = 32;
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
let masterKey: Buffer | null = null;
|
||||
|
||||
/**
|
||||
* Установить master-key (вызывается ОДНОКРАТНО при первом старте).
|
||||
* Повторная установка после успешной загрузки запрещена (это бы убило все
|
||||
* существующие encrypted_mnemonic).
|
||||
*/
|
||||
export function swapMasterKey(newKey: Buffer): void {
|
||||
if (!newKey || newKey.length !== KEY_LEN) {
|
||||
throw new Error(`swapMasterKey: invalid key (expected ${KEY_LEN} bytes)`);
|
||||
}
|
||||
if (masterKey) {
|
||||
throw new Error('swapMasterKey: master key already loaded; rotation is not supported');
|
||||
}
|
||||
masterKey = newKey;
|
||||
}
|
||||
|
||||
export function masterKeyMatches(candidate: Buffer): boolean {
|
||||
if (!masterKey || !candidate || candidate.length !== KEY_LEN) return false;
|
||||
return masterKey.equals(candidate);
|
||||
}
|
||||
|
||||
export function isCryptoReady(): boolean {
|
||||
return masterKey !== null && masterKey.length === KEY_LEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fetch master-key из Vault. НЕ мутирует глобал — возвращает Buffer.
|
||||
* Throws при отсутствии или невалидном формате.
|
||||
*/
|
||||
export async function fetchMasterKey(
|
||||
addr: string,
|
||||
token: string,
|
||||
mount: string,
|
||||
path: string,
|
||||
): Promise<Buffer> {
|
||||
const secrets = await fetchVaultKV2(addr, token, mount, path);
|
||||
if (!secrets) {
|
||||
throw new Error('Failed to load crypto master key from Vault');
|
||||
}
|
||||
|
||||
// H12 — pin one field name. Multiple aliases → silent key drift on Vault misconfig.
|
||||
// Reject if alternates present but primary missing → signals misconfig.
|
||||
const raw = secrets.key;
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
if (secrets.master_key || secrets.MASTER_KEY) {
|
||||
throw new Error('Crypto master key misconfigured: Vault has alternate field but missing canonical "key"');
|
||||
}
|
||||
throw new Error('Crypto master key invalid: expected hex string in Vault field "key"');
|
||||
}
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(raw)) {
|
||||
throw new Error('Crypto master key invalid: must be 64-char hex (32 bytes)');
|
||||
}
|
||||
|
||||
const buf = Buffer.from(raw, 'hex');
|
||||
if (buf.length !== KEY_LEN) {
|
||||
throw new Error(`Crypto master key invalid: got ${buf.length} bytes, expected ${KEY_LEN}`);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function encryptMnemonic(plaintext: string): string {
|
||||
if (!masterKey) {
|
||||
throw new Error('Crypto service not ready');
|
||||
}
|
||||
if (typeof plaintext !== 'string' || plaintext.length === 0) {
|
||||
throw new Error('encryptMnemonic: plaintext must be non-empty string');
|
||||
}
|
||||
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv('aes-256-gcm', masterKey, iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return Buffer.concat([iv, ct, tag]).toString('base64');
|
||||
}
|
||||
|
||||
export function decryptMnemonic(blob: string): string {
|
||||
if (!masterKey) {
|
||||
throw new Error('Crypto service not ready');
|
||||
}
|
||||
if (typeof blob !== 'string' || blob.length === 0) {
|
||||
throw new Error('decryptMnemonic: blob must be non-empty string');
|
||||
}
|
||||
|
||||
const buf = Buffer.from(blob, 'base64');
|
||||
if (buf.length < IV_LEN + TAG_LEN + 1) {
|
||||
throw new Error('decryptMnemonic: blob too short');
|
||||
}
|
||||
|
||||
const iv = buf.subarray(0, IV_LEN);
|
||||
const tag = buf.subarray(buf.length - TAG_LEN);
|
||||
const ct = buf.subarray(IV_LEN, buf.length - TAG_LEN);
|
||||
|
||||
const decipher = createDecipheriv('aes-256-gcm', masterKey, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
try {
|
||||
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
throw new Error('decryptMnemonic: authentication failed');
|
||||
}
|
||||
}
|
||||
@@ -1,422 +1,115 @@
|
||||
import crypto from 'crypto';
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import { SignJWT, jwtVerify } from 'jose';
|
||||
import { fetchVaultKV2 } from '../config/vault';
|
||||
import { env } from '../config/env';
|
||||
import { env, getVaultToken } from '../config/env';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
/**
|
||||
* CSRF validation compatible with Python `itsdangerous` URLSafeTimedSerializer (Flask-WTF).
|
||||
* Token: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
|
||||
*
|
||||
* Vault: read-only. `secret_key` обязателен; `salt`/`digest` опциональны (дефолты при отсутствии).
|
||||
*
|
||||
* itsdangerous 2.x по умолчанию: key_derivation=django-concat, digest=sha1.
|
||||
* Старый Node-код использовал legacy-hmac-signer — из-за этого prod 403 при верном secret_key.
|
||||
*/
|
||||
export const CSRF_COOKIE_NAME = 'csrf_token';
|
||||
|
||||
const ITSDANGEROUS_EPOCH = 1293840000;
|
||||
export const CSRF_HEADER_NAME = 'X-CSRF-Token';
|
||||
|
||||
const DEFAULT_SALT = 'itsdangerous.Signer';
|
||||
const LEGACY_VERIFY_SALTS = [
|
||||
'csrf-salt',
|
||||
'csrf',
|
||||
'csrf-token',
|
||||
'wtf',
|
||||
'wtf-csrf',
|
||||
'itsdangerous.Signer',
|
||||
] as const;
|
||||
const TTL_SECONDS = 3600;
|
||||
|
||||
/** Порядок: сначала то, что реально ставит itsdangerous 2.x / Flask-WTF. */
|
||||
const KEY_DERIVATIONS = [
|
||||
'django-concat',
|
||||
'legacy-hmac-signer',
|
||||
'hmac',
|
||||
'concat',
|
||||
'none',
|
||||
] as const;
|
||||
let signingKeyBytes: Uint8Array | null = null;
|
||||
|
||||
export type CsrfKeyDerivation = (typeof KEY_DERIVATIONS)[number];
|
||||
|
||||
export interface CsrfConfig {
|
||||
secret: string;
|
||||
salt: string;
|
||||
digest: 'sha256' | 'sha512';
|
||||
maxAgeSec: number;
|
||||
saltFromVault: boolean;
|
||||
digestFromVault: boolean;
|
||||
}
|
||||
|
||||
let current: CsrfConfig | null = null;
|
||||
|
||||
export function swapCsrfConfig(cfg: CsrfConfig | null): void {
|
||||
current = cfg;
|
||||
}
|
||||
|
||||
export function isCsrfConfigured(): boolean {
|
||||
return current !== null;
|
||||
}
|
||||
|
||||
export function csrfSecretFingerprint(secret: string): string {
|
||||
return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
export interface CsrfConfigSummary {
|
||||
mount: string;
|
||||
path: string;
|
||||
salt: string;
|
||||
digest: 'sha256' | 'sha512';
|
||||
maxAgeSec: number;
|
||||
secretFp: string;
|
||||
saltSource: 'vault' | 'default';
|
||||
digestSource: 'vault' | 'default';
|
||||
}
|
||||
|
||||
export function getCsrfConfigSummary(): CsrfConfigSummary | null {
|
||||
if (!current || !env.vault.csrfPath) return null;
|
||||
return {
|
||||
mount: env.vault.mount,
|
||||
path: env.vault.csrfPath,
|
||||
salt: current.salt,
|
||||
digest: current.digest,
|
||||
maxAgeSec: current.maxAgeSec,
|
||||
secretFp: csrfSecretFingerprint(current.secret),
|
||||
saltSource: current.saltFromVault ? 'vault' : 'default',
|
||||
digestSource: current.digestFromVault ? 'vault' : 'default',
|
||||
};
|
||||
}
|
||||
|
||||
export function logCsrfConfigLoaded(): void {
|
||||
const summary = getCsrfConfigSummary();
|
||||
if (!summary) return;
|
||||
logger.info(
|
||||
`CSRF config loaded: mount=${summary.mount} path=${summary.path} ` +
|
||||
`salt="${summary.salt}" digest=${summary.digest} maxAgeSec=${summary.maxAgeSec} ` +
|
||||
`salt_source=${summary.saltSource} digest_source=${summary.digestSource} ` +
|
||||
`secret_fp=${summary.secretFp} verify_key_derivations=${KEY_DERIVATIONS.join(',')}`,
|
||||
);
|
||||
if (summary.saltSource === 'default') {
|
||||
logger.warn(
|
||||
'CSRF salt missing in Vault KV — using default itsdangerous.Signer (read-only; verify uses legacy salt matrix)',
|
||||
);
|
||||
export function setCsrfSigningKey(secret: string): void {
|
||||
if (secret.length < 32) {
|
||||
throw new Error('CSRF secret must be at least 32 characters');
|
||||
}
|
||||
signingKeyBytes = new Uint8Array(createHash('sha256').update(secret, 'utf8').digest());
|
||||
}
|
||||
|
||||
export async function fetchCsrfConfig(
|
||||
addr: string,
|
||||
token: string,
|
||||
mount: string,
|
||||
path: string,
|
||||
): Promise<CsrfConfig> {
|
||||
const secrets = await fetchVaultKV2(addr, token, mount, path);
|
||||
if (!secrets) {
|
||||
throw new Error('Failed to load CSRF secret from Vault');
|
||||
|
||||
export function hasCsrfSigningKey(): boolean {
|
||||
return signingKeyBytes !== null;
|
||||
}
|
||||
|
||||
|
||||
function getKey(): Uint8Array {
|
||||
if (!signingKeyBytes) {
|
||||
throw new Error('CSRF signing key not configured');
|
||||
}
|
||||
|
||||
const secret =
|
||||
secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret;
|
||||
if (!secret || typeof secret !== 'string' || secret.length < 32) {
|
||||
throw new Error('CSRF secret invalid: must be string >= 32 chars');
|
||||
}
|
||||
|
||||
let saltFromVault = false;
|
||||
let salt = DEFAULT_SALT;
|
||||
if (secrets.salt && typeof secrets.salt === 'string' && secrets.salt.length >= 1) {
|
||||
salt = secrets.salt;
|
||||
saltFromVault = true;
|
||||
}
|
||||
|
||||
let digestFromVault = false;
|
||||
let digest: 'sha256' | 'sha512' = 'sha256';
|
||||
if (secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
||||
digest = secrets.digest;
|
||||
digestFromVault = true;
|
||||
}
|
||||
|
||||
let maxAgeSec = 60 * 60 * 24 * 7;
|
||||
if (secrets.max_age_sec) {
|
||||
const n = parseInt(String(secrets.max_age_sec), 10);
|
||||
if (!Number.isNaN(n) && n > 0) maxAgeSec = n;
|
||||
}
|
||||
|
||||
return { secret, salt, digest, maxAgeSec, saltFromVault, digestFromVault };
|
||||
return signingKeyBytes;
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string): Buffer {
|
||||
const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4);
|
||||
const padded = s + '='.repeat(pad);
|
||||
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
}
|
||||
|
||||
/** itsdangerous 2.x default: hash(salt + b"signer" + secret_key). */
|
||||
function deriveKeyDjangoConcat(secret: string, salt: string, digest: string): Buffer {
|
||||
const data = Buffer.concat([
|
||||
Buffer.from(salt, 'utf8'),
|
||||
Buffer.from('signer', 'utf8'),
|
||||
Buffer.from(secret, 'utf8'),
|
||||
]);
|
||||
return crypto.createHash(digest).update(data).digest();
|
||||
}
|
||||
|
||||
/** itsdangerous key_derivation="hmac": HMAC(secret, salt). */
|
||||
function deriveKeyHmac(secret: string, salt: string, digest: string): Buffer {
|
||||
return crypto.createHmac(digest, secret).update(salt, 'utf8').digest();
|
||||
}
|
||||
|
||||
/** itsdangerous key_derivation="concat": hash(salt + secret). */
|
||||
function deriveKeyConcat(secret: string, salt: string, digest: string): Buffer {
|
||||
const data = Buffer.concat([Buffer.from(salt, 'utf8'), Buffer.from(secret, 'utf8')]);
|
||||
return crypto.createHash(digest).update(data).digest();
|
||||
}
|
||||
|
||||
/** Старый wallet / часть 1.x: HMAC(secret, salt + "signer"). */
|
||||
function deriveKeyLegacyHmacSigner(secret: string, salt: string, digest: string): Buffer {
|
||||
return crypto.createHmac(digest, secret).update(salt + 'signer').digest();
|
||||
}
|
||||
|
||||
export function deriveSigningKey(
|
||||
secret: string,
|
||||
salt: string,
|
||||
digest: string,
|
||||
mode: CsrfKeyDerivation,
|
||||
): Buffer {
|
||||
switch (mode) {
|
||||
case 'django-concat':
|
||||
return deriveKeyDjangoConcat(secret, salt, digest);
|
||||
case 'hmac':
|
||||
return deriveKeyHmac(secret, salt, digest);
|
||||
case 'concat':
|
||||
return deriveKeyConcat(secret, salt, digest);
|
||||
case 'legacy-hmac-signer':
|
||||
return deriveKeyLegacyHmacSigner(secret, salt, digest);
|
||||
case 'none':
|
||||
return Buffer.from(secret, 'utf8');
|
||||
default:
|
||||
return deriveKeyDjangoConcat(secret, salt, digest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Big-endian int from b64url timestamp chunk (без epoch). */
|
||||
function decodeTimestampRaw(encoded: string): number {
|
||||
const raw = b64urlDecode(encoded);
|
||||
let ts = 0;
|
||||
for (const b of raw) ts = ts * 256 + b;
|
||||
return ts;
|
||||
}
|
||||
|
||||
const TIMESTAMP_SKEW_SEC = 60;
|
||||
/** Ниже — отсекаем legacy raw, чтобы не путать с unix. */
|
||||
const MIN_PLAUSIBLE_UNIX = 1_577_836_800;
|
||||
|
||||
type TimestampCheck = 'ok' | 'future' | 'expired';
|
||||
|
||||
function checkIssuedAt(issuedAt: number, maxAgeSec: number): TimestampCheck {
|
||||
export async function issueCsrfToken(): Promise<string> {
|
||||
const nonce = randomBytes(24).toString('base64url');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (issuedAt > now + TIMESTAMP_SKEW_SEC) return 'future';
|
||||
if (now - issuedAt > maxAgeSec) return 'expired';
|
||||
return 'ok';
|
||||
return new SignJWT({ scope: 'csrf', nce: nonce })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt(now)
|
||||
.setNotBefore(now)
|
||||
.setExpirationTime(now + TTL_SECONDS)
|
||||
.sign(getKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* itsdangerous 2.x (prod auth): unix в payload.
|
||||
* Старый URLSafeTimedSerializer / test-jwt-signer: raw + ITSDANGEROUS_EPOCH.
|
||||
*/
|
||||
function verifyCsrfTimestamp(
|
||||
tsStr: string,
|
||||
maxAgeSec: number,
|
||||
): { ok: true; mode: 'unix' | 'legacy-epoch' } | { ok: false; reason: string } {
|
||||
let raw: number;
|
||||
try {
|
||||
raw = decodeTimestampRaw(tsStr);
|
||||
} catch {
|
||||
return { ok: false, reason: 'Invalid timestamp' };
|
||||
|
||||
export async function verifyCsrfPair(
|
||||
cookieToken: string | undefined,
|
||||
headerToken: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!cookieToken || !headerToken) {
|
||||
const e = new Error('CSRF token missing') as Error & { status: number };
|
||||
e.status = 403;
|
||||
throw e;
|
||||
}
|
||||
|
||||
const candidates: { issuedAt: number; mode: 'unix' | 'legacy-epoch' }[] = [
|
||||
{ issuedAt: raw, mode: 'unix' },
|
||||
{ issuedAt: raw + ITSDANGEROUS_EPOCH, mode: 'legacy-epoch' },
|
||||
];
|
||||
|
||||
let lastReason = 'Invalid timestamp';
|
||||
for (const { issuedAt, mode } of candidates) {
|
||||
if (issuedAt < MIN_PLAUSIBLE_UNIX) continue;
|
||||
const check = checkIssuedAt(issuedAt, maxAgeSec);
|
||||
if (check === 'ok') {
|
||||
if (mode === 'legacy-epoch') {
|
||||
logger.warn('CSRF timestamp decoded as legacy-epoch (itsdangerous 1.x / test signer)');
|
||||
}
|
||||
return { ok: true, mode };
|
||||
const a = Buffer.from(cookieToken);
|
||||
const b = Buffer.from(headerToken);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
const e = new Error('CSRF token mismatch') as Error & { status: number };
|
||||
e.status = 403;
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
const { payload } = await jwtVerify(cookieToken, getKey(), {
|
||||
algorithms: ['HS256'],
|
||||
clockTolerance: 10,
|
||||
});
|
||||
if (payload.scope !== 'csrf') {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
lastReason = check === 'future' ? 'Token from the future' : 'Token expired';
|
||||
}
|
||||
|
||||
return { ok: false, reason: lastReason };
|
||||
}
|
||||
|
||||
export interface CsrfVerifyResult {
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
actualSigLen?: number;
|
||||
expectedSigLen?: number;
|
||||
}
|
||||
|
||||
type CsrfDigest = 'sha1' | 'sha256' | 'sha512';
|
||||
|
||||
const DIGEST_BY_SIG_LEN: Record<number, CsrfDigest> = {
|
||||
20: 'sha1',
|
||||
32: 'sha256',
|
||||
64: 'sha512',
|
||||
};
|
||||
|
||||
const ALL_DIGESTS: CsrfDigest[] = ['sha1', 'sha256', 'sha512'];
|
||||
|
||||
const MIN_VERIFY_SALT_LEN = 1;
|
||||
|
||||
function verifyCsrfTokenWithParams(
|
||||
cfg: CsrfConfig,
|
||||
salt: string,
|
||||
digest: CsrfDigest,
|
||||
keyDerivation: CsrfKeyDerivation,
|
||||
token: string,
|
||||
): CsrfVerifyResult {
|
||||
if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' };
|
||||
|
||||
const lastDot = token.lastIndexOf('.');
|
||||
if (lastDot < 0) return { valid: false, reason: 'Malformed token (no signature)' };
|
||||
|
||||
const payloadTs = token.slice(0, lastDot);
|
||||
const sigStr = token.slice(lastDot + 1);
|
||||
|
||||
const prevDot = payloadTs.lastIndexOf('.');
|
||||
if (prevDot < 0) return { valid: false, reason: 'Malformed token (no timestamp)' };
|
||||
|
||||
const tsStr = payloadTs.slice(prevDot + 1);
|
||||
|
||||
const derived = deriveSigningKey(cfg.secret, salt, digest, keyDerivation);
|
||||
const expectedSig = crypto.createHmac(digest, derived).update(payloadTs).digest();
|
||||
|
||||
let actualSig: Buffer;
|
||||
try {
|
||||
actualSig = b64urlDecode(sigStr);
|
||||
} catch {
|
||||
return { valid: false, reason: 'Invalid signature encoding' };
|
||||
}
|
||||
|
||||
if (expectedSig.length !== actualSig.length) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Signature length mismatch',
|
||||
expectedSigLen: expectedSig.length,
|
||||
actualSigLen: actualSig.length,
|
||||
};
|
||||
}
|
||||
if (!crypto.timingSafeEqual(expectedSig, actualSig)) {
|
||||
return { valid: false, reason: 'Signature mismatch' };
|
||||
}
|
||||
|
||||
const tsResult = verifyCsrfTimestamp(tsStr, cfg.maxAgeSec);
|
||||
if (!tsResult.ok) {
|
||||
return { valid: false, reason: tsResult.reason };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function saltsToTry(vaultSalt: string): string[] {
|
||||
const out: string[] = [];
|
||||
const add = (s: string, minLen = MIN_VERIFY_SALT_LEN) => {
|
||||
if (s && s.length >= minLen && !out.includes(s)) out.push(s);
|
||||
};
|
||||
add(vaultSalt, 8);
|
||||
for (const s of LEGACY_VERIFY_SALTS) add(s);
|
||||
return out;
|
||||
}
|
||||
|
||||
function digestsToTry(primary: CsrfVerifyResult, vaultDigest: CsrfDigest): CsrfDigest[] {
|
||||
const order: CsrfDigest[] = [];
|
||||
const add = (d: CsrfDigest) => {
|
||||
if (!order.includes(d)) order.push(d);
|
||||
};
|
||||
add(vaultDigest);
|
||||
if (primary.actualSigLen !== undefined) {
|
||||
const inferred = DIGEST_BY_SIG_LEN[primary.actualSigLen];
|
||||
if (inferred) add(inferred);
|
||||
}
|
||||
for (const d of ALL_DIGESTS) add(d);
|
||||
return order;
|
||||
}
|
||||
|
||||
function derivationsToTry(primary: CsrfVerifyResult): CsrfKeyDerivation[] {
|
||||
const order: CsrfKeyDerivation[] = [...KEY_DERIVATIONS];
|
||||
if (primary.actualSigLen === 20) {
|
||||
// Prod auth: itsdangerous 2.x + sha1 → django-concat первым.
|
||||
return ['django-concat', ...order.filter((d) => d !== 'django-concat')];
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
function isRetryableVerifyFailure(reason?: string): boolean {
|
||||
return reason === 'Signature length mismatch' || reason === 'Signature mismatch';
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify: django-concat (itsdangerous 2.x) + legacy matrix (salt × digest × key_derivation).
|
||||
*/
|
||||
function inferSigLenFromToken(token: string): number | undefined {
|
||||
const lastDot = token.lastIndexOf('.');
|
||||
if (lastDot < 0) return undefined;
|
||||
try {
|
||||
return b64urlDecode(token.slice(lastDot + 1)).length;
|
||||
} catch {
|
||||
return undefined;
|
||||
const e = new Error('CSRF token invalid') as Error & { status: number };
|
||||
e.status = 403;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
||||
|
||||
const vaultSalt = current.salt;
|
||||
const vaultDigest = current.digest as CsrfDigest;
|
||||
const salts = saltsToTry(vaultSalt);
|
||||
const sigProbe: CsrfVerifyResult = { valid: false, actualSigLen: inferSigLenFromToken(token) };
|
||||
const derivations = derivationsToTry(sigProbe);
|
||||
|
||||
let lastMismatch: CsrfVerifyResult = { valid: false, reason: 'Signature mismatch' };
|
||||
|
||||
for (const keyDerivation of derivations) {
|
||||
for (const salt of salts) {
|
||||
for (const digest of digestsToTry(sigProbe.actualSigLen !== undefined ? sigProbe : lastMismatch, vaultDigest)) {
|
||||
const attempt = verifyCsrfTokenWithParams(
|
||||
current,
|
||||
salt,
|
||||
digest,
|
||||
keyDerivation,
|
||||
token,
|
||||
);
|
||||
if (attempt.valid) {
|
||||
const isPrimary =
|
||||
keyDerivation === 'django-concat' &&
|
||||
salt === vaultSalt &&
|
||||
digest === vaultDigest;
|
||||
if (!isPrimary) {
|
||||
logger.warn(
|
||||
`CSRF verified with fallback key_derivation=${keyDerivation} digest=${digest} salt="${salt}" ` +
|
||||
`(config digest=${vaultDigest} salt="${vaultSalt}"). Align auth with Vault metadata when possible.`,
|
||||
);
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
if (isRetryableVerifyFailure(attempt.reason)) {
|
||||
lastMismatch = attempt;
|
||||
} else if (attempt.reason && attempt.reason !== 'Signature mismatch') {
|
||||
return attempt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Signature mismatch (all digest/salt/key_derivation fallbacks failed)',
|
||||
actualSigLen: lastMismatch.actualSigLen,
|
||||
expectedSigLen: lastMismatch.expectedSigLen,
|
||||
};
|
||||
export function getCsrfCookieMaxAgeMs(): number {
|
||||
return TTL_SECONDS * 1000;
|
||||
}
|
||||
|
||||
|
||||
export async function loadCsrfSecretFromVault(): Promise<boolean> {
|
||||
const token = getVaultToken();
|
||||
if (!token || !env.vault.addr) {
|
||||
return false;
|
||||
}
|
||||
const data = await fetchVaultKV2(env.vault.addr, token, env.vault.mount, env.vault.csrfSecretPath);
|
||||
const key = data?.CSRF_SECRET_KEY;
|
||||
if (!key || key.length < 32) {
|
||||
logger.warn('Vault CSRF secret missing or too short');
|
||||
return false;
|
||||
}
|
||||
setCsrfSigningKey(key);
|
||||
logger.info('CSRF signing key loaded from Vault');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
export function finalizeCsrfConfigFromEnv(): void {
|
||||
if (hasCsrfSigningKey()) {
|
||||
return;
|
||||
}
|
||||
const k = process.env.CSRF_SECRET_KEY;
|
||||
if (k && k.length >= 32) {
|
||||
setCsrfSigningKey(k);
|
||||
logger.info('CSRF signing key loaded from environment');
|
||||
return;
|
||||
}
|
||||
logger.error('CSRF_SECRET_KEY is required (Vault path or env, min 32 characters)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* Gas oracle для EVM-чейнов (ETH/BSC). Парсит fees из RPC через `eth_feeHistory` —
|
||||
* это запрос за последние N блоков с percentile-distribution of priority tips.
|
||||
*
|
||||
* Возвращает 3 tier'а:
|
||||
* slow — p25 priority (дешевле, дольше confirmation)
|
||||
* normal — p50 priority (median, рекомендованный default)
|
||||
* fast — p75 priority (быстрее, дороже)
|
||||
*
|
||||
* maxFeePerGas = baseFee_latest * 2 + maxPriorityFeePerGas (стандартная EIP-1559 формула).
|
||||
*
|
||||
* Floor:
|
||||
* - BSC baseFee часто ~0 (chain не полностью EIP-1559) → без floor получится 0.001 gwei
|
||||
* которое реджектится min-relay. Floor = 0.05 gwei.
|
||||
* - ETH реалистичный min ~1 gwei.
|
||||
*
|
||||
* Cap (sanity check): чтобы compromised RPC не подсунул insanely-high fees.
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
|
||||
const BSC_RPC = 'https://bsc-dataseed.binance.org';
|
||||
|
||||
const RPC: Record<'ETH' | 'BSC', string> = { ETH: ETH_RPC, BSC: BSC_RPC };
|
||||
|
||||
// Realistic mainnet floors (gwei). 0 = without floor (use raw eth_feeHistory value).
|
||||
// ETH: убран floor — eth_feeHistory сам по себе репрезентативный, искусственный floor
|
||||
// перерасходовал gas в spam/low-traffic блоках.
|
||||
// BSC: оставлен низкий floor — chain не полностью EIP-1559, без минимума получается
|
||||
// 0.001 gwei который reject'ится min-relay.
|
||||
const FLOOR_GWEI: Record<'ETH' | 'BSC', string> = {
|
||||
ETH: '0',
|
||||
BSC: '0.05',
|
||||
};
|
||||
|
||||
// Sanity cap (gwei). Соответствует MAX_GAS_PRICE_GWEI=500 в signer'е.
|
||||
const CAP_GWEI = 500;
|
||||
|
||||
const BLOCKS_TO_SAMPLE = 5;
|
||||
const PERCENTILES = [25, 50, 75]; // slow, normal, fast
|
||||
|
||||
export type FeeTier = 'slow' | 'normal' | 'fast';
|
||||
|
||||
export interface FeeQuote {
|
||||
maxFeePerGas: string; // wei (decimal string)
|
||||
maxPriorityFeePerGas: string; // wei (decimal string)
|
||||
gweiTotal: number; // display-friendly (rounded to 3 dec)
|
||||
gweiPriority: number;
|
||||
}
|
||||
|
||||
export interface FeeTiers {
|
||||
chain: 'ETH' | 'BSC';
|
||||
baseFeeGwei: number;
|
||||
slow: FeeQuote;
|
||||
normal: FeeQuote;
|
||||
fast: FeeQuote;
|
||||
}
|
||||
|
||||
function median(arr: ethers.BigNumber[]): ethers.BigNumber {
|
||||
if (arr.length === 0) return ethers.BigNumber.from(0);
|
||||
const sorted = [...arr].sort((a, b) => (a.lt(b) ? -1 : a.eq(b) ? 0 : 1));
|
||||
return sorted[Math.floor(sorted.length / 2)];
|
||||
}
|
||||
|
||||
function gweiNum(wei: ethers.BigNumber): number {
|
||||
return Math.round(Number(ethers.utils.formatUnits(wei, 'gwei')) * 1000) / 1000;
|
||||
}
|
||||
|
||||
export async function getEvmFeeTiers(chain: 'ETH' | 'BSC'): Promise<FeeTiers> {
|
||||
const provider = new ethers.providers.StaticJsonRpcProvider(RPC[chain]);
|
||||
const history = await provider.send('eth_feeHistory', [
|
||||
ethers.utils.hexValue(BLOCKS_TO_SAMPLE),
|
||||
'latest',
|
||||
PERCENTILES,
|
||||
]);
|
||||
|
||||
const baseFees: string[] = history.baseFeePerGas ?? [];
|
||||
const rewards: string[][] = history.reward ?? [];
|
||||
|
||||
// baseFee для следующего блока — последний элемент массива
|
||||
const baseFeeNext = baseFees.length > 0
|
||||
? ethers.BigNumber.from(baseFees[baseFees.length - 1])
|
||||
: ethers.BigNumber.from(0);
|
||||
|
||||
const floorWei = ethers.utils.parseUnits(FLOOR_GWEI[chain], 'gwei');
|
||||
const capWei = ethers.utils.parseUnits(String(CAP_GWEI), 'gwei');
|
||||
|
||||
const priorityForTier = (idx: number): ethers.BigNumber => {
|
||||
const vals = rewards
|
||||
.map((row) => row?.[idx])
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.map((v) => ethers.BigNumber.from(v));
|
||||
return median(vals);
|
||||
};
|
||||
|
||||
const buildQuote = (rawPriority: ethers.BigNumber): FeeQuote => {
|
||||
// Apply floor and cap
|
||||
let priority = rawPriority.lt(floorWei) ? floorWei : rawPriority;
|
||||
if (priority.gt(capWei)) priority = capWei;
|
||||
let maxFee = baseFeeNext.mul(2).add(priority);
|
||||
if (maxFee.gt(capWei)) maxFee = capWei;
|
||||
return {
|
||||
maxFeePerGas: maxFee.toString(),
|
||||
maxPriorityFeePerGas: priority.toString(),
|
||||
gweiTotal: gweiNum(maxFee),
|
||||
gweiPriority: gweiNum(priority),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
chain,
|
||||
baseFeeGwei: gweiNum(baseFeeNext),
|
||||
slow: buildQuote(priorityForTier(0)),
|
||||
normal: buildQuote(priorityForTier(1)),
|
||||
fast: buildQuote(priorityForTier(2)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить fee-quote для одного tier'а — utility для signer'а.
|
||||
*/
|
||||
export async function getEvmFeeForTier(
|
||||
chain: 'ETH' | 'BSC',
|
||||
tier: FeeTier,
|
||||
): Promise<FeeQuote> {
|
||||
const tiers = await getEvmFeeTiers(chain);
|
||||
return tiers[tier];
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as jose from 'jose';
|
||||
import { env } from '../config/env';
|
||||
import { fetchVaultKV2 } from '../config/vault';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export interface AccessTokenPayload {
|
||||
@@ -20,41 +19,21 @@ export interface AuthContext {
|
||||
token: AccessTokenPayload;
|
||||
}
|
||||
|
||||
type KeyType = Awaited<ReturnType<typeof jose.importSPKI>>;
|
||||
const keyMap = new Map<string, Awaited<ReturnType<typeof jose.importSPKI>>>();
|
||||
|
||||
// Whitelist надёжных асимметричных алгоритмов. Никогда не разрешаем 'none'/HS*
|
||||
// (HS — симметричные, могли бы быть подставлены через algorithm confusion).
|
||||
const ALLOWED_ALGORITHMS = new Set(['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'EdDSA', 'PS256', 'PS384', 'PS512']);
|
||||
|
||||
if (!ALLOWED_ALGORITHMS.has(env.jwt.algorithm)) {
|
||||
throw new Error(`JWT_ALGORITHM "${env.jwt.algorithm}" not allowed. Use one of: ${[...ALLOWED_ALGORITHMS].join(', ')}`);
|
||||
}
|
||||
|
||||
// Live key store — атомарно подменяется через swapKeyMap()
|
||||
let keyMap: Map<string, KeyType> = new Map();
|
||||
|
||||
export function swapKeyMap(newMap: Map<string, KeyType>): void {
|
||||
keyMap = newMap;
|
||||
}
|
||||
|
||||
export function getKeyMapSize(): number {
|
||||
return keyMap.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fetch JWT public keys from Vault, не мутируя глобальный keyMap.
|
||||
* Возвращает новую Map для атомарного swap'а.
|
||||
*/
|
||||
export async function fetchJwtKeysFromVault(
|
||||
export async function loadJwtKeysFromVault(
|
||||
vaultAddr: string,
|
||||
vaultToken: string,
|
||||
mount: string,
|
||||
kidPath: string,
|
||||
kidsPrefix: string,
|
||||
): Promise<Map<string, KeyType>> {
|
||||
): Promise<void> {
|
||||
const { fetchVaultKV2 } = await import('../config/vault');
|
||||
|
||||
const kidData = await fetchVaultKV2(vaultAddr, vaultToken, mount, kidPath);
|
||||
if (!kidData) {
|
||||
throw new Error('Failed to read JWT kid config from Vault');
|
||||
logger.warn('Failed to read JWT kid config from Vault');
|
||||
return;
|
||||
}
|
||||
|
||||
const kids: string[] = [];
|
||||
@@ -62,11 +41,10 @@ export async function fetchJwtKeysFromVault(
|
||||
if (kidData.previous && kidData.previous !== kidData.active) kids.push(kidData.previous);
|
||||
|
||||
if (kids.length === 0) {
|
||||
throw new Error('No active/previous kids found in Vault');
|
||||
logger.warn('No active/previous kids found in Vault');
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Map<string, KeyType>();
|
||||
|
||||
for (const kid of kids) {
|
||||
const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`);
|
||||
if (!kidSecret?.public_key) {
|
||||
@@ -74,15 +52,16 @@ export async function fetchJwtKeysFromVault(
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
|
||||
next.set(kid, key);
|
||||
try {
|
||||
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
|
||||
keyMap.set(kid, key);
|
||||
logger.info(`Loaded JWT public key for kid=${kid}`);
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to import public key for kid=${kid}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (next.size === 0) {
|
||||
throw new Error('No public keys could be loaded from Vault');
|
||||
}
|
||||
|
||||
return next;
|
||||
logger.info(`JWT key store loaded: ${keyMap.size} key(s)`);
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(token: string): Promise<AuthContext> {
|
||||
@@ -92,17 +71,17 @@ export async function verifyAccessToken(token: string): Promise<AuthContext> {
|
||||
const header = jose.decodeProtectedHeader(token);
|
||||
const kid = header.kid;
|
||||
|
||||
if (!kid || typeof kid !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(kid)) {
|
||||
throw Object.assign(new Error('Missing or invalid kid in token header'), { status: 401 });
|
||||
if (!kid) {
|
||||
throw Object.assign(new Error('Missing kid in token header'), { status: 401 });
|
||||
}
|
||||
|
||||
const key = keyMap.get(kid);
|
||||
if (!key) {
|
||||
logger.warn(`Unknown kid=${kid}`);
|
||||
throw Object.assign(new Error('Unknown token kid'), { status: 401 });
|
||||
}
|
||||
|
||||
// Двойная защита от algorithm confusion: проверяем точное совпадение
|
||||
if (header.alg !== env.jwt.algorithm || !ALLOWED_ALGORITHMS.has(String(header.alg))) {
|
||||
if (header.alg !== env.jwt.algorithm) {
|
||||
throw Object.assign(new Error('Invalid token algorithm'), { status: 401 });
|
||||
}
|
||||
|
||||
@@ -127,13 +106,8 @@ export async function verifyAccessToken(token: string): Promise<AuthContext> {
|
||||
throw Object.assign(new Error('Invalid token type'), { status: 401 });
|
||||
}
|
||||
|
||||
// Строгая валидация sub/sid — иначе number/__proto__/10MB строки попадают в PG / в req.auth.
|
||||
const SUB_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
if (typeof payload.sub !== 'string' || !SUB_RE.test(payload.sub)) {
|
||||
throw Object.assign(new Error('Invalid sub claim'), { status: 401 });
|
||||
}
|
||||
if (typeof payload.sid !== 'string' || !SUB_RE.test(payload.sid)) {
|
||||
throw Object.assign(new Error('Invalid sid claim'), { status: 401 });
|
||||
if (!payload.sub || !payload.sid) {
|
||||
throw Object.assign(new Error('Missing token claims'), { status: 401 });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { env } from '../config/env';
|
||||
import { vaultAppRoleLogin } from '../config/vault';
|
||||
import { fetchJwtKeysFromVault, swapKeyMap, getKeyMapSize } from './jwt.service';
|
||||
import { fetchCsrfConfig, swapCsrfConfig, logCsrfConfigLoaded } from './csrf.service';
|
||||
import { fetchMasterKey, swapMasterKey, masterKeyMatches, isCryptoReady } from './crypto.service';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Inflight guard — reentrant calls share the same promise (audit#4 C2/C3).
|
||||
// Без этого две параллельные refreshAllKeys могут torn-state'ить keyMap/csrf/crypto.
|
||||
let inflight: Promise<RefreshResult> | null = null;
|
||||
|
||||
// H15/H18 — track refresh outcomes для health endpoint + alarm.
|
||||
export interface RefreshResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
let lastRefresh: RefreshResult = { ok: false, reason: 'not_yet_run', timestamp: 0 };
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
export function getLastRefreshResult(): RefreshResult {
|
||||
return lastRefresh;
|
||||
}
|
||||
export function getConsecutiveFailures(): number {
|
||||
return consecutiveFailures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic refresh: pre-fetch JWT/CSRF/crypto secrets, swap globals только если необходимые получены.
|
||||
* Reentrant-safe.
|
||||
* H18 — returns RefreshResult так что caller знает реально ли refresh succeeded.
|
||||
*/
|
||||
export async function refreshAllKeys(): Promise<RefreshResult> {
|
||||
if (inflight) return inflight;
|
||||
inflight = doRefresh().finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
async function doRefresh(): Promise<RefreshResult> {
|
||||
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath, cryptoKeyPath } = env.vault;
|
||||
const fail = (reason: string): RefreshResult => {
|
||||
consecutiveFailures += 1;
|
||||
lastRefresh = { ok: false, reason, timestamp: Date.now() };
|
||||
logger.error(`Vault refresh failed: ${reason} (consecutive=${consecutiveFailures})`);
|
||||
return lastRefresh;
|
||||
};
|
||||
|
||||
if (!addr || !roleId || !secretId) {
|
||||
return fail('vault_not_configured');
|
||||
}
|
||||
|
||||
// КАЖДЫЙ refresh — свежий AppRole login. Vault token TTL обычно ≤1 час.
|
||||
const token = await vaultAppRoleLogin(addr, roleId, secretId);
|
||||
if (!token) {
|
||||
return fail('approle_login_failed');
|
||||
}
|
||||
|
||||
const jwtPromise = fetchJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
|
||||
const csrfPromise = csrfPath ? fetchCsrfConfig(addr, token, mount, csrfPath) : Promise.resolve(null);
|
||||
const cryptoPromise = cryptoKeyPath ? fetchMasterKey(addr, token, mount, cryptoKeyPath) : Promise.resolve(null);
|
||||
|
||||
const [jwtResult, csrfResult, cryptoResult] = await Promise.allSettled([jwtPromise, csrfPromise, cryptoPromise]);
|
||||
|
||||
if (jwtResult.status === 'rejected') {
|
||||
return fail(`jwt_fetch_failed: ${jwtResult.reason?.message || jwtResult.reason}`);
|
||||
}
|
||||
if (csrfPath && csrfResult.status === 'rejected') {
|
||||
return fail(`csrf_fetch_failed: ${csrfResult.reason?.message || csrfResult.reason}`);
|
||||
}
|
||||
// Master-key: первый load обязателен, дальнейшие failures толерантны.
|
||||
if (cryptoKeyPath && !isCryptoReady() && cryptoResult.status === 'rejected') {
|
||||
return fail(`crypto_fetch_failed: ${cryptoResult.reason?.message || cryptoResult.reason}`);
|
||||
}
|
||||
|
||||
// Atomic swap. JS single-threaded → observers видят либо все старые, либо все новые.
|
||||
swapKeyMap(jwtResult.value);
|
||||
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
|
||||
swapCsrfConfig(csrfResult.value);
|
||||
logCsrfConfigLoaded();
|
||||
}
|
||||
if (cryptoResult.status === 'fulfilled' && cryptoResult.value) {
|
||||
if (!isCryptoReady()) {
|
||||
swapMasterKey(cryptoResult.value);
|
||||
logger.info('Crypto master key loaded');
|
||||
} else if (!masterKeyMatches(cryptoResult.value)) {
|
||||
// H16 — master-key drift detected. По умолчанию FATAL: если operator не выставил
|
||||
// ALLOW_MASTER_KEY_ROTATION=true explicitly, мы НЕ продолжаем silently на старом key.
|
||||
const allowRotation = process.env.ALLOW_MASTER_KEY_ROTATION === 'true';
|
||||
const msg = 'Vault crypto/master key DIFFERS from in-memory key. ALL existing encrypted_mnemonic will become undecryptable.';
|
||||
if (allowRotation) {
|
||||
logger.warn(msg + ' (continuing because ALLOW_MASTER_KEY_ROTATION=true)');
|
||||
} else {
|
||||
logger.error(msg + ' Set ALLOW_MASTER_KEY_ROTATION=true to acknowledge migration intent. FATAL — service will exit.');
|
||||
// Defer exit so rest of refresh logs flush
|
||||
setImmediate(() => process.exit(1));
|
||||
return fail('master_key_drift');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
consecutiveFailures = 0;
|
||||
lastRefresh = { ok: true, timestamp: Date.now() };
|
||||
logger.info(
|
||||
`Keys refreshed atomically: JWT keys=${getKeyMapSize()}` +
|
||||
(csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '') +
|
||||
`, Crypto=${isCryptoReady() ? 'ready' : 'NOT-READY'}`
|
||||
);
|
||||
return lastRefresh;
|
||||
}
|
||||
|
||||
export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void {
|
||||
if (timer) return;
|
||||
timer = setInterval(() => {
|
||||
logger.info('Refreshing keys from Vault...');
|
||||
void refreshAllKeys().catch((err) =>
|
||||
logger.error(`Key rotation tick failed: ${err?.message || err}`)
|
||||
);
|
||||
}, intervalMs);
|
||||
logger.info(`Key rotation scheduled (every ${intervalMs}ms)`);
|
||||
}
|
||||
|
||||
export function stopKeyRotation(): void {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
logger.info('Key rotation stopped');
|
||||
}
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
/**
|
||||
* USD price oracle for wallet balance responses + 24h change percentage.
|
||||
*
|
||||
* Data source: CoinGecko free API (https://api.coingecko.com/api/v3/simple/price).
|
||||
* Cache: KeyDB (Redis), TTL = 300s.
|
||||
*
|
||||
* Now also returns `change24h` (price change percent over rolling 24h) — used by
|
||||
* `/api/prices/dynamics`. Existing helpers `getPricesByIds` / `getPricesBySymbols`
|
||||
* остаются backward-compatible (возвращают только number | null) — для них достаточно
|
||||
* `usd` поля из cache.
|
||||
*
|
||||
* Security (см. план §"Security checklist"):
|
||||
* S1 — whitelist через getCoingeckoId → user input не попадает в URL.
|
||||
* S2 — лимит размеров вызовов через caller (controller `/prices`).
|
||||
* S3 — strict typeof/Number.isFinite/>=0 при чтении cache.
|
||||
* S4 — in-flight dedup (см. `_inflight` map) + cache.
|
||||
* S5 — никаких stack-trace'ов наружу; ошибки в logger.
|
||||
* S9 — CG API key, если задан, идёт ТОЛЬКО в header (не в URL).
|
||||
* S10 — `Number.isFinite` guard для usdValue (применяется в `wallet-ops.service.ts`).
|
||||
* S11 — жёсткий 5s AbortController timeout.
|
||||
* S12 — `null` ответ не кэшируем; только успешные числа уходят в Redis.
|
||||
*/
|
||||
|
||||
import { getRedis } from '../config/redis';
|
||||
import { logger } from '../lib/logger';
|
||||
import { getCoingeckoId } from '../lib/token-registry';
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
|
||||
const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price';
|
||||
const CACHE_TTL_SECONDS = 300;
|
||||
const CACHE_KEY_PREFIX = 'price:';
|
||||
const FETCH_TIMEOUT_MS = 5000;
|
||||
const MAX_IDS_PER_REQUEST = 100;
|
||||
|
||||
export interface PriceWithChange {
|
||||
usd: number;
|
||||
change24h: number | null; // например -1.38 (= -1.38%), 0.06, null если CG не отдал
|
||||
}
|
||||
|
||||
interface CachedPrice {
|
||||
usd: number;
|
||||
change24h: number | null;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
/** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */
|
||||
const _inflight = new Map<string, Promise<Record<string, PriceWithChange | null>>>();
|
||||
|
||||
function isValidPrice(n: unknown): n is number {
|
||||
return typeof n === 'number' && Number.isFinite(n) && n >= 0;
|
||||
}
|
||||
|
||||
function isValidChange(n: unknown): n is number {
|
||||
// change24h может быть negative (падение цены), но конечное число
|
||||
return typeof n === 'number' && Number.isFinite(n);
|
||||
}
|
||||
|
||||
function buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
const key = process.env.COINGECKO_API_KEY;
|
||||
if (key && key.length > 0) {
|
||||
headers['x-cg-demo-api-key'] = key;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches CoinGecko /simple/price for a batch of coin ids.
|
||||
* Now includes `include_24hr_change=true` — отдаёт usd_24h_change поле.
|
||||
*/
|
||||
async function fetchCoingecko(ids: string[]): Promise<Record<string, PriceWithChange | null>> {
|
||||
const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd&include_24hr_change=true`;
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: ctrl.signal,
|
||||
headers: buildHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger.warn(`CoinGecko HTTP ${res.status} for ${ids.length} ids`);
|
||||
const out: Record<string, PriceWithChange | null> = {};
|
||||
for (const id of ids) out[id] = null;
|
||||
return out;
|
||||
}
|
||||
const json = (await res.json()) as Record<string, { usd?: unknown; usd_24h_change?: unknown }>;
|
||||
const out: Record<string, PriceWithChange | null> = {};
|
||||
for (const id of ids) {
|
||||
const usd = json?.[id]?.usd;
|
||||
const change = json?.[id]?.usd_24h_change;
|
||||
if (isValidPrice(usd)) {
|
||||
out[id] = {
|
||||
usd,
|
||||
change24h: isValidChange(change) ? change : null,
|
||||
};
|
||||
} else {
|
||||
out[id] = null;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch (err: any) {
|
||||
logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`);
|
||||
const out: Record<string, PriceWithChange | null> = {};
|
||||
for (const id of ids) out[id] = null;
|
||||
return out;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает USD-цены + 24h change для списка CoinGecko ids.
|
||||
* Никогда не throws — degrades to `null` per-id.
|
||||
*
|
||||
* Cache: read-through KeyDB, 300s TTL. Только валидные usd кэшируются (S12).
|
||||
* Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4).
|
||||
*/
|
||||
export async function getPricesWithChangeByIds(
|
||||
ids: string[],
|
||||
): Promise<Record<string, PriceWithChange | null>> {
|
||||
if (!Array.isArray(ids) || ids.length === 0) return {};
|
||||
|
||||
const uniqIds = Array.from(new Set(ids.filter((x) => typeof x === 'string' && x.length > 0)));
|
||||
if (uniqIds.length === 0) return {};
|
||||
|
||||
const result: Record<string, PriceWithChange | null> = {};
|
||||
let redis: ReturnType<typeof getRedis> | null = null;
|
||||
try {
|
||||
redis = getRedis();
|
||||
} catch {
|
||||
redis = null;
|
||||
}
|
||||
|
||||
// 1) Read cache (pipeline)
|
||||
const misses: string[] = [];
|
||||
if (redis) {
|
||||
try {
|
||||
const pipeline = redis.pipeline();
|
||||
for (const id of uniqIds) pipeline.get(CACHE_KEY_PREFIX + id);
|
||||
const cached = await pipeline.exec();
|
||||
uniqIds.forEach((id, i) => {
|
||||
const tuple = cached?.[i];
|
||||
const raw = tuple?.[1] as string | null | undefined;
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as CachedPrice;
|
||||
if (isValidPrice(parsed?.usd)) {
|
||||
result[id] = {
|
||||
usd: parsed.usd,
|
||||
change24h: isValidChange(parsed?.change24h) ? parsed.change24h : null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// S3 — невалидный JSON в cache → fall through к refetch.
|
||||
}
|
||||
}
|
||||
misses.push(id);
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.warn(`Redis cache read failed: ${err?.message || 'unknown'}`);
|
||||
for (const id of uniqIds) {
|
||||
if (!(id in result)) misses.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const id of uniqIds) misses.push(id);
|
||||
}
|
||||
|
||||
if (misses.length === 0) return result;
|
||||
|
||||
// 2) Fetch misses в batches + in-flight dedup (S4).
|
||||
const fetched: Record<string, PriceWithChange | null> = {};
|
||||
for (let i = 0; i < misses.length; i += MAX_IDS_PER_REQUEST) {
|
||||
const batch = misses.slice(i, i + MAX_IDS_PER_REQUEST);
|
||||
const batchKey = batch.join('|');
|
||||
|
||||
let p = _inflight.get(batchKey);
|
||||
if (!p) {
|
||||
p = fetchCoingecko(batch).finally(() => _inflight.delete(batchKey));
|
||||
_inflight.set(batchKey, p);
|
||||
}
|
||||
const batchResult = await p;
|
||||
Object.assign(fetched, batchResult);
|
||||
}
|
||||
|
||||
// 3) Persist successes to cache (S12: skip nulls).
|
||||
if (redis) {
|
||||
try {
|
||||
const setP = redis.pipeline();
|
||||
let writes = 0;
|
||||
for (const [id, val] of Object.entries(fetched)) {
|
||||
if (val && isValidPrice(val.usd)) {
|
||||
setP.set(
|
||||
CACHE_KEY_PREFIX + id,
|
||||
JSON.stringify({
|
||||
usd: val.usd,
|
||||
change24h: val.change24h,
|
||||
ts: Date.now(),
|
||||
} satisfies CachedPrice),
|
||||
'EX',
|
||||
CACHE_TTL_SECONDS,
|
||||
);
|
||||
writes += 1;
|
||||
}
|
||||
}
|
||||
if (writes > 0) await setP.exec();
|
||||
} catch (err: any) {
|
||||
logger.warn(`Redis cache write failed: ${err?.message || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Merge fetched into result.
|
||||
for (const id of misses) {
|
||||
result[id] = id in fetched ? fetched[id] : null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible thin wrapper: возвращает только usd (без change24h).
|
||||
* Все существующие callers (portfolio, swap quote USD enrichment) используют это.
|
||||
*/
|
||||
export async function getPricesByIds(ids: string[]): Promise<Record<string, number | null>> {
|
||||
const rich = await getPricesWithChangeByIds(ids);
|
||||
const out: Record<string, number | null> = {};
|
||||
for (const id of Object.keys(rich)) {
|
||||
out[id] = rich[id]?.usd ?? null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience-обёртка для callers которые оперируют (chain, symbol) парами.
|
||||
*
|
||||
* Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null.
|
||||
* Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful).
|
||||
*/
|
||||
export async function getPricesBySymbols(
|
||||
pairs: { chain: ChainCode; symbol: string }[],
|
||||
): Promise<Map<string, number | null>> {
|
||||
const out = new Map<string, number | null>();
|
||||
if (!Array.isArray(pairs) || pairs.length === 0) return out;
|
||||
|
||||
const pairToId = new Map<string, string | null>();
|
||||
const idsToFetch = new Set<string>();
|
||||
|
||||
for (const { chain, symbol } of pairs) {
|
||||
const key = `${chain}:${symbol}`;
|
||||
if (pairToId.has(key)) continue;
|
||||
const id = getCoingeckoId(chain, symbol);
|
||||
pairToId.set(key, id);
|
||||
if (id) idsToFetch.add(id);
|
||||
else out.set(key, null);
|
||||
}
|
||||
|
||||
const prices = await getPricesByIds(Array.from(idsToFetch));
|
||||
|
||||
for (const [key, id] of pairToId.entries()) {
|
||||
if (out.has(key)) continue;
|
||||
if (!id) {
|
||||
out.set(key, null);
|
||||
continue;
|
||||
}
|
||||
out.set(key, prices[id] ?? null);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as getPricesBySymbols но возвращает PriceWithChange.
|
||||
* Используется в /api/prices/dynamics.
|
||||
*/
|
||||
export async function getPricesWithChangeBySymbols(
|
||||
pairs: { chain: ChainCode; symbol: string }[],
|
||||
): Promise<Map<string, PriceWithChange | null>> {
|
||||
const out = new Map<string, PriceWithChange | null>();
|
||||
if (!Array.isArray(pairs) || pairs.length === 0) return out;
|
||||
|
||||
const pairToId = new Map<string, string | null>();
|
||||
const idsToFetch = new Set<string>();
|
||||
|
||||
for (const { chain, symbol } of pairs) {
|
||||
const key = `${chain}:${symbol}`;
|
||||
if (pairToId.has(key)) continue;
|
||||
const id = getCoingeckoId(chain, symbol);
|
||||
pairToId.set(key, id);
|
||||
if (id) idsToFetch.add(id);
|
||||
else out.set(key, null);
|
||||
}
|
||||
|
||||
const prices = await getPricesWithChangeByIds(Array.from(idsToFetch));
|
||||
|
||||
for (const [key, id] of pairToId.entries()) {
|
||||
if (out.has(key)) continue;
|
||||
if (!id) {
|
||||
out.set(key, null);
|
||||
continue;
|
||||
}
|
||||
out.set(key, prices[id] ?? null);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Wallet generation: BIP39 mnemonic + multi-chain address derivation.
|
||||
* Server-side для custodial-флоу.
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import { createHash } from 'crypto';
|
||||
import bs58 from 'bs58';
|
||||
import * as bip39 from 'bip39';
|
||||
import { BIP32Factory } from 'bip32';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { Keypair } from '@solana/web3.js';
|
||||
import { derivePath } from 'ed25519-hd-key';
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
|
||||
const bip32 = BIP32Factory(ecc);
|
||||
|
||||
export const DERIVATION_PATHS: Record<ChainCode, string> = {
|
||||
ETH: "m/44'/60'/0'/0/0",
|
||||
BSC: "m/44'/60'/0'/0/0",
|
||||
BTC: "m/84'/0'/0'/0/0",
|
||||
TRX: "m/44'/195'/0'/0/0",
|
||||
SOL: "m/44'/501'/0'/0'",
|
||||
};
|
||||
|
||||
export const ALL_CHAINS: ChainCode[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL'];
|
||||
|
||||
export interface DerivedWallet {
|
||||
chain: ChainCode;
|
||||
address: string;
|
||||
derivationPath: string;
|
||||
}
|
||||
|
||||
export function generateMnemonic(): string {
|
||||
return bip39.generateMnemonic(128);
|
||||
}
|
||||
|
||||
export function validateMnemonic(m: string): boolean {
|
||||
return bip39.validateMnemonic(m);
|
||||
}
|
||||
|
||||
export async function deriveAllAddresses(mnemonic: string): Promise<DerivedWallet[]> {
|
||||
if (!bip39.validateMnemonic(mnemonic)) {
|
||||
throw new Error('Invalid mnemonic');
|
||||
}
|
||||
|
||||
const seed = await bip39.mnemonicToSeed(mnemonic);
|
||||
const seedHex = seed.toString('hex');
|
||||
|
||||
// ETH (BSC shares)
|
||||
const ethWallet = ethers.Wallet.fromMnemonic(mnemonic, DERIVATION_PATHS.ETH);
|
||||
const ethAddress = ethers.utils.getAddress(ethWallet.address);
|
||||
|
||||
// BTC P2WPKH bech32
|
||||
const btcRoot = bip32.fromSeed(seed);
|
||||
const btcChild = btcRoot.derivePath(DERIVATION_PATHS.BTC);
|
||||
if (!btcChild.publicKey) throw new Error('BTC derivation failed');
|
||||
const btcPayment = bitcoin.payments.p2wpkh({
|
||||
pubkey: Buffer.from(btcChild.publicKey),
|
||||
network: bitcoin.networks.bitcoin,
|
||||
});
|
||||
if (!btcPayment.address) throw new Error('BTC payment derivation failed');
|
||||
|
||||
// TRX (same secp256k1 + keccak256 as ETH, different encoding)
|
||||
const trxWallet = ethers.Wallet.fromMnemonic(mnemonic, DERIVATION_PATHS.TRX);
|
||||
const trxAddress = ethAddressToTron(trxWallet.address);
|
||||
|
||||
// SOL (ed25519)
|
||||
const { key: solKey } = derivePath(DERIVATION_PATHS.SOL, seedHex);
|
||||
if (!solKey || solKey.length !== 32) {
|
||||
throw new Error('SOL derivation produced invalid seed length');
|
||||
}
|
||||
const solKeypair = Keypair.fromSeed(solKey);
|
||||
const solAddress = solKeypair.publicKey.toBase58();
|
||||
|
||||
return [
|
||||
{ chain: 'ETH', address: ethAddress, derivationPath: DERIVATION_PATHS.ETH },
|
||||
{ chain: 'BSC', address: ethAddress, derivationPath: DERIVATION_PATHS.BSC },
|
||||
{ chain: 'BTC', address: btcPayment.address, derivationPath: DERIVATION_PATHS.BTC },
|
||||
{ chain: 'TRX', address: trxAddress, derivationPath: DERIVATION_PATHS.TRX },
|
||||
{ chain: 'SOL', address: solAddress, derivationPath: DERIVATION_PATHS.SOL },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ETH address (0x...) → TRX base58check (T...).
|
||||
* Используют одну curve и keccak256-derivation; различается только prefix (0x41) + encoding.
|
||||
*/
|
||||
export function ethAddressToTron(ethAddr: string): string {
|
||||
const hex = ethAddr.toLowerCase().replace(/^0x/, '');
|
||||
if (hex.length !== 40) {
|
||||
throw new Error('ethAddressToTron: invalid input length');
|
||||
}
|
||||
const bytes = Buffer.from(hex, 'hex');
|
||||
const prefixed = Buffer.concat([Buffer.from([0x41]), bytes]);
|
||||
const h1 = createHash('sha256').update(prefixed).digest();
|
||||
const h2 = createHash('sha256').update(h1).digest();
|
||||
const checksum = h2.subarray(0, 4);
|
||||
return bs58.encode(new Uint8Array(Buffer.concat([prefixed, checksum])));
|
||||
}
|
||||
@@ -1,933 +0,0 @@
|
||||
/**
|
||||
* Wallet read-only operations across chains: balance + tx history.
|
||||
* Server-side signing now lives in `wallet-signer.service.ts` (custodial).
|
||||
*/
|
||||
import { ethers } from 'ethers';
|
||||
import { createHash } from 'crypto';
|
||||
import { env } from '../config/env';
|
||||
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
|
||||
import { getPricesBySymbols } from './price-oracle.service';
|
||||
import { logger } from '../lib/logger';
|
||||
import { getRedis } from '../config/redis';
|
||||
|
||||
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
// ── External APIs ──
|
||||
const BLOCKSTREAM = 'https://blockstream.info/api';
|
||||
const TRONGRID = 'https://api.trongrid.io';
|
||||
const BSC_RPC = 'https://bsc-dataseed.binance.org';
|
||||
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
|
||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||||
|
||||
const ERC20_ABI = [
|
||||
'function balanceOf(address owner) view returns (uint256)',
|
||||
'function transfer(address to, uint256 amount) returns (bool)',
|
||||
'function decimals() view returns (uint8)',
|
||||
];
|
||||
|
||||
// ─────────────────────── BALANCE ───────────────────────
|
||||
|
||||
export interface FormattedAmount {
|
||||
raw: string; // smallest units (string-encoded BigInt — без потери точности)
|
||||
formatted: string; // human-readable, e.g. "0.003"
|
||||
decimals: number; // decimals chain'а/токена
|
||||
/**
|
||||
* USD price per 1 целая единица (e.g. $67432.12 за 1 BTC).
|
||||
* `null` если symbol не в реестре с `coingeckoId` или upstream price oracle недоступен.
|
||||
* Источник: CoinGecko free API, cache 5 мин в KeyDB.
|
||||
*/
|
||||
usdPrice: number | null;
|
||||
/**
|
||||
* Совокупная USD-стоимость holding'а: `Number(formatted) × usdPrice`.
|
||||
* Округлено до 8 знаков. `null` если `usdPrice === null` или вычисление дало `Infinity/NaN`.
|
||||
*/
|
||||
usdValue: number | null;
|
||||
}
|
||||
|
||||
export interface BalanceResult {
|
||||
chain: ChainCode;
|
||||
address: string;
|
||||
native: FormattedAmount;
|
||||
tokens?: Record<string, FormattedAmount>;
|
||||
}
|
||||
|
||||
// Native decimals per chain
|
||||
const NATIVE_DECIMALS: Record<ChainCode, number> = {
|
||||
ETH: 18, // wei
|
||||
BSC: 18, // wei (BNB)
|
||||
BTC: 8, // satoshi
|
||||
TRX: 6, // sun
|
||||
SOL: 9, // lamports
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert raw bigint string in smallest units → human-readable decimal string.
|
||||
* Без потери точности (string-based, не Number/Float).
|
||||
*
|
||||
* formatUnits("3000000000000000", 18) → "0.003"
|
||||
* formatUnits("1500000", 6) → "1.5"
|
||||
* formatUnits("123456", 0) → "123456"
|
||||
*/
|
||||
export function formatUnits(raw: string, decimals: number): string {
|
||||
if (!/^-?\d+$/.test(raw)) return '0';
|
||||
if (decimals === 0) return raw;
|
||||
|
||||
const negative = raw.startsWith('-');
|
||||
const abs = negative ? raw.slice(1) : raw;
|
||||
const padded = abs.padStart(decimals + 1, '0');
|
||||
const whole = padded.slice(0, padded.length - decimals);
|
||||
const frac = padded.slice(-decimals).replace(/0+$/, '');
|
||||
const result = frac ? `${whole}.${frac}` : whole;
|
||||
return negative ? `-${result}` : result;
|
||||
}
|
||||
|
||||
function fmt(raw: string, decimals: number): FormattedAmount {
|
||||
return {
|
||||
raw,
|
||||
formatted: formatUnits(raw, decimals),
|
||||
decimals,
|
||||
usdPrice: null, // populated post-build via populatePrices()
|
||||
usdValue: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Округление USD значения до 8 знаков после запятой (избавляемся от float-мусора).
|
||||
* S10 — `Infinity`/`NaN` → `null`.
|
||||
*/
|
||||
function roundUsd(n: number): number | null {
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return Math.round(n * 1e8) / 1e8;
|
||||
}
|
||||
|
||||
/**
|
||||
* Мутирует BalanceResult, проставляя `usdPrice` и `usdValue` каждому FormattedAmount.
|
||||
* Никогда не throws — если price oracle упал, поля остаются `null`.
|
||||
*/
|
||||
async function populatePrices(result: BalanceResult): Promise<void> {
|
||||
try {
|
||||
const pairs: { chain: ChainCode; symbol: string }[] = [
|
||||
{ chain: result.chain, symbol: result.chain }, // native (BTC/ETH/BSC/TRX/SOL)
|
||||
];
|
||||
if (result.tokens) {
|
||||
for (const sym of Object.keys(result.tokens)) {
|
||||
pairs.push({ chain: result.chain, symbol: sym });
|
||||
}
|
||||
}
|
||||
const prices = await getPricesBySymbols(pairs);
|
||||
|
||||
// Native
|
||||
const nativeKey = `${result.chain}:${result.chain}`;
|
||||
const nativePrice = prices.get(nativeKey) ?? null;
|
||||
result.native.usdPrice = nativePrice;
|
||||
if (nativePrice != null) {
|
||||
const formattedNum = Number(result.native.formatted);
|
||||
result.native.usdValue = Number.isFinite(formattedNum)
|
||||
? roundUsd(formattedNum * nativePrice)
|
||||
: null;
|
||||
}
|
||||
|
||||
// Tokens
|
||||
if (result.tokens) {
|
||||
for (const [sym, amt] of Object.entries(result.tokens)) {
|
||||
const key = `${result.chain}:${sym}`;
|
||||
const p = prices.get(key) ?? null;
|
||||
amt.usdPrice = p;
|
||||
if (p != null) {
|
||||
const fNum = Number(amt.formatted);
|
||||
amt.usdValue = Number.isFinite(fNum) ? roundUsd(fNum * p) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Не валим запрос — balance вернётся без цен.
|
||||
logger.warn(`populatePrices(${result.chain}) failed: ${err?.message || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTokens(
|
||||
raw: Record<string, string>,
|
||||
decimalsLookup: Record<string, number>,
|
||||
): Record<string, FormattedAmount> {
|
||||
const out: Record<string, FormattedAmount> = {};
|
||||
for (const [sym, val] of Object.entries(raw)) {
|
||||
out[sym] = fmt(val, decimalsLookup[sym] ?? 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> {
|
||||
const nativeDecimals = NATIVE_DECIMALS[chain];
|
||||
let result: BalanceResult;
|
||||
switch (chain) {
|
||||
case 'BTC':
|
||||
result = {
|
||||
chain, address,
|
||||
native: fmt(await btcBalance(address), nativeDecimals),
|
||||
};
|
||||
break;
|
||||
case 'TRX': {
|
||||
const { trx, tokens } = await trxBalance(address);
|
||||
const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals]));
|
||||
result = {
|
||||
chain, address,
|
||||
native: fmt(trx, nativeDecimals),
|
||||
tokens: fmtTokens(tokens, decimalsMap),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'BSC':
|
||||
case 'ETH': {
|
||||
const tokenList = getEvmTokens(chain);
|
||||
const rpc = chain === 'BSC' ? BSC_RPC : ETH_RPC;
|
||||
const { native, tokens } = await evmBalance(
|
||||
rpc,
|
||||
address,
|
||||
tokenList.map((t) => ({ symbol: t.symbol, addr: t.contractAddress })),
|
||||
);
|
||||
const decimalsMap = Object.fromEntries(tokenList.map((t) => [t.symbol, t.decimals]));
|
||||
result = {
|
||||
chain, address,
|
||||
native: fmt(native, nativeDecimals),
|
||||
tokens: fmtTokens(tokens, decimalsMap),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'SOL': {
|
||||
const { native, tokens } = await solBalance(address);
|
||||
const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals]));
|
||||
result = {
|
||||
chain, address,
|
||||
native: fmt(native, nativeDecimals),
|
||||
tokens: fmtTokens(tokens, decimalsMap),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate USD prices (graceful — never throws, fields stay null on failure).
|
||||
await populatePrices(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─────────────────────── PORTFOLIO (aggregate всех 5 сетей) ─────────
|
||||
|
||||
export interface ChainPortfolio extends BalanceResult {
|
||||
/** Сумма usdValue по native + всем токенам chain'а. `null` если все цены недоступны. */
|
||||
totalUsd: number | null;
|
||||
/** true = данные из KeyDB cache (RPC chain'а упал в этом запросе). */
|
||||
stale: boolean;
|
||||
/** Unix ms когда данные были обновлены (fresh fetch). */
|
||||
lastUpdated: number;
|
||||
/** Причина почему stale (только если stale=true). */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PortfolioResult {
|
||||
/** Grand sum по всем сетям. Округлено до 8 знаков. */
|
||||
totalUsd: number;
|
||||
/** true если хотя бы одна сеть в stale/error состоянии. */
|
||||
hasErrors: boolean;
|
||||
/** Per-chain breakdown. `null` для chain'а если ни fresh ни cache нет. */
|
||||
perChain: Record<ChainCode, ChainPortfolio | null>;
|
||||
}
|
||||
|
||||
const PORTFOLIO_CACHE_TTL_SEC = 3600; // 1 час stale-fallback
|
||||
const PORTFOLIO_CACHE_PREFIX = 'portfolio:'; // ключ: portfolio:{userId}:{chain}
|
||||
|
||||
function computeChainTotalUsd(b: BalanceResult): number | null {
|
||||
let total = 0;
|
||||
let anyValid = false;
|
||||
const add = (amt: FormattedAmount | undefined): void => {
|
||||
const v = amt?.usdValue;
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
total += v;
|
||||
anyValid = true;
|
||||
}
|
||||
};
|
||||
add(b.native);
|
||||
for (const a of Object.values(b.tokens ?? {})) add(a);
|
||||
return anyValid ? roundUsd(total) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate balance по всем 5 сетям. Параллельно дёргает `getBalance(chain, address)` для каждой,
|
||||
* сохраняет успешные ответы в KeyDB (TTL 1 час). При сбое RPC отдельной сети — возвращает
|
||||
* последний кэшированный snapshot с пометкой `stale:true`, чтобы UI никогда не показывал 0.
|
||||
*
|
||||
* Не throws — даже при полной недоступности всех сетей вернёт totalUsd=0 + hasErrors=true.
|
||||
*/
|
||||
export async function getPortfolio(
|
||||
userId: string,
|
||||
addresses: Record<ChainCode, string>,
|
||||
): Promise<PortfolioResult> {
|
||||
const chains: ChainCode[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL'];
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
chains.map((c) => {
|
||||
const addr = addresses[c];
|
||||
if (!addr) return Promise.reject(new Error(`No ${c} address for user`));
|
||||
return getBalance(c, addr);
|
||||
}),
|
||||
);
|
||||
|
||||
let redis: ReturnType<typeof getRedis> | null = null;
|
||||
try { redis = getRedis(); } catch { redis = null; }
|
||||
|
||||
const perChain: Record<string, ChainPortfolio | null> = {};
|
||||
let totalUsd = 0;
|
||||
let hasErrors = false;
|
||||
const now = Date.now();
|
||||
|
||||
for (let i = 0; i < chains.length; i++) {
|
||||
const chain = chains[i];
|
||||
const res = settled[i];
|
||||
const cacheKey = `${PORTFOLIO_CACHE_PREFIX}${userId}:${chain}`;
|
||||
|
||||
if (res.status === 'fulfilled') {
|
||||
const balance = res.value;
|
||||
const chainTotal = computeChainTotalUsd(balance);
|
||||
const entry: ChainPortfolio = {
|
||||
...balance,
|
||||
totalUsd: chainTotal,
|
||||
stale: false,
|
||||
lastUpdated: now,
|
||||
};
|
||||
perChain[chain] = entry;
|
||||
if (typeof chainTotal === 'number') totalUsd += chainTotal;
|
||||
// Cache fire-and-forget
|
||||
if (redis) {
|
||||
redis
|
||||
.set(cacheKey, JSON.stringify(entry), 'EX', PORTFOLIO_CACHE_TTL_SEC)
|
||||
.catch((err) => logger.warn(`portfolio cache write ${chain} failed: ${err?.message}`));
|
||||
}
|
||||
} else {
|
||||
hasErrors = true;
|
||||
const reason = String((res.reason as any)?.message || 'unknown');
|
||||
// Попробуем cached fallback
|
||||
let cached: ChainPortfolio | null = null;
|
||||
if (redis) {
|
||||
try {
|
||||
const raw = await redis.get(cacheKey);
|
||||
if (raw) cached = JSON.parse(raw) as ChainPortfolio;
|
||||
} catch (err: any) {
|
||||
logger.warn(`portfolio cache read ${chain} failed: ${err?.message}`);
|
||||
}
|
||||
}
|
||||
if (cached) {
|
||||
perChain[chain] = { ...cached, stale: true, error: reason };
|
||||
if (typeof cached.totalUsd === 'number') totalUsd += cached.totalUsd;
|
||||
} else {
|
||||
perChain[chain] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalUsd: roundUsd(totalUsd) ?? 0,
|
||||
hasErrors,
|
||||
perChain: perChain as Record<ChainCode, ChainPortfolio | null>,
|
||||
};
|
||||
}
|
||||
|
||||
async function btcBalance(address: string): Promise<string> {
|
||||
const res = await fetchJson(`${BLOCKSTREAM}/address/${address}`);
|
||||
const stats = res.chain_stats;
|
||||
const sat = BigInt(stats.funded_txo_sum) - BigInt(stats.spent_txo_sum);
|
||||
return sat.toString();
|
||||
}
|
||||
|
||||
async function trxBalance(address: string): Promise<{ trx: string; tokens: Record<string, string> }> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
const accRes = await fetchJson(`${TRONGRID}/v1/accounts/${address}`, { headers });
|
||||
const trx = accRes.data?.[0]?.balance ? String(accRes.data[0].balance) : '0';
|
||||
|
||||
// Parallel TRC20 balanceOf для всех зарегистрированных токенов
|
||||
const tokens: Record<string, string> = {};
|
||||
const addrHex = tronAddressToHex(address).padStart(64, '0');
|
||||
|
||||
await Promise.all(
|
||||
getTrxTokens().map(async ({ symbol, contractAddress }) => {
|
||||
try {
|
||||
const res = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
owner_address: address,
|
||||
contract_address: contractAddress,
|
||||
function_selector: 'balanceOf(address)',
|
||||
parameter: addrHex,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
const hex = res.constant_result?.[0];
|
||||
tokens[symbol] = hex && !/^0+$/.test(hex) ? BigInt('0x' + hex).toString() : '0';
|
||||
} catch {
|
||||
tokens[symbol] = '0';
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { trx, tokens };
|
||||
}
|
||||
|
||||
async function evmBalance(
|
||||
rpc: string,
|
||||
address: string,
|
||||
tokens: { symbol: string; addr: string }[],
|
||||
): Promise<{ native: string; tokens: Record<string, string> }> {
|
||||
const provider = new ethers.providers.StaticJsonRpcProvider(rpc);
|
||||
// H52 — Promise.allSettled: одна сбоящая RPC subcall не валит весь endpoint в 502
|
||||
const nativeP = withTimeout(provider.getBalance(address), TIMEOUT_MS, 'EVM balance timeout').then(b => b.toString()).catch(() => '0');
|
||||
|
||||
const tokenBalances: Record<string, string> = {};
|
||||
await Promise.allSettled(
|
||||
tokens.map(async ({ symbol, addr }) => {
|
||||
try {
|
||||
const c = new ethers.Contract(addr, ERC20_ABI, provider);
|
||||
const bal: ethers.BigNumber = await withTimeout(c.balanceOf(address), TIMEOUT_MS, `${symbol} balance timeout`);
|
||||
tokenBalances[symbol] = bal.toString();
|
||||
} catch {
|
||||
tokenBalances[symbol] = '0';
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const native = await nativeP;
|
||||
return { native, tokens: tokenBalances };
|
||||
}
|
||||
|
||||
async function solBalance(address: string): Promise<{ native: string; tokens: Record<string, string> }> {
|
||||
// 1) Native SOL balance
|
||||
const nativeRes = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getBalance',
|
||||
params: [address],
|
||||
}),
|
||||
});
|
||||
const native = String(nativeRes.result?.value ?? 0);
|
||||
|
||||
// 2) Все SPL token accounts юзера одним запросом
|
||||
const tokens: Record<string, string> = {};
|
||||
const knownMints = new Map(getSolTokens().map((t) => [t.mint, t.symbol]));
|
||||
|
||||
// Сразу инициализируем все известные символы нулём (чтобы output был consistent)
|
||||
for (const [, sym] of knownMints) tokens[sym] = '0';
|
||||
|
||||
try {
|
||||
const splRes = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getTokenAccountsByOwner',
|
||||
params: [
|
||||
address,
|
||||
{ programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' }, // SPL Token program
|
||||
{ encoding: 'jsonParsed' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const accounts = splRes.result?.value || [];
|
||||
for (const acc of accounts) {
|
||||
const info = acc?.account?.data?.parsed?.info;
|
||||
const mint = info?.mint;
|
||||
const amount = info?.tokenAmount?.amount;
|
||||
if (mint && amount && knownMints.has(mint)) {
|
||||
const symbol = knownMints.get(mint)!;
|
||||
// Суммируем если несколько token accounts для одного mint
|
||||
const prev = BigInt(tokens[symbol] || '0');
|
||||
tokens[symbol] = (prev + BigInt(amount)).toString();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// SOL RPC недоступен — оставляем нули
|
||||
}
|
||||
|
||||
return { native, tokens };
|
||||
}
|
||||
|
||||
// ─────────────────────── TRANSACTIONS ───────────────────────
|
||||
|
||||
export interface TxItem {
|
||||
txid: string;
|
||||
timestamp: number | null; // unix seconds
|
||||
direction: 'in' | 'out' | 'self';
|
||||
amount?: string;
|
||||
token?: string;
|
||||
to?: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export async function getTransactions(chain: ChainCode, address: string, limit: number): Promise<TxItem[]> {
|
||||
switch (chain) {
|
||||
case 'BTC':
|
||||
return btcTransactions(address, limit);
|
||||
case 'TRX':
|
||||
return trxTransactions(address, limit);
|
||||
case 'BSC':
|
||||
return scanTransactions('https://api.bscscan.com/api', env.bscscanApiKey, address, limit);
|
||||
case 'ETH':
|
||||
return scanTransactions('https://api.etherscan.io/api', env.etherscanApiKey, address, limit);
|
||||
case 'SOL':
|
||||
return solTransactions(address, limit);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanTransactions(
|
||||
apiBase: string,
|
||||
apiKey: string | null,
|
||||
address: string,
|
||||
limit: number,
|
||||
): Promise<TxItem[]> {
|
||||
if (!apiKey) return [];
|
||||
|
||||
const url = new URL(apiBase);
|
||||
url.searchParams.set('module', 'account');
|
||||
url.searchParams.set('action', 'txlist');
|
||||
url.searchParams.set('address', address);
|
||||
url.searchParams.set('startblock', '0');
|
||||
url.searchParams.set('endblock', '99999999');
|
||||
url.searchParams.set('page', '1');
|
||||
url.searchParams.set('offset', String(Math.min(limit, 100)));
|
||||
url.searchParams.set('sort', 'desc');
|
||||
url.searchParams.set('apikey', apiKey);
|
||||
|
||||
const res = await fetchJson(url.toString());
|
||||
if (res.status !== '1' || !Array.isArray(res.result)) return [];
|
||||
|
||||
return (res.result as any[]).slice(0, limit).map((tx) => {
|
||||
const isOut = String(tx.from).toLowerCase() === address.toLowerCase();
|
||||
return {
|
||||
txid: tx.hash,
|
||||
timestamp: tx.timeStamp ? parseInt(tx.timeStamp, 10) : null,
|
||||
direction: (isOut ? 'out' : 'in') as TxItem['direction'],
|
||||
amount: tx.value || undefined,
|
||||
from: tx.from,
|
||||
to: tx.to,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function btcTransactions(address: string, limit: number): Promise<TxItem[]> {
|
||||
const txs = await fetchJson(`${BLOCKSTREAM}/address/${address}/txs`);
|
||||
return (txs as any[]).slice(0, limit).map((tx) => {
|
||||
const vin = Array.isArray(tx.vin) ? tx.vin : [];
|
||||
const vout = Array.isArray(tx.vout) ? tx.vout : [];
|
||||
const inSelf = vin.some((v: any) => v.prevout?.scriptpubkey_address === address);
|
||||
const allOutsSelf = vout.length > 0 && vout.every((v: any) => v.scriptpubkey_address === address);
|
||||
const anyOutsExternal = vout.some((v: any) => v.scriptpubkey_address && v.scriptpubkey_address !== address);
|
||||
|
||||
// H49 — корректная direction logic:
|
||||
// self = consolidation/tx where ВСЕ outs идут обратно к нам (rare; обычно нет change)
|
||||
// out = мы spend'им (inSelf=true) И есть external recipient
|
||||
// in = мы получаем (НЕ inSelf, есть out к нам)
|
||||
let direction: TxItem['direction'];
|
||||
if (inSelf && allOutsSelf) {
|
||||
direction = 'self';
|
||||
} else if (inSelf && anyOutsExternal) {
|
||||
direction = 'out';
|
||||
} else {
|
||||
direction = 'in';
|
||||
}
|
||||
|
||||
// amount: для 'out' — sum external outs; для 'in' — sum outs к нам; для 'self' — 0
|
||||
let amountSat = 0n;
|
||||
if (direction === 'in') {
|
||||
amountSat = vout
|
||||
.filter((v: any) => v.scriptpubkey_address === address)
|
||||
.reduce((s: bigint, v: any) => s + BigInt(v.value || 0), 0n);
|
||||
} else if (direction === 'out') {
|
||||
amountSat = vout
|
||||
.filter((v: any) => v.scriptpubkey_address && v.scriptpubkey_address !== address)
|
||||
.reduce((s: bigint, v: any) => s + BigInt(v.value || 0), 0n);
|
||||
}
|
||||
|
||||
return {
|
||||
txid: tx.txid,
|
||||
timestamp: tx.status?.block_time ?? null,
|
||||
direction,
|
||||
amount: String(amountSat),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function trxTransactions(address: string, limit: number): Promise<TxItem[]> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
const res = await fetchJson(
|
||||
`${TRONGRID}/v1/accounts/${address}/transactions?limit=${limit}`,
|
||||
{ headers },
|
||||
);
|
||||
return ((res.data as any[]) || []).slice(0, limit).map((tx) => {
|
||||
const contract = tx.raw_data?.contract?.[0];
|
||||
const value = contract?.parameter?.value;
|
||||
const fromAddr = value?.owner_address ? hexToTron(value.owner_address) : '';
|
||||
const toAddr = value?.to_address ? hexToTron(value.to_address) : '';
|
||||
const isOut = fromAddr === address;
|
||||
return {
|
||||
txid: tx.txID,
|
||||
timestamp: tx.block_timestamp ? Math.floor(tx.block_timestamp / 1000) : null,
|
||||
direction: (isOut ? 'out' : 'in') as TxItem['direction'],
|
||||
amount: value?.amount ? String(value.amount) : undefined,
|
||||
from: fromAddr || undefined,
|
||||
to: toAddr || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function solTransactions(address: string, limit: number): Promise<TxItem[]> {
|
||||
const sigsRes = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getSignaturesForAddress',
|
||||
params: [address, { limit }],
|
||||
}),
|
||||
});
|
||||
// H50 — H18: filter out failed txs + deep-parse selected (limit small concurrency).
|
||||
const allSigs = ((sigsRes.result as any[]) || []).filter((s) => s.err === null);
|
||||
|
||||
// Fetch tx details для balance deltas — batch parallel но небольшим limit'ом
|
||||
const results: TxItem[] = [];
|
||||
for (const sig of allSigs.slice(0, limit)) {
|
||||
try {
|
||||
const txRes = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getTransaction',
|
||||
params: [sig.signature, { maxSupportedTransactionVersion: 0, encoding: 'json' }],
|
||||
}),
|
||||
});
|
||||
const tx = txRes.result;
|
||||
const accountKeys = tx?.transaction?.message?.accountKeys || [];
|
||||
const idx = accountKeys.indexOf(address);
|
||||
const pre = tx?.meta?.preBalances?.[idx];
|
||||
const post = tx?.meta?.postBalances?.[idx];
|
||||
let direction: TxItem['direction'] = 'self';
|
||||
let amount: string | undefined;
|
||||
if (typeof pre === 'number' && typeof post === 'number') {
|
||||
const delta = post - pre;
|
||||
if (delta < 0) {
|
||||
direction = 'out';
|
||||
amount = String(-delta);
|
||||
} else if (delta > 0) {
|
||||
direction = 'in';
|
||||
amount = String(delta);
|
||||
}
|
||||
}
|
||||
results.push({
|
||||
txid: sig.signature,
|
||||
timestamp: sig.blockTime ?? null,
|
||||
direction,
|
||||
amount,
|
||||
});
|
||||
} catch {
|
||||
// Если getTransaction fails — fallback на minimal entry
|
||||
results.push({
|
||||
txid: sig.signature,
|
||||
timestamp: sig.blockTime ?? null,
|
||||
direction: 'self',
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─────────────────────── HELPERS ───────────────────────
|
||||
// (buildSend + chain-specific builders deleted — server signs custodially via wallet-signer.service.ts)
|
||||
|
||||
/* deleted-marker-begin
|
||||
export interface BuildSendParams {
|
||||
chain: ChainCode;
|
||||
from: string;
|
||||
to: string;
|
||||
amount: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export type UnsignedTx =
|
||||
| { kind: 'btc'; from: string; to: string; amountSat: string; utxos: any[]; feeRateSatPerVb: number }
|
||||
| { kind: 'tron'; transaction: any }
|
||||
| { kind: 'evm'; to: string; data: string; value: string; chainId: number; gasLimit?: string }
|
||||
| { kind: 'solana'; instructions: any; recentBlockhash: string };
|
||||
|
||||
export async function buildSend(p: BuildSendParams): Promise<UnsignedTx> {
|
||||
switch (p.chain) {
|
||||
case 'BTC':
|
||||
return buildBtcSend(p);
|
||||
case 'TRX':
|
||||
return buildTrxSend(p);
|
||||
case 'BSC':
|
||||
return buildEvmSend(p, BSC_RPC, 56, USDT_BEP20);
|
||||
case 'ETH':
|
||||
return buildEvmSend(p, ETH_RPC, 1, USDT_ERC20);
|
||||
case 'SOL':
|
||||
return buildSolSend(p);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildBtcSend(p: BuildSendParams): Promise<UnsignedTx> {
|
||||
if (p.token) throw new Error('BTC tokens not supported');
|
||||
const utxos = await fetchJson(`${BLOCKSTREAM}/address/${p.from}/utxo`);
|
||||
const fees = await fetchJson(`${BLOCKSTREAM}/fee-estimates`);
|
||||
const confirmed = ((utxos as any[]) || []).filter((u) => u.status?.confirmed);
|
||||
|
||||
return {
|
||||
kind: 'btc',
|
||||
from: p.from,
|
||||
to: p.to,
|
||||
amountSat: p.amount,
|
||||
utxos: confirmed.map((u) => ({ txid: u.txid, vout: u.vout, value: u.value })),
|
||||
feeRateSatPerVb: Math.ceil((fees as any)['3'] ?? (fees as any)['6'] ?? 5),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildTrxSend(p: BuildSendParams): Promise<UnsignedTx> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
if (!p.token) {
|
||||
// Native TRX
|
||||
const res = await fetchJson(`${TRONGRID}/wallet/createtransaction`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ owner_address: p.from, to_address: p.to, amount: Number(p.amount), visible: true }),
|
||||
});
|
||||
return { kind: 'tron', transaction: res };
|
||||
}
|
||||
|
||||
if (p.token.toUpperCase() === 'USDT') {
|
||||
// TRC20 USDT
|
||||
const param = tronAddressToHex(p.to).padStart(64, '0') + BigInt(p.amount).toString(16).padStart(64, '0');
|
||||
const res = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
owner_address: p.from,
|
||||
contract_address: USDT_TRC20,
|
||||
function_selector: 'transfer(address,uint256)',
|
||||
parameter: param,
|
||||
fee_limit: 100_000_000,
|
||||
call_value: 0,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
return { kind: 'tron', transaction: res };
|
||||
}
|
||||
|
||||
throw new Error(`Token ${p.token} not supported on TRX`);
|
||||
}
|
||||
|
||||
async function buildEvmSend(p: BuildSendParams, rpc: string, chainId: number, usdtAddr: string): Promise<UnsignedTx> {
|
||||
if (!ethers.utils.isAddress(p.to)) throw new Error('Invalid recipient address');
|
||||
|
||||
if (!p.token) {
|
||||
return { kind: 'evm', to: p.to, data: '0x', value: ethers.BigNumber.from(p.amount).toHexString(), chainId };
|
||||
}
|
||||
|
||||
if (p.token.toUpperCase() === 'USDT') {
|
||||
const iface = new ethers.utils.Interface(ERC20_ABI);
|
||||
const data = iface.encodeFunctionData('transfer', [p.to, p.amount]);
|
||||
return { kind: 'evm', to: usdtAddr, data, value: '0x0', chainId };
|
||||
}
|
||||
|
||||
throw new Error(`Token ${p.token} not supported on ${chainId === 56 ? 'BSC' : 'ETH'}`);
|
||||
}
|
||||
|
||||
async function buildSolSend(p: BuildSendParams): Promise<UnsignedTx> {
|
||||
const {
|
||||
Connection,
|
||||
PublicKey,
|
||||
SystemProgram,
|
||||
Transaction,
|
||||
} = await import('@solana/web3.js');
|
||||
|
||||
const conn = new Connection(SOL_RPC, 'confirmed');
|
||||
|
||||
let fromPk: InstanceType<typeof PublicKey>;
|
||||
let toPk: InstanceType<typeof PublicKey>;
|
||||
try {
|
||||
fromPk = new PublicKey(p.from);
|
||||
toPk = new PublicKey(p.to);
|
||||
} catch {
|
||||
throw new Error('Invalid Solana address');
|
||||
}
|
||||
|
||||
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
|
||||
const tx = new Transaction({
|
||||
feePayer: fromPk,
|
||||
blockhash,
|
||||
lastValidBlockHeight,
|
||||
});
|
||||
|
||||
if (!p.token) {
|
||||
// Native SOL transfer
|
||||
tx.add(
|
||||
SystemProgram.transfer({
|
||||
fromPubkey: fromPk,
|
||||
toPubkey: toPk,
|
||||
lamports: BigInt(p.amount),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// SPL token transfer (manual instruction — не тянем @solana/spl-token)
|
||||
const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
|
||||
const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');
|
||||
|
||||
const mint = solMintFor(p.token);
|
||||
if (!mint) throw new Error(`Unsupported SOL token: ${p.token}`);
|
||||
|
||||
const fromAta = await deriveAta(new PublicKey(mint), fromPk, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
|
||||
const toAta = await deriveAta(new PublicKey(mint), toPk, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
|
||||
|
||||
// Transfer instruction (instruction tag = 3 для SPL Token Transfer)
|
||||
const data = Buffer.alloc(9);
|
||||
data.writeUInt8(3, 0);
|
||||
data.writeBigUInt64LE(BigInt(p.amount), 1);
|
||||
|
||||
tx.add({
|
||||
programId: TOKEN_PROGRAM_ID,
|
||||
keys: [
|
||||
{ pubkey: fromAta, isSigner: false, isWritable: true },
|
||||
{ pubkey: toAta, isSigner: false, isWritable: true },
|
||||
{ pubkey: fromPk, isSigner: true, isWritable: false },
|
||||
],
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// Сериализуем сообщение (без подписей) для клиента
|
||||
const serialized = tx.serialize({ requireAllSignatures: false, verifySignatures: false });
|
||||
|
||||
return {
|
||||
kind: 'solana',
|
||||
instructions: serialized.toString('base64'),
|
||||
recentBlockhash: blockhash,
|
||||
};
|
||||
}
|
||||
|
||||
function solMintFor(symbol: string): string | null {
|
||||
const map: Record<string, string> = {
|
||||
USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
|
||||
USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
|
||||
};
|
||||
return map[symbol] ?? null;
|
||||
}
|
||||
|
||||
async function deriveAta(
|
||||
mint: any,
|
||||
owner: any,
|
||||
tokenProgramId: any,
|
||||
associatedTokenProgramId: any,
|
||||
): Promise<any> {
|
||||
const { PublicKey } = await import('@solana/web3.js');
|
||||
const [pda] = await PublicKey.findProgramAddress(
|
||||
[owner.toBuffer(), tokenProgramId.toBuffer(), mint.toBuffer()],
|
||||
associatedTokenProgramId,
|
||||
);
|
||||
return pda;
|
||||
}
|
||||
deleted-marker-end */
|
||||
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
function tronAddressToHex(address: string): string {
|
||||
let num = 0n;
|
||||
for (const ch of address) {
|
||||
const i = BASE58_ALPHABET.indexOf(ch);
|
||||
if (i === -1) throw new Error('Invalid base58 character in TRON address');
|
||||
num = num * 58n + BigInt(i);
|
||||
}
|
||||
const hex = num.toString(16).padStart(50, '0');
|
||||
return hex.slice(2, 42); // 20 bytes без префикса 0x41
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode TRON address: 20-byte hex (без 0x41 prefix) → base58check 'T...' string.
|
||||
* H51 — фронтенд получал raw hex `41...` instead of readable `T...`. Fix.
|
||||
*/
|
||||
function hexToTron(hex: string): string {
|
||||
if (!hex) return '';
|
||||
// Принимаем hex с или без префикса 0x41
|
||||
let bytesHex = hex.toLowerCase();
|
||||
if (bytesHex.startsWith('0x')) bytesHex = bytesHex.slice(2);
|
||||
// Если уже 21 byte (с prefix) — оставляем; если 20 — добавляем 0x41
|
||||
if (bytesHex.length === 40) {
|
||||
bytesHex = '41' + bytesHex;
|
||||
} else if (bytesHex.length !== 42) {
|
||||
// Unknown length — fail-safe return raw input для backward compat
|
||||
return hex;
|
||||
}
|
||||
if (!/^[0-9a-f]+$/.test(bytesHex)) return hex;
|
||||
|
||||
const payload = Buffer.from(bytesHex, 'hex');
|
||||
// SHA256d checksum (4 bytes)
|
||||
const h1 = createHash('sha256').update(payload).digest();
|
||||
const h2 = createHash('sha256').update(h1).digest();
|
||||
const fullBytes = Buffer.concat([payload, h2.subarray(0, 4)]);
|
||||
|
||||
// base58 encode
|
||||
return base58Encode(fullBytes);
|
||||
}
|
||||
|
||||
const BASE58_ALPHABET_OPS = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
function base58Encode(bytes: Buffer): string {
|
||||
let num = 0n;
|
||||
for (const b of bytes) {
|
||||
num = (num << 8n) + BigInt(b);
|
||||
}
|
||||
let s = '';
|
||||
while (num > 0n) {
|
||||
s = BASE58_ALPHABET_OPS[Number(num % 58n)] + s;
|
||||
num /= 58n;
|
||||
}
|
||||
// Leading zero bytes → leading '1's
|
||||
for (const b of bytes) {
|
||||
if (b === 0) s = '1' + s;
|
||||
else break;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Upstream ${res.status}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, msg: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(msg)), ms);
|
||||
promise.then(
|
||||
(v) => { clearTimeout(t); resolve(v); },
|
||||
(e) => { clearTimeout(t); reject(e); },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,859 +0,0 @@
|
||||
/**
|
||||
* Bridge-specific signers/helpers — отдельный файл чтобы не разрастать `wallet-signer.service.ts`.
|
||||
*
|
||||
* Чем отличается от обычного signAndBroadcast:
|
||||
* - EVM `signAndBroadcastEvmApprove` — ERC20.approve(spender, amount) для bridge router'а;
|
||||
* включает wait 1 conf чтобы next tx видел свежий allowance.
|
||||
* - `readErc20Allowance` — direct view call (без подписи) для pre-check.
|
||||
* - TRX/BTC — bridge-specific path для unsigned tx от Relay/LiFi.
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import { createHash } from 'crypto';
|
||||
import * as bip39 from 'bip39';
|
||||
import { BIP32Factory } from 'bip32';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { env } from '../config/env';
|
||||
import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service';
|
||||
import { pickProxiedEvmProvider } from '../lib/outbound-proxy';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const bip32 = BIP32Factory(ecc);
|
||||
|
||||
const ETH_RPCS = [
|
||||
'https://ethereum-rpc.publicnode.com',
|
||||
'https://eth.llamarpc.com',
|
||||
'https://rpc.ankr.com/eth',
|
||||
];
|
||||
const BSC_RPCS = [
|
||||
'https://bsc-dataseed.binance.org',
|
||||
'https://bsc-dataseed1.binance.org',
|
||||
'https://bsc-dataseed2.binance.org',
|
||||
'https://bsc.publicnode.com',
|
||||
];
|
||||
|
||||
const TRONGRID = 'https://api.trongrid.io';
|
||||
const BLOCKSTREAM = 'https://blockstream.info/api';
|
||||
|
||||
const HTTP_TIMEOUT_MS = 20_000;
|
||||
const APPROVE_GAS_LIMIT = 80_000; // EIP-2 approve ~50k базовая + overhead
|
||||
const MAX_GAS_PRICE_GWEI = 500;
|
||||
|
||||
const ERC20_ABI = [
|
||||
'function approve(address spender, uint256 amount) returns (bool)',
|
||||
'function allowance(address owner, address spender) view returns (uint256)',
|
||||
'function balanceOf(address owner) view returns (uint256)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Структурированная ошибка для balance pre-check. Controller'ы маппят `code === 'INSUFFICIENT_BALANCE'`
|
||||
* в HTTP 400 с human-readable message.
|
||||
*/
|
||||
export class InsufficientBalanceError extends Error {
|
||||
code = 'INSUFFICIENT_BALANCE' as const;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InsufficientBalanceError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Структурированная ошибка для pre-broadcast simulation revert. Controller'ы маппят
|
||||
* `code === 'SIMULATION_FAILED'` в HTTP 400. Поскольку simulation НЕ broadcast'ит — fees
|
||||
* пользователя не сгорают.
|
||||
*/
|
||||
export class BridgeSimulationError extends Error {
|
||||
code = 'SIMULATION_FAILED' as const;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BridgeSimulationError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EVM helpers ──────────────────────────────────────────────────────
|
||||
|
||||
export interface SignEvmApproveParams {
|
||||
chain: 'ETH' | 'BSC';
|
||||
mnemonic: string;
|
||||
expectedFromAddress: string;
|
||||
spender: string; // bridge router address (LiFi diamond / Relay router)
|
||||
token: string; // ERC20 contract address
|
||||
amount: string; // exact approve amount (smallest units, decimal string)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign + broadcast ERC20.approve(spender, amount). Waits 1 confirmation
|
||||
* перед return — bridge tx сразу следующий видит свежий allowance.
|
||||
*/
|
||||
export async function signAndBroadcastEvmApprove(p: SignEvmApproveParams): Promise<{ txid: string }> {
|
||||
const expectedChainId = p.chain === 'ETH' ? 1 : 56;
|
||||
const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||
|
||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
|
||||
if (wallet.address.toLowerCase() !== p.expectedFromAddress.toLowerCase()) {
|
||||
throw new Error(`Derived ${p.chain} address ${wallet.address} ≠ stored ${p.expectedFromAddress}`);
|
||||
}
|
||||
|
||||
const provider = await pickProxiedEvmProvider(rpcs, expectedChainId);
|
||||
const signer = wallet.connect(provider);
|
||||
const token = new ethers.Contract(p.token, ERC20_ABI, signer);
|
||||
|
||||
// Fee tier: используем provider.getFeeData() — это OK для approve (low priority).
|
||||
const feeData = await provider.getFeeData();
|
||||
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
|
||||
let maxFeePerGas = feeData.maxFeePerGas ?? ethers.utils.parseUnits('30', 'gwei');
|
||||
let maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.utils.parseUnits('1', 'gwei');
|
||||
if (maxFeePerGas.gt(capWei)) maxFeePerGas = capWei;
|
||||
if (maxPriorityFeePerGas.gt(maxFeePerGas)) maxPriorityFeePerGas = maxFeePerGas;
|
||||
|
||||
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
||||
const data = token.interface.encodeFunctionData('approve', [p.spender, ethers.BigNumber.from(p.amount)]);
|
||||
|
||||
const sent = await signer.sendTransaction({
|
||||
to: p.token,
|
||||
data,
|
||||
value: 0,
|
||||
chainId: expectedChainId,
|
||||
nonce,
|
||||
gasLimit: APPROVE_GAS_LIMIT,
|
||||
type: 2,
|
||||
maxFeePerGas,
|
||||
maxPriorityFeePerGas,
|
||||
});
|
||||
// Wait 1 conf чтобы bridge tx (next) видел updated allowance
|
||||
await Promise.race([
|
||||
sent.wait(1),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('approve confirm timeout')), 60_000)),
|
||||
]);
|
||||
logger.info(`EVM approve confirmed: chain=${p.chain} token=${p.token} spender=${p.spender} amount=${p.amount} txid=${sent.hash}`);
|
||||
return { txid: sent.hash };
|
||||
}
|
||||
|
||||
export interface ReadErc20AllowanceParams {
|
||||
chain: 'ETH' | 'BSC';
|
||||
token: string;
|
||||
owner: string;
|
||||
spender: string;
|
||||
}
|
||||
|
||||
export async function readErc20Allowance(p: ReadErc20AllowanceParams): Promise<bigint> {
|
||||
const expectedChainId = p.chain === 'ETH' ? 1 : 56;
|
||||
const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||
const provider = await pickProxiedEvmProvider(rpcs, expectedChainId);
|
||||
const token = new ethers.Contract(p.token, ERC20_ABI, provider);
|
||||
const res: ethers.BigNumber = await token.allowance(p.owner, p.spender);
|
||||
return BigInt(res.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read EVM native balance (BNB / ETH) for an address. Smallest units (wei) as bigint.
|
||||
*/
|
||||
export async function readEvmNativeBalance(chain: 'ETH' | 'BSC', address: string): Promise<bigint> {
|
||||
const chainId = chain === 'ETH' ? 1 : 56;
|
||||
const rpcs = chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||
const provider = await pickProxiedEvmProvider(rpcs, chainId);
|
||||
const bal: ethers.BigNumber = await provider.getBalance(address);
|
||||
return BigInt(bal.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read ERC20 token balance (USDT / USDC / etc.) for an address. Smallest units as bigint.
|
||||
*/
|
||||
export async function readErc20Balance(chain: 'ETH' | 'BSC', token: string, owner: string): Promise<bigint> {
|
||||
const chainId = chain === 'ETH' ? 1 : 56;
|
||||
const rpcs = chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||
const provider = await pickProxiedEvmProvider(rpcs, chainId);
|
||||
const c = new ethers.Contract(token, ERC20_ABI, provider);
|
||||
const res: ethers.BigNumber = await c.balanceOf(owner);
|
||||
return BigInt(res.toString());
|
||||
}
|
||||
|
||||
// ─── SOL balance helpers ──────────────────────────────────────────────
|
||||
|
||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||||
|
||||
/**
|
||||
* Read SOL native balance in lamports. Используется для bridge-execute pre-check
|
||||
* чтобы сразу отвергать "insufficient lamports" simulation errors с человеческим message.
|
||||
*/
|
||||
export async function readSolBalance(address: string): Promise<bigint> {
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getBalance',
|
||||
params: [address],
|
||||
});
|
||||
const res = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const lamports = res?.result?.value;
|
||||
if (typeof lamports !== 'number') {
|
||||
throw new Error(`SOL balance read failed: ${JSON.stringify(res).slice(0, 200)}`);
|
||||
}
|
||||
return BigInt(lamports);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read SPL token balance (USDC/USDT/...) для SOL owner. Returns smallest units.
|
||||
* Если token account не существует (юзер ни разу не получал token) — возвращает 0n.
|
||||
*/
|
||||
export async function readSplTokenBalance(owner: string, mint: string): Promise<bigint> {
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getTokenAccountsByOwner',
|
||||
params: [owner, { mint }, { encoding: 'jsonParsed' }],
|
||||
});
|
||||
const res = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const accounts = res?.result?.value || [];
|
||||
let total = 0n;
|
||||
for (const acc of accounts) {
|
||||
const raw = acc?.account?.data?.parsed?.info?.tokenAmount?.amount;
|
||||
if (typeof raw === 'string') total += BigInt(raw);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ─── TRX bridge helpers ───────────────────────────────────────────────
|
||||
|
||||
export interface SignRawTronParams {
|
||||
mnemonic: string;
|
||||
expectedFromAddress: string;
|
||||
contractAddress: string; // TRC20 token / LiFi router (T...base58)
|
||||
callData: string; // hex calldata (без 0x ИЛИ с 0x — нормализуем)
|
||||
callValue: bigint; // TRX amount в sun (0 для most contract calls)
|
||||
feeLimit: number; // максимум sun сжигается на energy/bandwidth (typical 30-150 TRX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign + broadcast arbitrary Tron smart-contract call. Используется для bridge'а
|
||||
* через LiFi/Jumper (которые возвращают raw contract call для TRC20 token approve / bridge).
|
||||
*
|
||||
* Flow (HTTP-only через TronGrid, no tronweb lib):
|
||||
* 1. POST /wallet/triggersmartcontract (build unsigned tx)
|
||||
* 2. MITM check: recompute txID, verify expiration/timestamp bounds, verify owner/contract
|
||||
* 3. Sign (ECDSA secp256k1, same as EVM signing с recoveryParam append)
|
||||
* 4. POST /wallet/broadcasttransaction
|
||||
*/
|
||||
export async function signAndBroadcastRawTron(p: SignRawTronParams): Promise<{ txid: string }> {
|
||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX);
|
||||
const fromTronAddr = ethAddressToTron(wallet.address);
|
||||
if (fromTronAddr !== p.expectedFromAddress) {
|
||||
throw new Error(`Derived TRX address ${fromTronAddr} ≠ stored ${p.expectedFromAddress}`);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
// Normalize calldata. triggersmartcontract API принимает либо:
|
||||
// - function_selector (canonical string "transfer(address,uint256)") + parameter (hex args)
|
||||
// → TronGrid keccak'ит selector NAME и prepend'ит к parameter
|
||||
// - data (full hex calldata = selector + params) → используется как-есть
|
||||
//
|
||||
// Если у нас неизвестный selector (LiFi bridge calls, custom routers) — мы НЕ можем
|
||||
// передать "0x..." как function_selector (TronGrid keccak'нёт строку и получит
|
||||
// полностью другие 4 байта → contract revert). Используем `data` напрямую.
|
||||
const data = p.callData.startsWith('0x') ? p.callData.slice(2) : p.callData;
|
||||
if (data.length < 8) throw new Error('TRX call data too short (need >= 4-byte selector)');
|
||||
const selector8 = data.slice(0, 8);
|
||||
const knownCanonical = lookupKnownSelector(selector8);
|
||||
|
||||
const callBody: any = {
|
||||
owner_address: fromTronAddr,
|
||||
contract_address: p.contractAddress,
|
||||
fee_limit: p.feeLimit,
|
||||
call_value: p.callValue > 0n ? Number(p.callValue) : 0,
|
||||
visible: true,
|
||||
};
|
||||
if (knownCanonical) {
|
||||
callBody.function_selector = knownCanonical;
|
||||
callBody.parameter = data.slice(8);
|
||||
} else {
|
||||
// Unknown selector (LiFi bridge call) — pass full calldata as-is
|
||||
callBody.data = data;
|
||||
}
|
||||
|
||||
// ── Fix 2: pre-simulation guard ──
|
||||
// Dry-run через triggerconstantcontract (read-only) ДО build+broadcast'а.
|
||||
// Если контракт revert'нёт на simulation — НЕ broadcast'им, fees не сгорают.
|
||||
// Это catches LiFi/Relay stale quotes + bad calldata + insufficient allowance + др.
|
||||
try {
|
||||
const simRes = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(callBody),
|
||||
});
|
||||
const simOk = simRes?.result?.result === true;
|
||||
if (!simOk) {
|
||||
const rawMsg = simRes?.result?.message;
|
||||
const msgDecoded = rawMsg
|
||||
? Buffer.from(rawMsg, 'hex')
|
||||
.toString()
|
||||
.split('')
|
||||
.map((ch) => (ch.charCodeAt(0) < 32 ? ' ' : ch))
|
||||
.join('')
|
||||
.trim()
|
||||
: '';
|
||||
const reason =
|
||||
msgDecoded ||
|
||||
simRes?.result?.code ||
|
||||
JSON.stringify(simRes?.result || {}).slice(0, 200);
|
||||
throw new BridgeSimulationError(
|
||||
`TRX bridge simulation reverted at ${p.contractAddress}: ${reason}. NOT broadcast (fees would burn). Re-quote and retry.`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err instanceof BridgeSimulationError) throw err;
|
||||
// TronGrid simulation API down → degraded mode: log warning, proceed (risk of burn'нутых fees,
|
||||
// но user не блокируется на upstream outage).
|
||||
logger.warn(`TRX pre-simulation failed (TronGrid down?), proceeding to broadcast: ${err?.message}`);
|
||||
}
|
||||
|
||||
const built = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(callBody),
|
||||
});
|
||||
|
||||
const txBody = built?.transaction;
|
||||
if (!txBody?.txID || !txBody?.raw_data_hex || !txBody?.raw_data) {
|
||||
throw new Error(`TRX bridge tx build failed: ${JSON.stringify(built).slice(0, 200)}`);
|
||||
}
|
||||
|
||||
// MITM defense (як в sendTrx): recompute txID + bounds + owner/contract.
|
||||
const expectedTxId = createHash('sha256').update(Buffer.from(txBody.raw_data_hex, 'hex')).digest('hex');
|
||||
if (expectedTxId !== txBody.txID) {
|
||||
throw new Error('TRX bridge txID mismatch — possible MITM');
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const expiration = Number(txBody.raw_data.expiration);
|
||||
const timestamp = Number(txBody.raw_data.timestamp);
|
||||
if (!Number.isFinite(expiration) || expiration - nowMs > 90_000 || expiration <= nowMs) {
|
||||
throw new Error(`TRX expiration out of bounds: ${expiration - nowMs}ms`);
|
||||
}
|
||||
if (!Number.isFinite(timestamp) || Math.abs(timestamp - nowMs) > 30_000) {
|
||||
throw new Error(`TRX timestamp drift: ${timestamp - nowMs}ms`);
|
||||
}
|
||||
const contract0 = txBody.raw_data.contract?.[0];
|
||||
if (contract0?.type !== 'TriggerSmartContract') {
|
||||
throw new Error(`TRX bridge contract type unexpected: ${contract0?.type}`);
|
||||
}
|
||||
const cv = contract0.parameter?.value;
|
||||
if (cv?.owner_address !== fromTronAddr) {
|
||||
throw new Error(`TRX bridge owner mismatch: ${cv?.owner_address}`);
|
||||
}
|
||||
if (cv?.contract_address !== p.contractAddress) {
|
||||
throw new Error(`TRX bridge contract mismatch: expected ${p.contractAddress}, got ${cv?.contract_address}`);
|
||||
}
|
||||
|
||||
// Sign
|
||||
const sk = new ethers.utils.SigningKey(wallet.privateKey);
|
||||
const sig = sk.signDigest('0x' + txBody.txID);
|
||||
if (sig.recoveryParam !== 0 && sig.recoveryParam !== 1) {
|
||||
throw new Error(`TRX bridge sig recoveryParam invalid: ${sig.recoveryParam}`);
|
||||
}
|
||||
const sigHex = sig.r.slice(2) + sig.s.slice(2) + sig.recoveryParam.toString(16).padStart(2, '0');
|
||||
|
||||
const cleanTxBody = {
|
||||
txID: txBody.txID,
|
||||
raw_data: txBody.raw_data,
|
||||
raw_data_hex: txBody.raw_data_hex,
|
||||
signature: [sigHex],
|
||||
visible: true,
|
||||
};
|
||||
const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(cleanTxBody),
|
||||
});
|
||||
if (!broadcast?.result) {
|
||||
const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown';
|
||||
const code = broadcast?.code || 'NO_CODE';
|
||||
throw new Error(`TRX bridge broadcast failed [${code}]: ${msg.slice(0, 200)}`);
|
||||
}
|
||||
return { txid: txBody.txID };
|
||||
}
|
||||
|
||||
export interface SignTrc20ApproveParams {
|
||||
mnemonic: string;
|
||||
expectedFromAddress: string;
|
||||
spender: string; // bridge router T...
|
||||
token: string; // TRC20 contract T...
|
||||
amount: string; // exact approve amount (decimal string)
|
||||
}
|
||||
|
||||
export async function signAndBroadcastTrc20Approve(p: SignTrc20ApproveParams): Promise<{ txid: string }> {
|
||||
// approve(address spender, uint256 amount) — function selector 0x095ea7b3
|
||||
const spenderHex = tronBase58ToHex(p.spender).padStart(64, '0');
|
||||
const amountHex = BigInt(p.amount).toString(16).padStart(64, '0');
|
||||
const callData = '095ea7b3' + spenderHex + amountHex;
|
||||
|
||||
return signAndBroadcastRawTron({
|
||||
mnemonic: p.mnemonic,
|
||||
expectedFromAddress: p.expectedFromAddress,
|
||||
contractAddress: p.token,
|
||||
callData,
|
||||
callValue: 0n,
|
||||
feeLimit: 30_000_000,
|
||||
});
|
||||
}
|
||||
|
||||
export interface SignTronPrebuiltParams {
|
||||
mnemonic: string;
|
||||
expectedFromAddress: string;
|
||||
/** Pre-built protobuf-encoded raw_data_hex от LiFi/Relay (transactionRequest.data) */
|
||||
rawDataHex: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign + broadcast a PRE-BUILT Tron tx (LiFi/Relay bridges возвращают уже-готовый
|
||||
* raw_data_hex в `transactionRequest.data` — НЕ EVM-style selector+params).
|
||||
*
|
||||
* **Не строит новый tx.** Просто:
|
||||
* 1. Compute txID = SHA256(raw_data_hex)
|
||||
* 2. Verify raw_data_hex содержит наш owner address (lightweight MITM check)
|
||||
* 3. Sign txID
|
||||
* 4. Broadcast {txID, raw_data_hex, signature[]}
|
||||
*
|
||||
* Этот helper НЕЛЬЗЯ использовать для locally-built tx (TRC20 approve и т.п.) —
|
||||
* для них используем `signAndBroadcastRawTron` / `signAndBroadcastTrc20Approve`,
|
||||
* которые сами строят tx через triggersmartcontract.
|
||||
*
|
||||
* Trust model: мы доверяем LiFi/Relay что raw_data корректен (они sim'или его на
|
||||
* их стороне). MITM defense ограничена substring-проверкой нашего owner_address
|
||||
* в protobuf bytes — без полного protobuf decoder.
|
||||
*/
|
||||
export async function signAndBroadcastTronPrebuiltTx(p: SignTronPrebuiltParams): Promise<{ txid: string }> {
|
||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX);
|
||||
const fromTronAddr = ethAddressToTron(wallet.address);
|
||||
if (fromTronAddr !== p.expectedFromAddress) {
|
||||
throw new Error(`Derived TRX address ${fromTronAddr} ≠ stored ${p.expectedFromAddress}`);
|
||||
}
|
||||
|
||||
// Normalize input
|
||||
const rawDataHex = p.rawDataHex.startsWith('0x') ? p.rawDataHex.slice(2) : p.rawDataHex;
|
||||
if (rawDataHex.length === 0 || !/^[0-9a-f]+$/i.test(rawDataHex)) {
|
||||
throw new Error('TRX prebuilt raw_data_hex is empty or not valid hex');
|
||||
}
|
||||
|
||||
// MITM defense (lightweight, без protobuf decoder):
|
||||
// Tron addresses в protobuf encoded as 21 bytes = 0x41 + 20-byte hex payload.
|
||||
// Если наш owner address не в raw_data — это не наша tx → reject.
|
||||
const ownerPayloadHex = tronBase58ToHex(fromTronAddr); // 20 bytes, без 0x41 prefix
|
||||
// Полный on-chain owner = '41' + ownerPayloadHex (21 bytes hex)
|
||||
const fullOwnerHex = '41' + ownerPayloadHex;
|
||||
if (!rawDataHex.toLowerCase().includes(fullOwnerHex.toLowerCase())) {
|
||||
throw new Error(
|
||||
`TRX prebuilt tx does not contain our owner address ${fromTronAddr} (${fullOwnerHex}) in raw_data — possible MITM or wrong wallet`,
|
||||
);
|
||||
}
|
||||
|
||||
// Compute txID (это и есть подписываемый digest)
|
||||
const txID = createHash('sha256').update(Buffer.from(rawDataHex, 'hex')).digest('hex');
|
||||
|
||||
// Sign
|
||||
const sk = new ethers.utils.SigningKey(wallet.privateKey);
|
||||
const sig = sk.signDigest('0x' + txID);
|
||||
if (sig.recoveryParam !== 0 && sig.recoveryParam !== 1) {
|
||||
throw new Error(`TRX prebuilt sig recoveryParam invalid: ${sig.recoveryParam}`);
|
||||
}
|
||||
const sigHex = sig.r.slice(2) + sig.s.slice(2) + sig.recoveryParam.toString(16).padStart(2, '0');
|
||||
|
||||
// Broadcast
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
const broadcastBody = {
|
||||
txID,
|
||||
raw_data_hex: rawDataHex,
|
||||
signature: [sigHex],
|
||||
visible: false,
|
||||
};
|
||||
const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasthex`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
// /wallet/broadcasthex — accepts hex transaction directly. Альтернатива
|
||||
// /wallet/broadcasttransaction (требует full object + raw_data field decoded).
|
||||
// broadcasthex проще: ему достаточно raw_data_hex + signature.
|
||||
body: JSON.stringify({
|
||||
transaction: encodeTronBroadcastHex(rawDataHex, sigHex),
|
||||
}),
|
||||
}).catch(async (firstErr: any) => {
|
||||
// Fallback на broadcasttransaction если broadcasthex не работает
|
||||
logger.warn(`broadcasthex failed (${firstErr?.message}), trying broadcasttransaction`);
|
||||
return await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(broadcastBody),
|
||||
});
|
||||
});
|
||||
|
||||
if (!broadcast?.result && broadcast?.code !== 'SUCCESS') {
|
||||
const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown';
|
||||
const code = broadcast?.code || 'NO_CODE';
|
||||
throw new Error(`TRX prebuilt broadcast failed [${code}]: ${msg.slice(0, 200)}`);
|
||||
}
|
||||
logger.info(`TRX prebuilt tx broadcast OK: from=${fromTronAddr} txID=${txID}`);
|
||||
return { txid: txID };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode signed tx как single hex string для /wallet/broadcasthex.
|
||||
* Format: full Transaction protobuf = raw_data + signature.
|
||||
*
|
||||
* Тут мы делаем небольшой protobuf-сборщик:
|
||||
* Transaction { bytes raw_data = 1 (length-prefixed); repeated bytes signature = 2 }
|
||||
* Field 1 (raw_data), wire type 2 (length-delimited): tag = 0x0a
|
||||
* Field 2 (signature), wire type 2 (length-delimited): tag = 0x12
|
||||
*/
|
||||
function encodeTronBroadcastHex(rawDataHex: string, sigHex: string): string {
|
||||
const rawDataBuf = Buffer.from(rawDataHex, 'hex');
|
||||
const sigBuf = Buffer.from(sigHex, 'hex');
|
||||
const parts: number[] = [];
|
||||
// Field 1: raw_data
|
||||
parts.push(0x0a);
|
||||
appendVarint(parts, rawDataBuf.length);
|
||||
for (const b of rawDataBuf) parts.push(b);
|
||||
// Field 2: signature
|
||||
parts.push(0x12);
|
||||
appendVarint(parts, sigBuf.length);
|
||||
for (const b of sigBuf) parts.push(b);
|
||||
return Buffer.from(parts).toString('hex');
|
||||
}
|
||||
|
||||
function appendVarint(out: number[], n: number): void {
|
||||
while (n > 0x7f) {
|
||||
out.push((n & 0x7f) | 0x80);
|
||||
n >>>= 7;
|
||||
}
|
||||
out.push(n & 0x7f);
|
||||
}
|
||||
|
||||
export interface ReadTrc20AllowanceParams {
|
||||
token: string;
|
||||
owner: string;
|
||||
spender: string;
|
||||
}
|
||||
|
||||
export async function readTrc20Allowance(p: ReadTrc20AllowanceParams): Promise<bigint> {
|
||||
// allowance(address owner, address spender) → uint256. Selector = 0xdd62ed3e
|
||||
const ownerHex = tronBase58ToHex(p.owner).padStart(64, '0');
|
||||
const spenderHex = tronBase58ToHex(p.spender).padStart(64, '0');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
const res = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
owner_address: p.owner,
|
||||
contract_address: p.token,
|
||||
function_selector: 'allowance(address,address)',
|
||||
parameter: ownerHex + spenderHex,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
const result = res?.constant_result?.[0];
|
||||
if (!result) return 0n;
|
||||
return BigInt('0x' + result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read native TRX balance в sun. 1 TRX = 1_000_000 sun.
|
||||
*/
|
||||
export async function readTrxBalance(address: string): Promise<bigint> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
const res = await fetchJson(`${TRONGRID}/wallet/getaccount`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ address, visible: true }),
|
||||
});
|
||||
const bal = res?.balance;
|
||||
if (bal === undefined || bal === null) return 0n; // empty/uninitialized account
|
||||
return BigInt(bal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read TRC20 token balance для owner. Returns smallest units.
|
||||
*/
|
||||
export async function readTrc20Balance(token: string, owner: string): Promise<bigint> {
|
||||
const ownerHex = tronBase58ToHex(owner).padStart(64, '0');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
const res = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
owner_address: owner,
|
||||
contract_address: token,
|
||||
function_selector: 'balanceOf(address)',
|
||||
parameter: ownerHex,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
const result = res?.constant_result?.[0];
|
||||
if (!result) return 0n;
|
||||
return BigInt('0x' + result);
|
||||
}
|
||||
|
||||
// ─── BTC bridge helpers ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sum confirmed UTXOs (satoshis) для BTC address — = доступный к spending balance.
|
||||
* Unconfirmed UTXOs игнорируются (matches sendBtc / signAndBroadcastBtcDeposit behavior).
|
||||
*/
|
||||
export async function readBtcConfirmedBalance(address: string): Promise<bigint> {
|
||||
const utxosRes = await fetchJson(`${BLOCKSTREAM}/address/${address}/utxo`);
|
||||
const utxos = (utxosRes as any[]) || [];
|
||||
let total = 0n;
|
||||
for (const u of utxos) {
|
||||
if (u.status?.confirmed) total += BigInt(u.value);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export interface SignBtcDepositParams {
|
||||
mnemonic: string;
|
||||
expectedFromAddress: string;
|
||||
depositAddress: string; // куда Relay просит отправить BTC (bridge solver address)
|
||||
amountSat: bigint; // сколько satoshis
|
||||
feeRateSatPerVb?: number; // optional override
|
||||
}
|
||||
|
||||
/**
|
||||
* Build P2WPKH (segwit bc1...) tx с одним recipient = depositAddress + change.
|
||||
* Sign все inputs + broadcast через blockstream.info.
|
||||
*
|
||||
* Re-uses bitcoinjs-lib patterns из существующего sendBtc — но без token check
|
||||
* и с custom recipient (вместо p.to).
|
||||
*/
|
||||
export async function signAndBroadcastBtcDeposit(p: SignBtcDepositParams): Promise<{ txid: string }> {
|
||||
const seed = await bip39.mnemonicToSeed(p.mnemonic);
|
||||
const root = bip32.fromSeed(seed);
|
||||
const child = root.derivePath(DERIVATION_PATHS.BTC);
|
||||
if (!child.publicKey) throw new Error('BTC derivation failed');
|
||||
|
||||
const network = bitcoin.networks.bitcoin;
|
||||
const pubkeyBuf = Buffer.from(child.publicKey);
|
||||
const payment = bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, network });
|
||||
if (!payment.address || !payment.output) throw new Error('BTC payment build failed');
|
||||
|
||||
if (payment.address !== p.expectedFromAddress) {
|
||||
throw new Error(`Derived BTC address ${payment.address} ≠ stored ${p.expectedFromAddress}`);
|
||||
}
|
||||
|
||||
// Bech32 sanity for deposit address (mainnet bc1... only)
|
||||
if (!/^bc1[ac-hj-np-z02-9]{6,}$/.test(p.depositAddress)) {
|
||||
throw new Error(`BTC deposit address malformed: ${p.depositAddress}`);
|
||||
}
|
||||
|
||||
const [utxosRes, feesRes, tipHeightRes] = await Promise.all([
|
||||
fetchJson(`${BLOCKSTREAM}/address/${payment.address}/utxo`),
|
||||
fetchJson(`${BLOCKSTREAM}/fee-estimates`),
|
||||
fetchJson(`${BLOCKSTREAM}/blocks/tip/height`).catch(() => null),
|
||||
]);
|
||||
const utxos = ((utxosRes as any[]) || []).filter((u) => u.status?.confirmed);
|
||||
if (!utxos.length) throw new Error('No confirmed BTC UTXOs to spend');
|
||||
const feeMap = feesRes as Record<string, number>;
|
||||
const rawCandidate = feeMap['6'] ?? feeMap['3'] ?? feeMap['1'];
|
||||
const rawNum = typeof rawCandidate === 'number' && Number.isFinite(rawCandidate) && rawCandidate > 0
|
||||
? rawCandidate
|
||||
: 2;
|
||||
const feeRate = Math.min(Math.max(Math.ceil(p.feeRateSatPerVb ?? rawNum), 2), 200); // floor 2 / ceil 200
|
||||
|
||||
if (p.amountSat <= 0n || p.amountSat > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
throw new Error('BTC amount out of safe range');
|
||||
}
|
||||
|
||||
// Sort UTXOs largest-first для минимизации количества inputs (меньше fee).
|
||||
utxos.sort((a, b) => b.value - a.value);
|
||||
|
||||
const psbt = new bitcoin.Psbt({ network });
|
||||
if (typeof tipHeightRes === 'number' && tipHeightRes > 0) {
|
||||
psbt.setLocktime(tipHeightRes); // anti fee-sniping
|
||||
}
|
||||
|
||||
const feeFor = (ins: number, outs: number) =>
|
||||
BigInt(Math.ceil((ins * 68 + outs * 31 + 11) * feeRate * 1.1));
|
||||
|
||||
let totalIn = 0n;
|
||||
const selected: typeof utxos = [];
|
||||
for (const u of utxos) {
|
||||
selected.push(u);
|
||||
totalIn += BigInt(u.value);
|
||||
if (totalIn >= p.amountSat + feeFor(selected.length, 2)) break;
|
||||
}
|
||||
const fee = feeFor(selected.length, 2);
|
||||
if (totalIn < p.amountSat + fee) {
|
||||
throw new Error(`Insufficient BTC balance: have=${totalIn} sat, need=${p.amountSat + fee} sat`);
|
||||
}
|
||||
|
||||
for (const u of selected) {
|
||||
psbt.addInput({
|
||||
hash: u.txid,
|
||||
index: u.vout,
|
||||
sequence: 0xfffffffd, // RBF enabled (BIP125)
|
||||
witnessUtxo: { script: payment.output, value: u.value },
|
||||
});
|
||||
}
|
||||
|
||||
psbt.addOutput({ address: p.depositAddress, value: Number(p.amountSat) });
|
||||
|
||||
const change = totalIn - p.amountSat - fee;
|
||||
const DUST_THRESHOLD = 294n;
|
||||
if (change < 0n) {
|
||||
throw new Error('BTC change negative (math bug)');
|
||||
}
|
||||
if (change > DUST_THRESHOLD) {
|
||||
psbt.addOutput({ address: payment.address, value: Number(change) });
|
||||
} else if (change > 0n) {
|
||||
throw new Error(
|
||||
`BTC change ${change} sat below dust threshold. Reduce amount by ${change} sat to consolidate.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
psbt.signInput(i, {
|
||||
publicKey: pubkeyBuf,
|
||||
sign: (hash: Buffer) => Buffer.from(child.sign(hash)),
|
||||
});
|
||||
}
|
||||
psbt.finalizeAllInputs();
|
||||
const txHex = psbt.extractTransaction().toHex();
|
||||
|
||||
const broadcastController = new AbortController();
|
||||
const tBroadcast = setTimeout(() => broadcastController.abort(), HTTP_TIMEOUT_MS);
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${BLOCKSTREAM}/tx`, {
|
||||
method: 'POST',
|
||||
body: txHex,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
signal: broadcastController.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(tBroadcast);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => '');
|
||||
throw new Error(`BTC bridge broadcast failed (${resp.status}): ${body.slice(0, 200)}`);
|
||||
}
|
||||
const txid = (await resp.text()).trim();
|
||||
logger.info(`BTC deposit broadcast OK: from=${payment.address} to=${p.depositAddress} sat=${p.amountSat} txid=${txid}`);
|
||||
return { txid };
|
||||
}
|
||||
|
||||
// ─── HTTP helper (local copy of `fetchJson` for testability) ─────────
|
||||
|
||||
/**
|
||||
* HTTP JSON fetcher с retry на 429 (rate limit) и 503 (transient overload).
|
||||
*
|
||||
* Backoff sequence: 0ms → 1500ms → 4000ms → 8000ms (≈13s worst-case до final fail).
|
||||
* Это покрывает TronGrid free-tier 3 req/sec limit (suspended 5s после превышения)
|
||||
* и blockstream.info burst limits.
|
||||
*
|
||||
* AbortController per-attempt. 4xx (кроме 429) и 5xx (кроме 503) — no retry, throws immediately.
|
||||
*/
|
||||
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
||||
const RETRY_DELAYS = [0, 1500, 4000, 8000];
|
||||
let lastErrMsg = '';
|
||||
for (let attempt = 0; attempt < RETRY_DELAYS.length; attempt++) {
|
||||
if (RETRY_DELAYS[attempt] > 0) {
|
||||
await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt]));
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: controller.signal });
|
||||
if (res.ok) {
|
||||
return await res.json();
|
||||
}
|
||||
const body = await res.text().catch(() => '');
|
||||
lastErrMsg = `Upstream ${res.status}: ${body.slice(0, 200)}`;
|
||||
// Retry на 429 (rate limit) и 503 (transient)
|
||||
if (res.status === 429 || res.status === 503) {
|
||||
logger.warn(`fetchJson ${res.status} on ${url} (attempt ${attempt + 1}/${RETRY_DELAYS.length}), backing off`);
|
||||
continue;
|
||||
}
|
||||
throw new Error(lastErrMsg);
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'AbortError') {
|
||||
lastErrMsg = `HTTP timeout (${HTTP_TIMEOUT_MS}ms)`;
|
||||
// Не retry на timeout — может быть upstream сильно загружен, escalate
|
||||
throw new Error(lastErrMsg);
|
||||
}
|
||||
if (attempt < RETRY_DELAYS.length - 1 && /Upstream (429|503)/.test(String(err?.message))) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
throw new Error(`fetchJson exhausted retries: ${lastErrMsg}`);
|
||||
}
|
||||
|
||||
// ─── TRON base58check decoder (local copy from wallet-signer.service.ts) ───
|
||||
// Decode base58check TRON address (T...) → 20-byte hex (без 0x41 prefix, без checksum).
|
||||
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
function tronBase58ToHex(address: string): string {
|
||||
if (typeof address !== 'string' || address.length === 0) {
|
||||
throw new Error('Invalid TRON address: empty');
|
||||
}
|
||||
let num = 0n;
|
||||
for (const ch of address) {
|
||||
const i = BASE58_ALPHABET.indexOf(ch);
|
||||
if (i === -1) throw new Error('Invalid base58 character in TRON address');
|
||||
num = num * 58n + BigInt(i);
|
||||
}
|
||||
let hex = num.toString(16);
|
||||
if (hex.length % 2 !== 0) hex = '0' + hex;
|
||||
let bytes = Buffer.from(hex, 'hex');
|
||||
let leadingOnes = 0;
|
||||
for (const ch of address) {
|
||||
if (ch === '1') leadingOnes++;
|
||||
else break;
|
||||
}
|
||||
if (leadingOnes > 0) {
|
||||
bytes = Buffer.concat([Buffer.alloc(leadingOnes, 0), bytes]);
|
||||
}
|
||||
if (bytes.length !== 25) {
|
||||
throw new Error(`Invalid TRON address length: ${bytes.length} bytes (expected 25)`);
|
||||
}
|
||||
if (bytes[0] !== 0x41) {
|
||||
throw new Error(`Invalid TRON address prefix: 0x${bytes[0].toString(16)} (expected 0x41)`);
|
||||
}
|
||||
const payload = bytes.subarray(0, 21);
|
||||
const checksum = bytes.subarray(21, 25);
|
||||
const dblSha = createHash('sha256').update(createHash('sha256').update(payload).digest()).digest();
|
||||
if (!dblSha.subarray(0, 4).equals(checksum)) {
|
||||
throw new Error('TRON address checksum mismatch');
|
||||
}
|
||||
return payload.subarray(1).toString('hex');
|
||||
}
|
||||
|
||||
// Selector hex (8 chars) → canonical name (e.g. '095ea7b3' → 'approve(address,uint256)').
|
||||
// Returns `null` если selector неизвестен — в этом случае caller должен использовать
|
||||
// `data` field вместо `function_selector + parameter` (TronGrid keccak'ит function_selector
|
||||
// как имя функции; для произвольного selector "0xXXXXXXXX" это даст НЕВЕРНЫЕ 4 байта,
|
||||
// → contract revert + потеря fees).
|
||||
function lookupKnownSelector(selector8: string): string | null {
|
||||
const map: Record<string, string> = {
|
||||
'a9059cbb': 'transfer(address,uint256)',
|
||||
'095ea7b3': 'approve(address,uint256)',
|
||||
'23b872dd': 'transferFrom(address,address,uint256)',
|
||||
'dd62ed3e': 'allowance(address,address)',
|
||||
'70a08231': 'balanceOf(address)',
|
||||
};
|
||||
return map[selector8.toLowerCase()] || null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
||||
-- ╔══════════════════════════════════════════════════════════════════╗
|
||||
-- ║ CryptoWallet API — Production DB schema ║
|
||||
-- ║ ║
|
||||
-- ║ APPEND-ONLY / NON-DESTRUCTIVE: ║
|
||||
-- ║ Безопасно прогонять повторно. Ничего не DROP'ает, не overwrite. ║
|
||||
-- ║ Если оператор добавил кастомные таблицы / индексы / constraints ║
|
||||
-- ║ вручную — они НЕ будут затронуты. ║
|
||||
-- ║ ║
|
||||
-- ║ Применять: psql -h <host> -U <user> -d <db> -f cryptowallet-schema.sql ║
|
||||
-- ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
-- NOTE: idempotency_keys и audit_log таблицы НЕ используются.
|
||||
-- - idempotency_keys → KeyDB (Redis cache) — apps/api/src/config/redis.ts
|
||||
-- - audit_log → stdout JSON-lines — apps/api/src/lib/audit-log.ts
|
||||
-- Скрипт их НЕ дропает (чтобы re-run был non-destructive).
|
||||
-- Если оператор хочет cleanup — manual one-time:
|
||||
-- DROP TABLE IF EXISTS audit_log CASCADE;
|
||||
-- DROP TABLE IF EXISTS idempotency_keys CASCADE;
|
||||
|
||||
-- ── USERS ───────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(26) NOT NULL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
last_name VARCHAR(128),
|
||||
first_name VARCHAR(128),
|
||||
middle_name VARCHAR(128),
|
||||
birth_date DATE,
|
||||
-- DEPRECATED: исторически generic crypto address; для ETH используем erc20 ниже.
|
||||
crypto_wallet VARCHAR(255),
|
||||
phone VARCHAR(16),
|
||||
inn VARCHAR(12),
|
||||
kyc_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
kyc_verified_at TIMESTAMP WITH TIME ZONE,
|
||||
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
passport_data VARCHAR(255),
|
||||
erc20 VARCHAR(255),
|
||||
-- EXTENSION (custodial wallet support):
|
||||
-- AES-256-GCM blob: IV(12) || ciphertext || authTag(16), base64. Master-key в Vault.
|
||||
encrypted_mnemonic TEXT
|
||||
);
|
||||
|
||||
-- Idempotent ALTERs для existing БД у которой нет extension-columns (только ADD если нет колонки)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic') THEN
|
||||
ALTER TABLE users ADD COLUMN encrypted_mnemonic TEXT;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'erc20') THEN
|
||||
ALTER TABLE users ADD COLUMN erc20 VARCHAR(255);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'passport_data') THEN
|
||||
ALTER TABLE users ADD COLUMN passport_data VARCHAR(255);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Constraint: blob size check (only ADDs if missing, никогда не DROP).
|
||||
-- Floor 100 (worst-case 12-word 3-char mnemonic = 100 base64 chars).
|
||||
-- Если оператор изменил этот constraint вручную — наш script его НЕ перезатрёт.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size') THEN
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_encrypted_mnemonic_size
|
||||
CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 100 AND 512));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Case-insensitive email uniqueness (Alice@x.com ≠ alice@x.com → ACCOUNT HIJACKING fix)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'users_email_lower_unique') THEN
|
||||
CREATE UNIQUE INDEX users_email_lower_unique ON users (lower(email));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Partial index для active-user queries
|
||||
CREATE INDEX IF NOT EXISTS idx_users_active ON users(id) WHERE is_deleted = FALSE;
|
||||
|
||||
-- erc20 format check (NULL or 0x + 40 hex)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_erc20_format') THEN
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_erc20_format
|
||||
CHECK (erc20 IS NULL OR erc20 ~ '^0x[0-9a-fA-F]{40}$');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- KYC consistency: verified=true requires verified_at NOT NULL
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_kyc_consistency') THEN
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_kyc_consistency
|
||||
CHECK ((kyc_verified = FALSE) OR (kyc_verified = TRUE AND kyc_verified_at IS NOT NULL));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── WALLETS ─────────────────────────────────────────────────────────
|
||||
-- ON DELETE RESTRICT: hard-delete user → запрос отвергнут пока есть wallets.
|
||||
-- Это защита от unrecoverable fund loss при GDPR-wipe или admin удалении.
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id VARCHAR(26) NOT NULL PRIMARY KEY,
|
||||
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
chain VARCHAR(16) NOT NULL,
|
||||
address VARCHAR(128) NOT NULL,
|
||||
derivation_path VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, chain),
|
||||
CHECK (chain IN ('ETH', 'BSC', 'BTC', 'TRX', 'SOL'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address);
|
||||
|
||||
-- NOTE: если БД старая и wallets.user_id_fkey ON DELETE CASCADE (а нужен RESTRICT
|
||||
-- для защиты от fund loss при delete user), оператор делает manual ОДИН раз:
|
||||
--
|
||||
-- ALTER TABLE wallets DROP CONSTRAINT wallets_user_id_fkey;
|
||||
-- ALTER TABLE wallets ADD CONSTRAINT wallets_user_id_fkey
|
||||
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;
|
||||
--
|
||||
-- Этот script ничего не дропает — re-run полностью non-destructive.
|
||||
@@ -1,70 +1,45 @@
|
||||
services:
|
||||
keydb:
|
||||
image: eqalpha/keydb
|
||||
container_name: cryptowallet-keydb
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: cryptowallet-db
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "6379"
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-cryptowallet_v2}
|
||||
volumes:
|
||||
- keydb_data:/data
|
||||
command:
|
||||
- keydb-server
|
||||
- --requirepass
|
||||
- ${REDIS_PASSWORD}
|
||||
- --dir
|
||||
- /data
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --appendfsync
|
||||
- everysec
|
||||
- --save
|
||||
- "900"
|
||||
- "1"
|
||||
- --save
|
||||
- "300"
|
||||
- "10"
|
||||
- --save
|
||||
- "60"
|
||||
- "10000"
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
# Наружу НЕ экспозим — только docker network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-cryptowallet_v2}"]
|
||||
interval: 5s
|
||||
timeout: 2s
|
||||
retries: 20
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: cryptowallet-api:latest
|
||||
container_name: cryptowallet-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
keydb:
|
||||
condition: service_healthy
|
||||
# Production: port открыт на all interfaces. TLS/WAF обязательно на reverse proxy.
|
||||
ports:
|
||||
- "3001:3001"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Override внутри docker network
|
||||
DB_HOST: postgres
|
||||
API_PORT: "3001"
|
||||
# Container hardening — post-RCE blast radius minimization.
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
pids_limit: 256
|
||||
mem_limit: 512m
|
||||
cpus: "1.0"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "--tries=1", "--timeout=3", "http://localhost:3001/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
NODE_ENV: production
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -72,4 +47,5 @@ services:
|
||||
max-file: "5"
|
||||
|
||||
volumes:
|
||||
keydb_data:
|
||||
pgdata:
|
||||
driver: local
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"turbo": "^2.4.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["tiny-secp256k1"],
|
||||
"onlyBuiltDependencies": ["bcrypt"],
|
||||
"overrides": {
|
||||
"ethers": "5.7.2"
|
||||
}
|
||||
|
||||
1105
pnpm-lock.yaml
generated
1105
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
70
start.sh
70
start.sh
@@ -3,54 +3,52 @@ set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
command -v docker >/dev/null 2>&1 || { echo "[ERROR] Docker not installed"; exit 1; }
|
||||
echo "=========================================="
|
||||
echo " CryptoWallet API - Production Deploy"
|
||||
echo "=========================================="
|
||||
|
||||
# 1. Docker check
|
||||
command -v docker >/dev/null || { echo "[ERROR] Docker not installed"; exit 1; }
|
||||
docker compose version >/dev/null 2>&1 || { echo "[ERROR] docker compose plugin missing"; exit 1; }
|
||||
|
||||
# .env handling
|
||||
# 2. .env check
|
||||
if [ ! -f .env ]; then
|
||||
if [ -f .env.example ]; then
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
echo "[INFO] .env создан из примера (mode 600) — заполни Vault креды и запусти снова"
|
||||
exit 1
|
||||
else
|
||||
echo "[ERROR] нет ни .env, ни .env.example"
|
||||
exit 1
|
||||
fi
|
||||
cp .env.example .env
|
||||
echo "[INFO] Created .env from .env.example"
|
||||
echo "[INFO] Fill in VAULT_ROLE_ID, VAULT_SECRET_ID and re-run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Защита: .env должен быть 600 (только владелец) — содержит Vault role/secret IDs.
|
||||
ENV_MODE=$(stat -c %a .env 2>/dev/null || stat -f %A .env 2>/dev/null)
|
||||
if [ "$ENV_MODE" != "600" ]; then
|
||||
echo "[WARN] .env mode is $ENV_MODE, enforcing 600"
|
||||
chmod 600 .env
|
||||
fi
|
||||
# 3. Build & start
|
||||
echo "[INFO] Building image..."
|
||||
docker compose build --pull api
|
||||
|
||||
# NOTE: logs/ директория НЕ нужна — audit-логи теперь в stdout (Docker logs).
|
||||
# Контейнер работает с read_only: true (см. docker-compose.yml).
|
||||
echo "[INFO] Starting services..."
|
||||
docker compose up -d
|
||||
|
||||
# Не используйте `docker compose down -v` — удалит keydb_data (кэш/idempotency).
|
||||
# Не пересоздавайте keydb без бэкапа. Обновление кода: `docker compose build api && docker compose up -d api`.
|
||||
echo "[INFO] Building and starting containers..."
|
||||
docker compose up -d --build
|
||||
|
||||
echo "[INFO] Waiting for API to become healthy..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:3001/api/health >/dev/null 2>&1; then
|
||||
echo "[OK] API is healthy"
|
||||
# 4. Wait for health
|
||||
echo "[INFO] Waiting for API to be healthy..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:3001/api/health >/dev/null 2>&1; then
|
||||
echo "[OK] API healthy"
|
||||
break
|
||||
fi
|
||||
if [ "$i" = "30" ]; then
|
||||
echo "[ERROR] API not healthy after 60s. Запусти 'docker compose logs --tail=50 api' для диагностики."
|
||||
if [ "$i" = "60" ]; then
|
||||
echo "[ERROR] API did not become healthy in 120s"
|
||||
docker compose logs --tail=50 api
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "API (loopback only): http://127.0.0.1:3001"
|
||||
echo " Перед публичным доступом → настрой reverse proxy (Caddy/Nginx) с TLS."
|
||||
echo "Health: http://127.0.0.1:3001/api/health"
|
||||
echo "Docs: http://127.0.0.1:3001/api/docs"
|
||||
echo "Logs: docker compose logs -f api"
|
||||
echo "Audit events: docker compose logs api | grep '\"level\":\"audit\"'"
|
||||
echo "=========================================="
|
||||
echo " Deploy complete"
|
||||
echo ""
|
||||
echo " API: http://localhost:3001"
|
||||
echo " Health: http://localhost:3001/api/health"
|
||||
echo " Docs: http://localhost:3001/api/docs"
|
||||
echo ""
|
||||
echo " Logs: docker compose logs -f api"
|
||||
echo " Stop: docker compose down"
|
||||
echo "=========================================="
|
||||
|
||||
Reference in New Issue
Block a user