Compare commits
2 Commits
main
...
517df542e1
| Author | SHA1 | Date | |
|---|---|---|---|
| 517df542e1 | |||
| 17855ecd87 |
@@ -1,12 +1,12 @@
|
|||||||
**/node_modules
|
node_modules/
|
||||||
**/dist
|
dist/
|
||||||
**/.git
|
.next/
|
||||||
**/.env
|
.env
|
||||||
**/.env.local
|
.env.local
|
||||||
**/*.log
|
*.log
|
||||||
**/logs
|
.turbo/
|
||||||
**/.DS_Store
|
.git/
|
||||||
**/.vscode
|
.gitea/
|
||||||
**/.idea
|
coverage/
|
||||||
pastdeploy/
|
.DS_Store
|
||||||
.seed-backup/
|
docs/
|
||||||
|
|||||||
115
.env.example
115
.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_ADDR=
|
||||||
VAULT_ROLE_ID=
|
VAULT_ROLE_ID=
|
||||||
VAULT_SECRET_ID=
|
VAULT_SECRET_ID=
|
||||||
@@ -6,74 +22,57 @@ VAULT_MOUNT_POINT=dev-secrets
|
|||||||
VAULT_SECRET_PATH=database
|
VAULT_SECRET_PATH=database
|
||||||
VAULT_JWT_KID_PATH=jwt/kid
|
VAULT_JWT_KID_PATH=jwt/kid
|
||||||
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
||||||
|
VAULT_CSRF_SECRET_PATH=cryptowallet/csrf
|
||||||
|
|
||||||
# CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF)
|
# CSRF (min 32 chars if not using Vault CSRF path)
|
||||||
VAULT_CSRF_PATH=csrf
|
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).
|
# JWT
|
||||||
# В 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_ALGORITHM=RS256
|
JWT_ALGORITHM=RS256
|
||||||
JWT_ISSUER=bitok
|
JWT_ACCESS_TTL_SECONDS=900
|
||||||
JWT_AUDIENCE=elcsa
|
JWT_REFRESH_TTL_SECONDS=2592000
|
||||||
|
JWT_ISSUER=auth-service
|
||||||
|
JWT_AUDIENCE=wallet-service
|
||||||
|
|
||||||
# ── Server ─────────────────────────────────────────────────────────
|
# Docs
|
||||||
API_PORT=3001
|
DOCS_USERNAME=admin
|
||||||
LOG_LEVEL=INFO
|
DOCS_PASSWORD=admin
|
||||||
|
|
||||||
# ── KeyDB / Redis (idempotency cache) ──────────────────────────────
|
# Redis
|
||||||
# REDIS_PASSWORD also used by docker-compose to seed KeyDB --requirepass.
|
|
||||||
REDIS_HOST=keydb
|
REDIS_HOST=keydb
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=keydb
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
# ── CORS ────────────────────────────────────────────────────────────
|
# RabbitMQ
|
||||||
# Comma-separated list of allowed origins. ПУСТО = no cross-origin.
|
RABBIT_EMAIL_CODE_QUEUE=email.verification_code
|
||||||
# Никогда не используй wildcard *
|
RABBIT_PUBLISH_PERSIST=true
|
||||||
CORS_ORIGINS=
|
RABBIT_CONNECT_TIMEOUT=5
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FORMAT=JSON
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS=http://localhost:3000,http://localhost:8000
|
||||||
CORS_ALLOW_CREDENTIALS=true
|
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=
|
RELAY_API_KEY=
|
||||||
|
|
||||||
|
# TRON
|
||||||
TRON_API_KEY=
|
TRON_API_KEY=
|
||||||
|
|
||||||
|
# Jupiter (Solana DEX aggregator)
|
||||||
JUPITER_API_KEY=
|
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=
|
|
||||||
|
|
||||||
# ── DeFi: staking (Lido/Solana) + Uniswap v3 LP ────────────────────
|
|
||||||
# Адреса опциональны (mainnet-дефолты в config/env.ts).
|
|
||||||
LIDO_STETH_ADDRESS=
|
|
||||||
LIDO_WITHDRAWAL_QUEUE_ADDRESS=
|
|
||||||
CURVE_STETH_POOL_ADDRESS=
|
|
||||||
UNISWAP_V3_NFPM_ADDRESS=
|
|
||||||
# Курируемые v3-пулы (csv). Дефолт — 3 пула: WETH/USDT, WBTC/WETH, XAUt/USDT.
|
|
||||||
UNISWAP_LP_POOLS=
|
|
||||||
# The Graph API-ключ для Uniswap v3 subgraph APR/TVL (пусто → APR=null).
|
|
||||||
THEGRAPH_API_KEY=
|
|
||||||
# Vote-адрес валидатора для нативного Solana-стейкинга. ОПЦИОНАЛЬНО (пусто → авто-выбор).
|
|
||||||
SOL_STAKE_VALIDATOR_VOTE=
|
|
||||||
|
|||||||
46
.gitignore
vendored
46
.gitignore
vendored
@@ -1,14 +1,36 @@
|
|||||||
.env
|
node_modules
|
||||||
.env.local
|
.pnpm-store
|
||||||
# Никогда не коммитить артефакты установки/сборки — только Docker build на сервере
|
.pnpm-debug.log*
|
||||||
node_modules/
|
|
||||||
**/node_modules/
|
.turbo
|
||||||
dist/
|
|
||||||
**/dist/
|
dist
|
||||||
.turbo/
|
build
|
||||||
**/.turbo/
|
out
|
||||||
|
.next
|
||||||
|
.nuxt
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
*.log
|
*.log
|
||||||
logs/
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode/
|
Thumbs.db
|
||||||
.idea/
|
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|||||||
72
Dockerfile
72
Dockerfile
@@ -1,52 +1,82 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
# syntax=docker/dockerfile:1.7
|
||||||
# Production Dockerfile — multi-stage build для shippable image.
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Финальный image: только compiled dist + prod deps + tini, runs as uid 1001.
|
# 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
|
ARG NODE_VERSION=20.19-alpine
|
||||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate \
|
ARG PNPM_VERSION=10.28.2
|
||||||
&& apk add --no-cache python3 make g++
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Base: node + pnpm
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:${NODE_VERSION} AS base
|
||||||
|
ARG PNPM_VERSION
|
||||||
|
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||||
WORKDIR /app
|
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
|
FROM base AS deps
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
COPY apps/api/package.json apps/api/
|
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
|
FROM base AS build
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=deps /app/apps/api/node_modules ./apps/api/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
|
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
|
FROM base AS prod-deps
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
COPY apps/api/package.json apps/api/
|
COPY apps/api/package.json apps/api/
|
||||||
RUN pnpm install --frozen-lockfile --prod \
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
&& BIGINT_DIR="$(find /app -path '*/node_modules/bigint-buffer' -type d 2>/dev/null | head -1)" \
|
pnpm install --frozen-lockfile --prod
|
||||||
&& if [ -n "$BIGINT_DIR" ]; then (cd "$BIGINT_DIR" && npm run rebuild); fi
|
|
||||||
|
|
||||||
# ── Stage 4: runtime image — minimal surface ──
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
FROM node:20-alpine AS runtime
|
# runtime: minimal production image
|
||||||
RUN apk add --no-cache tini wget \
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
&& addgroup -S app -g 1001 \
|
FROM node:${NODE_VERSION} AS runtime
|
||||||
&& adduser -S app -G app -u 1001
|
|
||||||
|
# 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
|
WORKDIR /app/apps/api
|
||||||
|
|
||||||
|
# Copy production dependencies
|
||||||
COPY --from=prod-deps --chown=app:app /app/node_modules /app/node_modules
|
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=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/dist ./dist
|
||||||
COPY --from=build --chown=app:app /app/apps/api/swagger.json ./swagger.json
|
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 --from=build --chown=app:app /app/apps/api/package.json ./package.json
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
API_PORT=3001
|
||||||
|
|
||||||
EXPOSE 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
|
CMD wget -qO- http://localhost:3001/api/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["/sbin/tini", "--"]
|
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",
|
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"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",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint src/ --ext .ts"
|
"lint": "eslint src/ --ext .ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"ed25519-hd-key": "^1.3.0",
|
|
||||||
"ethers": "5.7.2",
|
"ethers": "5.7.2",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^8.4.1",
|
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"ioredis": "^5.4.0",
|
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
"knex": "^3.1.0",
|
"knex": "^3.1.0",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"tiny-secp256k1": "^2.2.3",
|
|
||||||
"ulidx": "^2.4.1",
|
"ulidx": "^2.4.1",
|
||||||
"undici": "^6.21.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cookie-parser": "^1.4.7",
|
"@types/cookie-parser": "^1.4.7",
|
||||||
|
|||||||
@@ -6,164 +6,46 @@ import swaggerUi from 'swagger-ui-express';
|
|||||||
import { env } from './config/env';
|
import { env } from './config/env';
|
||||||
import { swaggerSpec } from './config/swagger';
|
import { swaggerSpec } from './config/swagger';
|
||||||
import { traceMiddleware } from './middleware/trace';
|
import { traceMiddleware } from './middleware/trace';
|
||||||
|
import { csrfProtect } from './middleware/csrf';
|
||||||
import { authMiddleware } from './middleware/auth';
|
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 { errorHandler } from './middleware/error-handler';
|
||||||
|
import csrfRoutes from './routes/csrf.routes';
|
||||||
import walletRoutes from './routes/wallet.routes';
|
import walletRoutes from './routes/wallet.routes';
|
||||||
import relayProxyRoutes from './routes/relay-proxy.routes';
|
import relayProxyRoutes from './routes/relay-proxy.routes';
|
||||||
import jumperProxyRoutes from './routes/jumper-proxy.routes';
|
|
||||||
import tronProxyRoutes from './routes/tron-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 btcProxyRoutes from './routes/btc-proxy.routes';
|
||||||
import pricesRoutes from './routes/prices.routes';
|
import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes';
|
||||||
import tokensRoutes from './routes/tokens.routes';
|
|
||||||
import bridgeRoutes from './routes/bridge.routes';
|
|
||||||
import stakingRoutes from './routes/staking.routes';
|
|
||||||
import lpRoutes from './routes/lp.routes';
|
|
||||||
import approvalsRoutes from './routes/approvals.routes';
|
|
||||||
import { metricsHandler, metricsMiddleware } from './lib/metrics';
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Trust proxy для корректного req.ip за reverse proxy / load balancer
|
|
||||||
app.set('trust proxy', 1);
|
|
||||||
|
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
|
app.use(cors({ origin: env.frontendUrl, credentials: true }));
|
||||||
// CORS — поддерживаем 3 режима:
|
app.use(express.json());
|
||||||
// 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(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(traceMiddleware);
|
app.use(traceMiddleware);
|
||||||
app.use(metricsMiddleware);
|
|
||||||
|
|
||||||
// ── PUBLIC endpoints ─────────────────────────────────────────────────────────
|
// ── PUBLIC endpoints (no auth) ────────────────────────────────────────────────
|
||||||
// H11 — /api/health with DB probe (не возвращает OK если DB down)
|
app.get('/api/health', (_req, res) => {
|
||||||
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' } });
|
res.json({ success: true, data: { status: 'ok' } });
|
||||||
} catch (err: any) {
|
|
||||||
res.status(503).json({ success: false, error: 'db_unavailable' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(['/healthcheck', '/api/healthcheck'], async (_req, res) => {
|
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||||
try {
|
app.get('/api/docs/swagger.json', (_req, res) => {
|
||||||
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' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get(['/metrics', '/api/metrics'], metricsHandler);
|
|
||||||
|
|
||||||
// ── Глобальный 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) => {
|
|
||||||
res.json(swaggerSpec);
|
res.json(swaggerSpec);
|
||||||
});
|
});
|
||||||
app.use('/api/docs', docsGate, swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
|
||||||
|
|
||||||
// ── PROTECTED endpoints (JWT + CSRF) ─────────────────────────────────────────
|
app.use('/api/csrf', csrfRoutes);
|
||||||
const protect = [authMiddleware, csrfMiddleware];
|
|
||||||
|
|
||||||
// Sensitive — самый строгий лимит. Каждый POST защищён JWT + CSRF.
|
// ── PROTECTED endpoints (JWT required) ────────────────────────────────────────
|
||||||
app.use('/api/wallets/create', ...protect, sensitiveLimiter);
|
app.use('/api/wallets', csrfProtect, authMiddleware, walletRoutes);
|
||||||
app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter);
|
app.use('/api/relay', csrfProtect, authMiddleware, relayProxyRoutes);
|
||||||
app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter);
|
app.use('/api/tron', csrfProtect, authMiddleware, tronProxyRoutes);
|
||||||
// Sensitive money-flow — строжайший лимит ТОЛЬКО на execute-эндпоинты (точный путь через app.post).
|
app.use('/api/sol/swap', csrfProtect, authMiddleware, solSwapProxyRoutes);
|
||||||
// Read-only quote/positions/pools матчатся обычным mutateLimiter (через router mount ниже).
|
app.use('/api/tron/swap', csrfProtect, authMiddleware, tronSwapProxyRoutes);
|
||||||
app.post('/api/wallets/:chain/stake', ...protect, sensitiveLimiter);
|
app.use('/api/btc', csrfProtect, authMiddleware, btcProxyRoutes);
|
||||||
app.post('/api/wallets/:chain/unstake', ...protect, sensitiveLimiter);
|
app.use('/api/bsc/swap', csrfProtect, authMiddleware, bscSwapProxyRoutes);
|
||||||
app.post('/api/wallets/:chain/unstake/claim', ...protect, sensitiveLimiter);
|
|
||||||
app.post('/api/wallets/:chain/lp/add', ...protect, sensitiveLimiter);
|
|
||||||
app.post('/api/wallets/:chain/lp/remove', ...protect, sensitiveLimiter);
|
|
||||||
// Custodial revoke approval (approve(spender,0)) — money-safety op, строжайший лимит.
|
|
||||||
app.post('/api/wallets/:chain/approvals/revoke-all', ...protect, sensitiveLimiter);
|
|
||||||
app.post('/api/wallets/:chain/approvals/revoke', ...protect, sensitiveLimiter);
|
|
||||||
|
|
||||||
// Mutating (proxy + read endpoints) — повышенный лимит
|
|
||||||
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
|
|
||||||
// Staking (Lido ETH) — custodial. Тот же namespace; пути /:chain/stake* не пересекаются с walletRoutes.
|
|
||||||
app.use('/api/wallets', ...protect, mutateLimiter, stakingRoutes);
|
|
||||||
// Uniswap v3 LP — custodial. Пути /:chain/lp/* не пересекаются с wallet/staking routes.
|
|
||||||
app.use('/api/wallets', ...protect, mutateLimiter, lpRoutes);
|
|
||||||
// Approval scan/revoke — custodial. Пути /:chain/approvals* не пересекаются с wallet/staking/lp routes.
|
|
||||||
// Закрывает повисший unlimited-approval (drain-вектор) + read-only scan открытых дверей.
|
|
||||||
app.use('/api/wallets', ...protect, mutateLimiter, approvalsRoutes);
|
|
||||||
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' });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
|||||||
@@ -9,18 +9,23 @@ const p = process.env;
|
|||||||
|
|
||||||
export let env = {
|
export let env = {
|
||||||
db: {
|
db: {
|
||||||
host: p.DB_HOST || '',
|
host: p.DB_HOST || 'localhost',
|
||||||
port: parseInt(p.DB_PORT || '5432'),
|
port: parseInt(p.DB_PORT || '5432'),
|
||||||
user: p.DB_USER || '',
|
user: p.DB_USER || 'postgres',
|
||||||
password: p.DB_PASSWORD || '',
|
password: p.DB_PASSWORD || 'postgres',
|
||||||
name: p.DB_NAME || '',
|
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: {
|
jwt: {
|
||||||
algorithm: p.JWT_ALGORITHM || 'RS256',
|
algorithm: p.JWT_ALGORITHM || 'RS256',
|
||||||
// Намеренно без default — каждый деплой ЯВНО указывает iss/aud, иначе сервис
|
issuer: p.JWT_ISSUER || 'auth-service',
|
||||||
// примет любой токен подписанный нашими ключами с любым iss/aud.
|
audience: p.JWT_AUDIENCE || 'bitforce',
|
||||||
issuer: p.JWT_ISSUER || '',
|
accessTtl: parseInt(p.JWT_ACCESS_TTL_SECONDS || '900'),
|
||||||
audience: p.JWT_AUDIENCE || '',
|
refreshTtl: parseInt(p.JWT_REFRESH_TTL_SECONDS || '2592000'),
|
||||||
},
|
},
|
||||||
vault: {
|
vault: {
|
||||||
addr: p.VAULT_ADDR || '',
|
addr: p.VAULT_ADDR || '',
|
||||||
@@ -30,71 +35,49 @@ export let env = {
|
|||||||
secretPath: p.VAULT_SECRET_PATH || 'database',
|
secretPath: p.VAULT_SECRET_PATH || 'database',
|
||||||
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
|
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
|
||||||
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
|
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
|
||||||
csrfPath: p.VAULT_CSRF_PATH || '',
|
csrfSecretPath: p.VAULT_CSRF_SECRET_PATH || 'cryptowallet/csrf',
|
||||||
cryptoKeyPath: p.VAULT_CRYPTO_KEY_PATH || 'crypto/master',
|
|
||||||
},
|
},
|
||||||
cors: {
|
csrf: {
|
||||||
// CORS_ORIGINS:
|
cookieSecure: p.CSRF_COOKIE_SECURE === 'true',
|
||||||
// - comma-separated list of origins → whitelist (recommended for prod)
|
cookieHttpOnly: p.CSRF_COOKIE_HTTPONLY !== 'false',
|
||||||
// - "*" → wildcard, любой origin принят (для dev/staging)
|
cookieSameSite: p.CSRF_COOKIE_SAMESITE || 'Lax',
|
||||||
// - "" → cross-origin blocked (fail-secure default)
|
cookiePath: p.CSRF_COOKIE_PATH || '/',
|
||||||
// Wildcard incompatible с CORS_ALLOW_CREDENTIALS=true (browser spec).
|
cookieDomain: p.CSRF_COOKIE_DOMAIN || '',
|
||||||
origins: (() => {
|
},
|
||||||
const raw = (p.CORS_ORIGINS || '').split(',').map((o) => o.trim()).filter(Boolean);
|
docs: {
|
||||||
// Wildcard sentinel — единственное значение `*` активирует wildcard mode.
|
username: p.DOCS_USERNAME || 'admin',
|
||||||
if (raw.length === 1 && raw[0] === '*') return ['*'];
|
password: p.DOCS_PASSWORD || 'admin',
|
||||||
// Иначе строгая 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',
|
|
||||||
},
|
},
|
||||||
port: parseInt(p.API_PORT || '3001'),
|
|
||||||
redis: {
|
redis: {
|
||||||
host: p.REDIS_HOST || 'keydb',
|
host: p.REDIS_HOST || 'keydb',
|
||||||
port: parseInt(p.REDIS_PORT || '6379'),
|
port: parseInt(p.REDIS_PORT || '6379'),
|
||||||
password: p.REDIS_PASSWORD || '',
|
password: p.REDIS_PASSWORD || 'keydb',
|
||||||
db: parseInt(p.REDIS_DB || '0'),
|
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,
|
relayApiKey: p.RELAY_API_KEY || null,
|
||||||
tronApiKey: p.TRON_API_KEY || null,
|
tronApiKey: p.TRON_API_KEY || null,
|
||||||
jupiterApiKey: p.JUPITER_API_KEY || null,
|
jupiterApiKey: p.JUPITER_API_KEY || null,
|
||||||
jupiterReferralAccount: p.JUPITER_REFERRAL_ACCOUNT || null,
|
jupiterReferralAccount: p.JUPITER_REFERRAL_ACCOUNT || null,
|
||||||
jupiterFeeBps: parseInt(p.JUPITER_FEE_BPS || '70'),
|
jupiterFeeBps: parseInt(p.JUPITER_FEE_BPS || '70'),
|
||||||
etherscanApiKey: p.ETHERSCAN_API_KEY || null,
|
|
||||||
bscscanApiKey: p.BSCSCAN_API_KEY || null,
|
|
||||||
// ── DeFi (staking + Uniswap v3 LP) ──────────────────────────────────
|
|
||||||
// Адреса хранятся lowercase; при использовании прогоняются через ethers.getAddress()
|
|
||||||
// → корректный checksum без риска ручной опечатки. Mainnet defaults.
|
|
||||||
defi: {
|
|
||||||
// Lido stETH (submit payable + ERC20 balanceOf)
|
|
||||||
lidoStEth: (p.LIDO_STETH_ADDRESS || '0xae7ab96520de3a18e5e111b5eaab095312d7fe84').toLowerCase(),
|
|
||||||
// Lido Withdrawal Queue (requestWithdrawals / claimWithdrawal) — для mode=queue
|
|
||||||
lidoWithdrawalQueue: (p.LIDO_WITHDRAWAL_QUEUE_ADDRESS || '0x889edc2edab5f40e902b864ad4d7abe3ce53ada8').toLowerCase(),
|
|
||||||
// Curve stETH/ETH pool — для мгновенного вывода (mode=swap) stETH→ETH (deepest liquidity)
|
|
||||||
curveStEthPool: (p.CURVE_STETH_POOL_ADDRESS || '0xdc24316b9ae028f1497c275eb9192a3ea0f67022').toLowerCase(),
|
|
||||||
// Uniswap v3 NonfungiblePositionManager (mint/increase/decrease/collect/burn)
|
|
||||||
uniswapV3Nfpm: (p.UNISWAP_V3_NFPM_ADDRESS || '0xc36442b4a4522e871399cd717abdd847ab11fe88').toLowerCase(),
|
|
||||||
// Курируемые v3-пулы (csv lowercased). Default — 3 пула (адреса подтверждены on-chain):
|
|
||||||
// WETH/USDT 0.3% 0x4e68ccd3e89f51c3074ca5072bbac773960dfa36
|
|
||||||
// WBTC/USDT 0.05% 0x56534741cd8b152df6d48adf7ac51f75169a83b2
|
|
||||||
// XAUt/USDT 0.05% 0x6546055f46e866a4b9a4a13e81273e3152bae5da
|
|
||||||
uniswapLpPools: (p.UNISWAP_LP_POOLS || '0x4e68ccd3e89f51c3074ca5072bbac773960dfa36,0x56534741cd8b152df6d48adf7ac51f75169a83b2,0x6546055f46e866a4b9a4a13e81273e3152bae5da')
|
|
||||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
|
||||||
// The Graph API-ключ для Uniswap v3 subgraph (APR/TVL). null → APR недоступен (не блокирует операции)
|
|
||||||
theGraphApiKey: p.THEGRAPH_API_KEY || null,
|
|
||||||
// Vote-адрес валидатора для нативного Solana-стейкинга. ОПЦИОНАЛЬНО: пусто → авто-выбор
|
|
||||||
// хорошего валидатора (Stakewiz → RPC → fallback, см. lib/sol-validator.ts). Задай, чтобы закрепить.
|
|
||||||
solStakeValidatorVote: p.SOL_STAKE_VALIDATOR_VOTE || '',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let vaultToken: string | null = null;
|
let vaultToken: string | null = null;
|
||||||
@@ -104,11 +87,6 @@ export function getVaultToken(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function initEnv(): Promise<void> {
|
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;
|
const { addr, roleId, secretId, mount, secretPath } = env.vault;
|
||||||
|
|
||||||
if (!addr || !roleId || !secretId) {
|
if (!addr || !roleId || !secretId) {
|
||||||
@@ -116,20 +94,6 @@ export async function initEnv(): Promise<void> {
|
|||||||
return;
|
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);
|
const token = await vaultAppRoleLogin(addr, roleId, secretId);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
logger.warn('Vault AppRole login failed, using .env fallback');
|
logger.warn('Vault AppRole login failed, using .env fallback');
|
||||||
@@ -147,44 +111,56 @@ export async function initEnv(): Promise<void> {
|
|||||||
|
|
||||||
logger.info('Loaded DB secrets from Vault');
|
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 s = (key: string) => secrets[key];
|
||||||
const si = (key: string, fallback: number) => {
|
const si = (key: string, fallback: number) => {
|
||||||
const v = secrets[key];
|
const v = secrets[key];
|
||||||
return v ? parseInt(v) : fallback;
|
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 = {
|
||||||
...env,
|
...env,
|
||||||
db: {
|
db: {
|
||||||
host: s('host') || s('DB_HOST') || env.db.host,
|
host: s('DB_HOST') || env.db.host,
|
||||||
port: si('port', si('DB_PORT', env.db.port)),
|
port: si('DB_PORT', env.db.port),
|
||||||
user: s('user') || s('DB_USER') || env.db.user,
|
user: s('DB_USER') || env.db.user,
|
||||||
password: s('password') || s('DB_PASSWORD') || env.db.password,
|
password: s('DB_PASSWORD') || env.db.password,
|
||||||
name: s('name') || s('DB_NAME') || env.db.name,
|
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: {
|
jwt: {
|
||||||
...env.jwt,
|
...env.jwt,
|
||||||
// H17 — trim whitespace; пустая строка после trim → fallback на env
|
issuer: s('JWT_ISSUER') || env.jwt.issuer,
|
||||||
issuer: (s('JWT_ISSUER')?.trim() || env.jwt.issuer),
|
audience: s('JWT_AUDIENCE') || env.jwt.audience,
|
||||||
audience: (s('JWT_AUDIENCE')?.trim() || 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: {
|
cors: {
|
||||||
origins: s('CORS_ORIGINS') ? s('CORS_ORIGINS')!.split(',') : env.cors.origins,
|
origins: s('CORS_ORIGINS') ? s('CORS_ORIGINS')!.split(',') : env.cors.origins,
|
||||||
// H5 — fail-secure consistent with line 53: explicit 'true' required, default false.
|
allowCredentials: secrets['CORS_ALLOW_CREDENTIALS'] !== 'false',
|
||||||
// Раньше: `!== 'false'` → defaults TRUE if field missing/empty (security inversion).
|
},
|
||||||
allowCredentials: secrets['CORS_ALLOW_CREDENTIALS'] === 'true',
|
rateLimit: {
|
||||||
|
requests: si('RATE_LIMIT_REQUESTS', env.rateLimit.requests),
|
||||||
|
window: si('RATE_LIMIT_WINDOW', env.rateLimit.window),
|
||||||
},
|
},
|
||||||
relayApiKey: s('RELAY_API_KEY') || env.relayApiKey,
|
relayApiKey: s('RELAY_API_KEY') || env.relayApiKey,
|
||||||
tronApiKey: s('TRON_API_KEY') || env.tronApiKey,
|
tronApiKey: s('TRON_API_KEY') || env.tronApiKey,
|
||||||
jupiterApiKey: s('JUPITER_API_KEY') || env.jupiterApiKey,
|
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,405 +0,0 @@
|
|||||||
/**
|
|
||||||
* Approval management — custodial scan + revoke + revoke-all («отвязка от всего») для EVM (ETH/BSC).
|
|
||||||
*
|
|
||||||
* Зачем: депозиты «утекают» через повисший unlimited-approval (напр. USDT → Relay Router v2 =
|
|
||||||
* MAX_UINT256): любой Relay-солвер может вывести каждое новое поступление токена с кошелька,
|
|
||||||
* заполняя свой ордер. Кошелёк custodial (ключ на сервере), поэтому отзыв `approve(spender, 0)`
|
|
||||||
* должен подписать сервер — revoke.cash не подходит (у пользователя нет приватного ключа).
|
|
||||||
*
|
|
||||||
* Безопасность:
|
|
||||||
* - revoke ВСЕГДА хардкодит `amount: '0'` → endpoint может ТОЛЬКО снизить allowance, никогда
|
|
||||||
* не выдать. Поэтому даже произвольные (но валидные) token/spender не являются attack surface.
|
|
||||||
* - JWT-bind кошелька chain + Idempotency-Key + auditLogStrict + decrypt mnemonic в памяти —
|
|
||||||
* тот же мутационный паттерн, что в bridge.routes.ts / staking.controller.ts.
|
|
||||||
* - scan — read-only `allowance` view-call (без подписи), только для видимости открытых дверей.
|
|
||||||
*
|
|
||||||
* Prevention НОВЫХ unlimited-approval сделана отдельно (lib/evm-tx-policy.ts cap 2^200);
|
|
||||||
* этот модуль закрывает УЖЕ повисшие approval-ы и даёт инструмент на будущее.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Request, Response } from 'express';
|
|
||||||
import { ethers } from 'ethers';
|
|
||||||
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 { signAndBroadcastEvmApprove, readErc20Allowance } from '../services/wallet-signer-bridge';
|
|
||||||
import { getEvmTokens } from '../lib/token-registry';
|
|
||||||
import { WETH_ADDRESS } from '../lib/defi-contracts';
|
|
||||||
import { formatSmallestUnits } from '../lib/amount-units';
|
|
||||||
import { env } from '../config/env';
|
|
||||||
|
|
||||||
type EvmChain = 'ETH' | 'BSC';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Порог «бесконечного» allowance — совпадает с cap из evm-tx-policy.ts (2^200 ≫ любого реального
|
|
||||||
* supply токена, но ловит MAX_UINT256 и near-max сентинелы). allowance выше → флаг unlimited.
|
|
||||||
*/
|
|
||||||
const UNLIMITED_THRESHOLD = 2n ** 200n;
|
|
||||||
|
|
||||||
interface KnownSpender { name: string; address: string; }
|
|
||||||
interface KnownToken { symbol: string; address: string; decimals: number; }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Known спендеры, которым наш flow когда-либо выдаёт approve. Сканируем allowance кошелька против
|
|
||||||
* них, чтобы показать открытые двери. Relay-роутеры — из evm-tx-policy.ts (RELAY_ROUTERS);
|
|
||||||
* Permit2 — canonical (один адрес на всех chain); NFPM — из env (Uniswap v3 LP).
|
|
||||||
*/
|
|
||||||
function knownSpenders(chain: EvmChain): KnownSpender[] {
|
|
||||||
const common: KnownSpender[] = [
|
|
||||||
{ name: 'Relay Router v2', address: '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f' },
|
|
||||||
{ name: 'Relay Depository v1', address: '0xa5f565650890fba1824ee0f21ebbbf660a179934' },
|
|
||||||
{ name: 'Permit2', address: '0x000000000022d473030f116ddee9f6b43ac78ba3' },
|
|
||||||
];
|
|
||||||
if (chain === 'ETH') {
|
|
||||||
return [
|
|
||||||
...common,
|
|
||||||
{ name: 'Relay Router v1', address: '0xf70da97812cb96acdf810712aa562db8dfa3dbef' },
|
|
||||||
{ name: 'Uniswap v3 NFPM', address: env.defi.uniswapV3Nfpm },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return common;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Known токены, которые реально аппрувятся в нашем flow (из registry) + WETH (авто-wrap для LP). */
|
|
||||||
function knownTokens(chain: EvmChain): KnownToken[] {
|
|
||||||
const base: KnownToken[] = getEvmTokens(chain).map((t) => ({
|
|
||||||
symbol: t.symbol,
|
|
||||||
address: t.contractAddress,
|
|
||||||
decimals: t.decimals,
|
|
||||||
}));
|
|
||||||
if (chain === 'ETH') base.push({ symbol: 'WETH', address: WETH_ADDRESS, decimals: 18 });
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserId(req: Request): string | null {
|
|
||||||
return (req as any).auth?.userId || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ETH/BSC only. Возвращает нормализованный chain или null (+ ответ 400). */
|
|
||||||
function resolveEvmChain(req: Request, res: Response): EvmChain | null {
|
|
||||||
const chain = String(req.params.chain).toUpperCase();
|
|
||||||
if (chain === 'ETH' || chain === 'BSC') return chain;
|
|
||||||
res.status(400).json({ success: false, error: 'Approvals supported on ETH and BSC only' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Одна открытая дверь (ненулевой allowance), найденная сканом. */
|
|
||||||
interface OpenApproval {
|
|
||||||
token: string;
|
|
||||||
tokenAddress: string;
|
|
||||||
spender: string;
|
|
||||||
spenderAddress: string;
|
|
||||||
allowance: string;
|
|
||||||
allowanceHuman: string;
|
|
||||||
unlimited: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Сканирует allowance кошелька по known-токенам × known-спендерам и возвращает только ОТКРЫТЫЕ
|
|
||||||
* (ненулевые) двери. Read-only (view-call, без подписи). Переиспользуется в `scanApprovals` (GET) и
|
|
||||||
* `revokeAllApprovals` (POST) — единый источник «какие двери открыты».
|
|
||||||
*/
|
|
||||||
async function scanOpenApprovals(chain: EvmChain, owner: string): Promise<OpenApproval[]> {
|
|
||||||
const tokens = knownTokens(chain);
|
|
||||||
const spenders = knownSpenders(chain);
|
|
||||||
const pairs: Array<{ token: KnownToken; spender: KnownSpender }> = [];
|
|
||||||
for (const token of tokens) for (const spender of spenders) pairs.push({ token, spender });
|
|
||||||
|
|
||||||
const results = await Promise.all(pairs.map(async ({ token, spender }) => {
|
|
||||||
try {
|
|
||||||
const allowance = await readErc20Allowance({
|
|
||||||
chain, token: token.address, owner, spender: spender.address,
|
|
||||||
});
|
|
||||||
if (allowance === 0n) return null; // только открытые двери
|
|
||||||
const unlimited = allowance > UNLIMITED_THRESHOLD;
|
|
||||||
return {
|
|
||||||
token: token.symbol,
|
|
||||||
tokenAddress: token.address,
|
|
||||||
spender: spender.name,
|
|
||||||
spenderAddress: spender.address,
|
|
||||||
allowance: allowance.toString(),
|
|
||||||
allowanceHuman: unlimited ? 'unlimited' : formatSmallestUnits(allowance.toString(), token.decimals),
|
|
||||||
unlimited,
|
|
||||||
} as OpenApproval;
|
|
||||||
} catch (err: any) {
|
|
||||||
logger.warn(`approvals scan: allowance read failed token=${token.symbol} spender=${spender.name}: ${err?.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
return results.filter((r): r is OpenApproval => r !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GET /:chain/approvals — read-only scan открытых allowance ──────────────
|
|
||||||
export async function scanApprovals(req: Request, res: Response): Promise<void> {
|
|
||||||
const userId = getUserId(req);
|
|
||||||
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
|
|
||||||
const chain = resolveEvmChain(req, res);
|
|
||||||
if (!chain) return;
|
|
||||||
|
|
||||||
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
|
||||||
if (!wallet) {
|
|
||||||
res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const approvals = await scanOpenApprovals(chain, wallet.address);
|
|
||||||
res.json({ success: true, data: { chain, owner: wallet.address, approvals } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /:chain/approvals/revoke — custodial revoke (approve(spender, 0)) ──
|
|
||||||
export async function revokeApproval(req: Request, res: Response): Promise<void> {
|
|
||||||
const userId = getUserId(req);
|
|
||||||
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
|
|
||||||
const chain = resolveEvmChain(req, res);
|
|
||||||
if (!chain) return;
|
|
||||||
|
|
||||||
const token = String(req.body?.token || '');
|
|
||||||
const spender = String(req.body?.spender || '');
|
|
||||||
if (!ethers.utils.isAddress(token)) {
|
|
||||||
res.status(400).json({ success: false, error: 'token must be a valid EVM contract address' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!ethers.utils.isAddress(spender)) {
|
|
||||||
res.status(400).json({ success: false, error: 'spender must be a valid EVM address' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
|
||||||
if (!wallet) {
|
|
||||||
res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-check: если allowance уже 0 — не тратим газ, отвечаем сразу (read-only, без подписи).
|
|
||||||
let current: bigint | null = null;
|
|
||||||
try {
|
|
||||||
current = await readErc20Allowance({ chain, token, owner: wallet.address, spender });
|
|
||||||
} catch (err: any) {
|
|
||||||
logger.warn(`approvals revoke pre-check failed (proceeding): ${err?.message}`);
|
|
||||||
}
|
|
||||||
if (current === 0n) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: { chain, token, spender, txid: null, alreadyZero: true, message: 'Allowance already 0 — nothing to revoke' },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Idempotency claim ──
|
|
||||||
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
|
|
||||||
if (idempKey) {
|
|
||||||
try {
|
|
||||||
const claim = await claimIdempotency(userId, idempKey, req.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Audit row BEFORE broadcast (strict) ──
|
|
||||||
let auditId: string;
|
|
||||||
try {
|
|
||||||
auditId = await auditLogStrict({ event: 'approval.revoke', userId, ip: req.ip || null, meta: { chain, token, spender } });
|
|
||||||
} catch (auditErr: any) {
|
|
||||||
logger.error(`Audit INSERT must succeed for approval.revoke: ${auditErr.message}`);
|
|
||||||
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 {
|
|
||||||
await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED');
|
|
||||||
res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// amount '0' ЗАХАРДКОЖЕН — endpoint может только отозвать, не выдать approval.
|
|
||||||
const { txid } = await signAndBroadcastEvmApprove({
|
|
||||||
chain,
|
|
||||||
mnemonic,
|
|
||||||
expectedFromAddress: wallet.address,
|
|
||||||
spender,
|
|
||||||
token,
|
|
||||||
amount: '0',
|
|
||||||
});
|
|
||||||
auditLog({
|
|
||||||
event: 'approval.revoke.broadcast', userId, ip: req.ip || null, result: 'success',
|
|
||||||
meta: { chain, token, spender, txid },
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
await completeAudit(auditId, 'success');
|
|
||||||
const data = { chain, token, spender, txid, previousAllowance: current?.toString() ?? null };
|
|
||||||
const respBody = { success: true, data };
|
|
||||||
if (idempKey) {
|
|
||||||
try { await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(respBody)); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
res.status(200).json(respBody);
|
|
||||||
} catch (err: any) {
|
|
||||||
await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80));
|
|
||||||
logger.warn(`Approval revoke failed: chain=${chain} token=${token} spender=${spender} → ${err?.message}`);
|
|
||||||
const respBody = { success: false, error: err?.message || 'revoke failed', code: err?.code };
|
|
||||||
if (idempKey) {
|
|
||||||
try { await saveIdempotencyResponse(userId, idempKey, 502, JSON.stringify(respBody)); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
res.status(502).json(respBody);
|
|
||||||
} finally {
|
|
||||||
mnemonic = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Похоже ли на «не хватает газа/нативки» — тогда дальнейшие revoke тоже упадут, прерываемся рано. */
|
|
||||||
function isInsufficientFunds(err: any): boolean {
|
|
||||||
const code = String(err?.code || '');
|
|
||||||
const msg = String(err?.message || '').toLowerCase();
|
|
||||||
return code === 'INSUFFICIENT_FUNDS' || msg.includes('insufficient funds');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /:chain/approvals/revoke-all — «отвязка от всего»: отозвать ВСЕ открытые двери ──
|
|
||||||
export async function revokeAllApprovals(req: Request, res: Response): Promise<void> {
|
|
||||||
const userId = getUserId(req);
|
|
||||||
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
|
|
||||||
const chain = resolveEvmChain(req, res);
|
|
||||||
if (!chain) return;
|
|
||||||
|
|
||||||
// По умолчанию отзываем ВСЕ ненулевые approval («от всего»). onlyUnlimited:true — только unlimited.
|
|
||||||
const onlyUnlimited = req.body?.onlyUnlimited === true;
|
|
||||||
|
|
||||||
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
|
||||||
if (!wallet) {
|
|
||||||
res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Серверный скан (не доверяем клиентскому списку) → какие двери открыты ──
|
|
||||||
let open: OpenApproval[];
|
|
||||||
try {
|
|
||||||
open = await scanOpenApprovals(chain, wallet.address);
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(502).json({ success: false, error: `approvals scan failed: ${err?.message || 'unknown'}` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const targets = onlyUnlimited ? open.filter((o) => o.unlimited) : open;
|
|
||||||
|
|
||||||
// Нет открытых дверей → ничего не подписываем (без decrypt/tx/газа).
|
|
||||||
if (targets.length === 0) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: { chain, owner: wallet.address, revoked: [], failed: [], skipped: [], total: 0, message: 'no open approvals — nothing to revoke' },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Idempotency claim ──
|
|
||||||
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
|
|
||||||
if (idempKey) {
|
|
||||||
try {
|
|
||||||
const claim = await claimIdempotency(userId, idempKey, req.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Audit BEFORE broadcast (strict) ──
|
|
||||||
let auditId: string;
|
|
||||||
try {
|
|
||||||
auditId = await auditLogStrict({
|
|
||||||
event: 'approval.revoke-all', userId, ip: req.ip || null,
|
|
||||||
meta: { chain, onlyUnlimited, count: targets.length },
|
|
||||||
});
|
|
||||||
} catch (auditErr: any) {
|
|
||||||
logger.error(`Audit INSERT must succeed for approval.revoke-all: ${auditErr.message}`);
|
|
||||||
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Decrypt mnemonic ОДИН раз для всех revoke ──
|
|
||||||
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 {
|
|
||||||
await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED');
|
|
||||||
res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const revoked: Array<{ token: string; spender: string; tokenAddress: string; spenderAddress: string; txid: string; previousAllowance: string }> = [];
|
|
||||||
const failed: Array<{ token: string; spender: string; tokenAddress: string; spenderAddress: string; error: string }> = [];
|
|
||||||
const skipped: Array<{ token: string; spender: string; tokenAddress: string; spenderAddress: string; reason: string }> = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// ПОСЛЕДОВАТЕЛЬНО: signAndBroadcastEvmApprove ждёт 1 conf → следующий nonce корректен.
|
|
||||||
for (let i = 0; i < targets.length; i++) {
|
|
||||||
const t = targets[i];
|
|
||||||
try {
|
|
||||||
// amount '0' ЗАХАРДКОЖЕН — отзыв, не выдача.
|
|
||||||
const { txid } = await signAndBroadcastEvmApprove({
|
|
||||||
chain, mnemonic, expectedFromAddress: wallet.address,
|
|
||||||
spender: t.spenderAddress, token: t.tokenAddress, amount: '0',
|
|
||||||
});
|
|
||||||
revoked.push({
|
|
||||||
token: t.token, spender: t.spender, tokenAddress: t.tokenAddress, spenderAddress: t.spenderAddress,
|
|
||||||
txid, previousAllowance: t.allowance,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
// Кончился газ/нативка → остальные тоже упадут: помечаем остаток skipped и выходим.
|
|
||||||
if (isInsufficientFunds(err)) {
|
|
||||||
failed.push({ token: t.token, spender: t.spender, tokenAddress: t.tokenAddress, spenderAddress: t.spenderAddress, error: 'insufficient funds for gas' });
|
|
||||||
for (let j = i + 1; j < targets.length; j++) {
|
|
||||||
const s = targets[j];
|
|
||||||
skipped.push({ token: s.token, spender: s.spender, tokenAddress: s.tokenAddress, spenderAddress: s.spenderAddress, reason: 'stopped: insufficient gas — top up native and retry' });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
failed.push({ token: t.token, spender: t.spender, tokenAddress: t.tokenAddress, spenderAddress: t.spenderAddress, error: err?.message?.slice(0, 160) || 'revoke failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
auditLog({
|
|
||||||
event: 'approval.revoke-all.broadcast', userId, ip: req.ip || null, result: 'success',
|
|
||||||
meta: { chain, revoked: revoked.length, failed: failed.length, skipped: skipped.length },
|
|
||||||
}).catch(() => {});
|
|
||||||
await completeAudit(auditId, 'success');
|
|
||||||
|
|
||||||
const data = { chain, owner: wallet.address, revoked, failed, skipped, total: targets.length };
|
|
||||||
const respBody = { success: true, data };
|
|
||||||
if (idempKey) {
|
|
||||||
try { await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(respBody)); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
res.status(200).json(respBody);
|
|
||||||
} catch (err: any) {
|
|
||||||
await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80));
|
|
||||||
logger.warn(`Approval revoke-all fatal: chain=${chain} → ${err?.message}`);
|
|
||||||
res.status(502).json({ success: false, error: err?.message || 'revoke-all failed' });
|
|
||||||
} finally {
|
|
||||||
mnemonic = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
/**
|
|
||||||
* Uniswap v3 LP endpoints — custodial (ETH). Пути /:chain/lp/*.
|
|
||||||
* Безопасность: JWT-bind ETH-кошелька, Idempotency-Key, auditLogStrict, decrypt mnemonic в памяти.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Request, Response } 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 { auditLogStrict, completeAudit } from '../lib/audit-log';
|
|
||||||
import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency';
|
|
||||||
import { StakingError } from '../services/staking.service';
|
|
||||||
import {
|
|
||||||
getPools,
|
|
||||||
quoteAdd,
|
|
||||||
executeAddLiquidity,
|
|
||||||
executeRemoveLiquidity,
|
|
||||||
readLpPositions,
|
|
||||||
} from '../services/uniswap-lp.service';
|
|
||||||
|
|
||||||
function getUserId(req: Request): string | null {
|
|
||||||
return (req as any).auth?.userId || null;
|
|
||||||
}
|
|
||||||
function assertEth(req: Request, res: Response): boolean {
|
|
||||||
if (String(req.params.chain).toUpperCase() !== 'ETH') {
|
|
||||||
res.status(400).json({ success: false, error: 'Uniswap v3 LP supported on ETH only' });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
function validAmount(v: unknown): v is string {
|
|
||||||
return typeof v === 'string' && /^\d+$/.test(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Ctx { auditId: string; mnemonic: string; wallet: { address: string }; idempKey: string | null; zero: () => void; }
|
|
||||||
|
|
||||||
async function beginMutation(req: Request, res: Response, userId: string, event: string, meta: Record<string, unknown>): Promise<Ctx | null> {
|
|
||||||
const wallet = await WalletModel.findByUserAndChain(userId, 'ETH');
|
|
||||||
if (!wallet) { res.status(403).json({ success: false, error: 'No ETH wallet for user — create wallet first' }); return null; }
|
|
||||||
|
|
||||||
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
|
|
||||||
if (idempKey) {
|
|
||||||
try {
|
|
||||||
const claim = await claimIdempotency(userId, idempKey, req.body || {});
|
|
||||||
if (!claim.fresh && claim.cached) { res.status(claim.cached.status).type('application/json').send(claim.cached.body); return null; }
|
|
||||||
} catch (err: any) { res.status(409).json({ success: false, error: err.message }); return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let auditId: string;
|
|
||||||
try {
|
|
||||||
auditId = await auditLogStrict({ event, userId, ip: req.ip || null, meta });
|
|
||||||
} catch (auditErr: any) {
|
|
||||||
logger.error(`Audit INSERT must succeed for ${event}: ${auditErr.message}`);
|
|
||||||
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 null; }
|
|
||||||
let mnemonic: string;
|
|
||||||
try { mnemonic = decryptMnemonic(blob); } catch { await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED'); res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' }); return null; }
|
|
||||||
|
|
||||||
const ctx: Ctx = { auditId, mnemonic, wallet, idempKey, zero: () => { ctx.mnemonic = ''; } };
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function finish(res: Response, userId: string, idempKey: string | null, auditId: string, data: unknown): Promise<void> {
|
|
||||||
await completeAudit(auditId, 'success');
|
|
||||||
const body = { success: true, data };
|
|
||||||
if (idempKey) { try { await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(body)); } catch { /* ignore */ } }
|
|
||||||
res.status(200).json(body);
|
|
||||||
}
|
|
||||||
async function fail(res: Response, userId: string, idempKey: string | null, auditId: string, err: any, label: string): Promise<void> {
|
|
||||||
const code = err instanceof StakingError ? err.httpStatus : 502;
|
|
||||||
await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80));
|
|
||||||
logger.warn(`LP ${label} failed: ${err?.message}`);
|
|
||||||
const body = { success: false, error: err?.message || `${label} failed`, code: err?.code };
|
|
||||||
if (idempKey) { try { await saveIdempotencyResponse(userId, idempKey, code, JSON.stringify(body)); } catch { /* ignore */ } }
|
|
||||||
res.status(code).json(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GET /:chain/lp/pools ───────────────────────────────────────────────
|
|
||||||
export async function lpPools(req: Request, res: Response): Promise<void> {
|
|
||||||
if (!getUserId(req)) { res.status(401).json({ success: false, error: 'auth required' }); return; }
|
|
||||||
if (!assertEth(req, res)) return;
|
|
||||||
try {
|
|
||||||
res.json({ success: true, data: await getPools() });
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(502).json({ success: false, error: err?.message || 'pools read failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /:chain/lp/quote ──────────────────────────────────────────────
|
|
||||||
export async function lpQuote(req: Request, res: Response): Promise<void> {
|
|
||||||
if (!getUserId(req)) { res.status(401).json({ success: false, error: 'auth required' }); return; }
|
|
||||||
if (!assertEth(req, res)) return;
|
|
||||||
const userId = getUserId(req)!;
|
|
||||||
const b = req.body || {};
|
|
||||||
if (!b.pool || !validAmount(String(b.amount0Desired ?? '')) || !validAmount(String(b.amount1Desired ?? ''))) {
|
|
||||||
res.status(400).json({ success: false, error: 'pool, amount0Desired, amount1Desired (integer strings) required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Кошелёк опционален: если есть — добавим проверку баланса token0/token1/gas (read-only, без 403).
|
|
||||||
const wallet = await WalletModel.findByUserAndChain(userId, 'ETH');
|
|
||||||
const quote = await quoteAdd({
|
|
||||||
poolAddress: String(b.pool),
|
|
||||||
priceLower: Number(b.priceLower),
|
|
||||||
priceUpper: Number(b.priceUpper),
|
|
||||||
amount0Desired: String(b.amount0Desired),
|
|
||||||
amount1Desired: String(b.amount1Desired),
|
|
||||||
slippageBps: b.slippageBps !== undefined ? Number(b.slippageBps) : undefined,
|
|
||||||
ownerAddress: wallet?.address,
|
|
||||||
useNativeEth: b.useNativeEth === true || b.useNativeEth === 'true',
|
|
||||||
});
|
|
||||||
res.json({ success: true, data: quote });
|
|
||||||
} catch (err: any) {
|
|
||||||
const status = err instanceof StakingError ? err.httpStatus : 502;
|
|
||||||
res.status(status).json({ success: false, error: err?.message || 'quote failed', code: err?.code });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /:chain/lp/add ────────────────────────────────────────────────
|
|
||||||
export async function lpAdd(req: Request, res: Response): Promise<void> {
|
|
||||||
const userId = getUserId(req);
|
|
||||||
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
|
|
||||||
if (!assertEth(req, res)) return;
|
|
||||||
const b = req.body || {};
|
|
||||||
if (!b.pool || !validAmount(String(b.amount0Desired ?? '')) || !validAmount(String(b.amount1Desired ?? ''))) {
|
|
||||||
res.status(400).json({ success: false, error: 'pool, amount0Desired, amount1Desired (integer strings) required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = await beginMutation(req, res, userId, 'lp.add', { pool: String(b.pool) });
|
|
||||||
if (!ctx) return;
|
|
||||||
const { auditId, mnemonic, wallet, idempKey } = ctx;
|
|
||||||
try {
|
|
||||||
const result = await executeAddLiquidity({
|
|
||||||
mnemonic, expectedFromAddress: wallet.address,
|
|
||||||
poolAddress: String(b.pool),
|
|
||||||
priceLower: Number(b.priceLower),
|
|
||||||
priceUpper: Number(b.priceUpper),
|
|
||||||
amount0Desired: String(b.amount0Desired),
|
|
||||||
amount1Desired: String(b.amount1Desired),
|
|
||||||
slippageBps: b.slippageBps !== undefined ? Number(b.slippageBps) : undefined,
|
|
||||||
useNativeEth: b.useNativeEth === true || b.useNativeEth === 'true',
|
|
||||||
});
|
|
||||||
await finish(res, userId, idempKey, auditId, result);
|
|
||||||
} catch (err: any) {
|
|
||||||
await fail(res, userId, idempKey, auditId, err, 'add');
|
|
||||||
} finally {
|
|
||||||
ctx.zero();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /:chain/lp/remove ─────────────────────────────────────────────
|
|
||||||
export async function lpRemove(req: Request, res: Response): Promise<void> {
|
|
||||||
const userId = getUserId(req);
|
|
||||||
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
|
|
||||||
if (!assertEth(req, res)) return;
|
|
||||||
const b = req.body || {};
|
|
||||||
const tokenId = String(b.tokenId ?? '');
|
|
||||||
const percent = Number(b.percent ?? 100);
|
|
||||||
if (!/^\d+$/.test(tokenId)) {
|
|
||||||
res.status(400).json({ success: false, error: 'tokenId (integer string) required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = await beginMutation(req, res, userId, 'lp.remove', { tokenId, percent });
|
|
||||||
if (!ctx) return;
|
|
||||||
const { auditId, mnemonic, wallet, idempKey } = ctx;
|
|
||||||
try {
|
|
||||||
const result = await executeRemoveLiquidity({ mnemonic, expectedFromAddress: wallet.address, tokenId, percent });
|
|
||||||
await finish(res, userId, idempKey, auditId, result);
|
|
||||||
} catch (err: any) {
|
|
||||||
await fail(res, userId, idempKey, auditId, err, 'remove');
|
|
||||||
} finally {
|
|
||||||
ctx.zero();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GET /:chain/lp/positions ───────────────────────────────────────────
|
|
||||||
export async function lpPositions(req: Request, res: Response): Promise<void> {
|
|
||||||
const userId = getUserId(req);
|
|
||||||
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
|
|
||||||
if (!assertEth(req, res)) return;
|
|
||||||
const wallet = await WalletModel.findByUserAndChain(userId, 'ETH');
|
|
||||||
if (!wallet) { res.status(403).json({ success: false, error: 'No ETH wallet for user — create wallet first' }); return; }
|
|
||||||
try {
|
|
||||||
res.json({ success: true, data: await readLpPositions(wallet.address) });
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(502).json({ success: false, error: err?.message || 'positions read failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
/**
|
|
||||||
* Staking endpoints — custodial. ETH → Lido, SOL → native (StakeProgram).
|
|
||||||
*
|
|
||||||
* Безопасность как в bridge.routes.ts: JWT-bind (адрес ≡ кошелёк юзера для chain),
|
|
||||||
* Idempotency-Key (анти-дабл-спенд), auditLogStrict до broadcast, decrypt mnemonic в памяти.
|
|
||||||
* Quote/positions читают из чейна (без записи). Пути: /:chain/stake*, /:chain/unstake*.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Request, Response } 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 {
|
|
||||||
quoteLidoStake,
|
|
||||||
executeLidoStake,
|
|
||||||
readLidoPositions,
|
|
||||||
unstakeLidoViaSwap,
|
|
||||||
unstakeLidoViaQueue,
|
|
||||||
claimLidoWithdrawal,
|
|
||||||
StakingError,
|
|
||||||
} from '../services/staking.service';
|
|
||||||
import {
|
|
||||||
quoteSolStake,
|
|
||||||
executeSolStake,
|
|
||||||
executeSolUnstake,
|
|
||||||
readSolStakePositions,
|
|
||||||
} from '../services/sol-stake.service';
|
|
||||||
|
|
||||||
type StakeChain = 'ETH' | 'SOL';
|
|
||||||
|
|
||||||
function getUserId(req: Request): string | null {
|
|
||||||
return (req as any).auth?.userId || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Разрешённые chains для стейкинга. Возвращает нормализованный chain или null (+ ответ 400). */
|
|
||||||
function resolveStakeChain(req: Request, res: Response): StakeChain | null {
|
|
||||||
const chain = String(req.params.chain).toUpperCase();
|
|
||||||
if (chain === 'ETH' || chain === 'SOL') return chain;
|
|
||||||
res.status(400).json({ success: false, error: 'Staking supported on ETH (Lido) and SOL (native) only' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validAmount(v: unknown): v is string {
|
|
||||||
return typeof v === 'string' && /^\d+$/.test(v) && v !== '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MutationCtx {
|
|
||||||
auditId: string;
|
|
||||||
mnemonic: string;
|
|
||||||
wallet: { address: string };
|
|
||||||
idempKey: string | null;
|
|
||||||
zero: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Общая преамбула мутации: JWT-bind кошелька chain + Idempotency-Key + auditLogStrict +
|
|
||||||
* decrypt mnemonic. На любой неуспех сам отвечает в res и возвращает null.
|
|
||||||
*/
|
|
||||||
async function beginMutation(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
userId: string,
|
|
||||||
chain: StakeChain,
|
|
||||||
event: string,
|
|
||||||
meta: Record<string, unknown>,
|
|
||||||
): Promise<MutationCtx | null> {
|
|
||||||
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
|
||||||
if (!wallet) {
|
|
||||||
res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
|
|
||||||
if (idempKey) {
|
|
||||||
try {
|
|
||||||
const claim = await claimIdempotency(userId, idempKey, req.body || {});
|
|
||||||
if (!claim.fresh && claim.cached) {
|
|
||||||
res.status(claim.cached.status).type('application/json').send(claim.cached.body);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(409).json({ success: false, error: err.message });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let auditId: string;
|
|
||||||
try {
|
|
||||||
auditId = await auditLogStrict({ event, userId, ip: req.ip || null, meta });
|
|
||||||
} catch (auditErr: any) {
|
|
||||||
logger.error(`Audit INSERT must succeed for ${event}: ${auditErr.message}`);
|
|
||||||
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 null;
|
|
||||||
}
|
|
||||||
let mnemonic: string;
|
|
||||||
try {
|
|
||||||
mnemonic = decryptMnemonic(blob);
|
|
||||||
} catch {
|
|
||||||
await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED');
|
|
||||||
res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx: MutationCtx = { auditId, mnemonic, wallet, idempKey, zero: () => { ctx.mnemonic = ''; } };
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function finishMutation(
|
|
||||||
res: Response, userId: string, idempKey: string | null, auditId: string, data: unknown,
|
|
||||||
): Promise<void> {
|
|
||||||
await completeAudit(auditId, 'success');
|
|
||||||
const respBody = { success: true, data };
|
|
||||||
if (idempKey) {
|
|
||||||
try { await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(respBody)); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
res.status(200).json(respBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function failMutation(
|
|
||||||
res: Response, userId: string, idempKey: string | null, auditId: string, err: any, label: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const code = err instanceof StakingError ? err.httpStatus : 502;
|
|
||||||
await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80));
|
|
||||||
logger.warn(`Stake ${label} failed: ${err?.message}`);
|
|
||||||
const respBody = { success: false, error: err?.message || `${label} failed`, code: err?.code };
|
|
||||||
if (idempKey) {
|
|
||||||
try { await saveIdempotencyResponse(userId, idempKey, code, JSON.stringify(respBody)); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
res.status(code).json(respBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /:chain/stake/quote ───────────────────────────────────────────
|
|
||||||
export async function stakeQuote(req: Request, res: Response): Promise<void> {
|
|
||||||
const userId = getUserId(req);
|
|
||||||
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
|
|
||||||
const chain = resolveStakeChain(req, res);
|
|
||||||
if (!chain) return;
|
|
||||||
|
|
||||||
const amount = String(req.body?.amount || '');
|
|
||||||
if (!validAmount(amount)) {
|
|
||||||
res.status(400).json({ success: false, error: 'amount must be a positive integer string (ETH=wei, SOL=lamports)' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const quote = chain === 'ETH' ? await quoteLidoStake(amount) : await quoteSolStake(amount);
|
|
||||||
res.json({ success: true, data: quote });
|
|
||||||
} catch (err: any) {
|
|
||||||
const status = err instanceof StakingError ? err.httpStatus : 502;
|
|
||||||
res.status(status).json({ success: false, error: err?.message || 'quote failed', code: err?.code });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /:chain/stake ─────────────────────────────────────────────────
|
|
||||||
export async function stakeExecute(req: Request, res: Response): Promise<void> {
|
|
||||||
const userId = getUserId(req);
|
|
||||||
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
|
|
||||||
const chain = resolveStakeChain(req, res);
|
|
||||||
if (!chain) return;
|
|
||||||
|
|
||||||
const amount = String(req.body?.amount || '');
|
|
||||||
if (!validAmount(amount)) {
|
|
||||||
res.status(400).json({ success: false, error: 'amount must be a positive integer string (ETH=wei, SOL=lamports)' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = await beginMutation(req, res, userId, chain, 'stake.execute', { chain, amount });
|
|
||||||
if (!ctx) return;
|
|
||||||
const { auditId, mnemonic, wallet, idempKey } = ctx;
|
|
||||||
try {
|
|
||||||
const result = chain === 'ETH'
|
|
||||||
? await executeLidoStake({ mnemonic, expectedFromAddress: wallet.address, amountWei: amount })
|
|
||||||
: await executeSolStake({ mnemonic, expectedFromAddress: wallet.address, amountLamports: amount });
|
|
||||||
auditLog({ event: 'stake.broadcast', userId, ip: req.ip || null, result: 'success', meta: { chain } }).catch(() => {});
|
|
||||||
await finishMutation(res, userId, idempKey, auditId, result);
|
|
||||||
} catch (err: any) {
|
|
||||||
await failMutation(res, userId, idempKey, auditId, err, 'stake');
|
|
||||||
} finally {
|
|
||||||
ctx.zero();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /:chain/unstake ───────────────────────────────────────────────
|
|
||||||
export async function unstakeExecute(req: Request, res: Response): Promise<void> {
|
|
||||||
const userId = getUserId(req);
|
|
||||||
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
|
|
||||||
const chain = resolveStakeChain(req, res);
|
|
||||||
if (!chain) return;
|
|
||||||
|
|
||||||
// Валидация тела зависит от chain
|
|
||||||
let amount = '';
|
|
||||||
let mode = 'swap';
|
|
||||||
let stakeAccount = '';
|
|
||||||
if (chain === 'ETH') {
|
|
||||||
amount = String(req.body?.amount || '');
|
|
||||||
if (!validAmount(amount)) {
|
|
||||||
res.status(400).json({ success: false, error: 'amount must be a positive integer string (stETH wei)' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mode = String(req.body?.mode || 'swap');
|
|
||||||
if (mode !== 'swap' && mode !== 'queue') {
|
|
||||||
res.status(400).json({ success: false, error: 'mode must be "swap" (default, instant) or "queue"' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// slippage на выводе задаётся сервером автоматически (маленький фикс) — клиент не управляет.
|
|
||||||
} else {
|
|
||||||
stakeAccount = String(req.body?.stakeAccount || '');
|
|
||||||
if (!stakeAccount) {
|
|
||||||
res.status(400).json({ success: false, error: 'stakeAccount required (SOL stake account address)' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = chain === 'ETH' ? { chain, mode, amount } : { chain, stakeAccount };
|
|
||||||
const ctx = await beginMutation(req, res, userId, chain, 'unstake.execute', meta);
|
|
||||||
if (!ctx) return;
|
|
||||||
const { auditId, mnemonic, wallet, idempKey } = ctx;
|
|
||||||
try {
|
|
||||||
let result: unknown;
|
|
||||||
if (chain === 'ETH') {
|
|
||||||
result = mode === 'queue'
|
|
||||||
? await unstakeLidoViaQueue({ mnemonic, expectedFromAddress: wallet.address, amountStEthWei: amount })
|
|
||||||
: await unstakeLidoViaSwap({ mnemonic, expectedFromAddress: wallet.address, amountStEthWei: amount });
|
|
||||||
} else {
|
|
||||||
result = await executeSolUnstake({ mnemonic, expectedFromAddress: wallet.address, stakeAccount });
|
|
||||||
}
|
|
||||||
await finishMutation(res, userId, idempKey, auditId, result);
|
|
||||||
} catch (err: any) {
|
|
||||||
await failMutation(res, userId, idempKey, auditId, err, 'unstake');
|
|
||||||
} finally {
|
|
||||||
ctx.zero();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /:chain/unstake/claim (ETH/Lido queue only) ───────────────────
|
|
||||||
export async function unstakeClaim(req: Request, res: Response): Promise<void> {
|
|
||||||
const userId = getUserId(req);
|
|
||||||
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
|
|
||||||
const chain = resolveStakeChain(req, res);
|
|
||||||
if (!chain) return;
|
|
||||||
if (chain !== 'ETH') {
|
|
||||||
res.status(400).json({ success: false, error: 'claim only applies to ETH Lido queue withdrawals' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestId = String(req.body?.requestId || '');
|
|
||||||
if (!/^\d+$/.test(requestId)) {
|
|
||||||
res.status(400).json({ success: false, error: 'requestId must be a positive integer string' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = await beginMutation(req, res, userId, 'ETH', 'unstake.lido.claim', { requestId });
|
|
||||||
if (!ctx) return;
|
|
||||||
const { auditId, mnemonic, wallet, idempKey } = ctx;
|
|
||||||
try {
|
|
||||||
const result = await claimLidoWithdrawal({ mnemonic, expectedFromAddress: wallet.address, requestId });
|
|
||||||
await finishMutation(res, userId, idempKey, auditId, result);
|
|
||||||
} catch (err: any) {
|
|
||||||
await failMutation(res, userId, idempKey, auditId, err, 'claim');
|
|
||||||
} finally {
|
|
||||||
ctx.zero();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GET /:chain/stake/positions ────────────────────────────────────────
|
|
||||||
export async function stakePositions(req: Request, res: Response): Promise<void> {
|
|
||||||
const userId = getUserId(req);
|
|
||||||
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
|
|
||||||
const chain = resolveStakeChain(req, res);
|
|
||||||
if (!chain) return;
|
|
||||||
|
|
||||||
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
|
||||||
if (!wallet) {
|
|
||||||
res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const positions = chain === 'ETH'
|
|
||||||
? await readLidoPositions(wallet.address)
|
|
||||||
: await readSolStakePositions(wallet.address);
|
|
||||||
res.json({ success: true, data: positions });
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(502).json({ success: false, error: err?.message || 'positions read failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 app from './app';
|
||||||
import { env, initEnv } from './config/env';
|
import { env, initEnv, getVaultToken } from './config/env';
|
||||||
import { refreshAllKeys, startKeyRotation, stopKeyRotation } from './services/key-rotation.service';
|
import { loadJwtKeysFromVault } from './services/jwt.service';
|
||||||
import { isCryptoReady, decryptMnemonic, encryptMnemonic } from './services/crypto.service';
|
import { loadCsrfSecretFromVault, finalizeCsrfConfigFromEnv } from './services/csrf.service';
|
||||||
import { db } from './config/database';
|
|
||||||
import { pingRedis, closeRedis } from './config/redis';
|
|
||||||
import { logger } from './lib/logger';
|
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() {
|
async function main() {
|
||||||
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
|
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
|
||||||
|
|
||||||
await initEnv();
|
await initEnv();
|
||||||
const refreshResult = await refreshAllKeys();
|
await loadCsrfSecretFromVault();
|
||||||
if (!refreshResult.ok) {
|
finalizeCsrfConfigFromEnv();
|
||||||
logger.error(`Initial Vault refresh failed: ${refreshResult.reason}. Refusing to start.`);
|
|
||||||
process.exit(1);
|
// 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.
|
const db = knex(knexConfig);
|
||||||
if (!isCryptoReady()) {
|
|
||||||
logger.error('Crypto master key not loaded — refusing to start (custodial wallets require it)');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Integrity self-test: если в БД уже есть encrypted_mnemonic, master-key должен их декриптить.
|
logger.info('Running migrations...');
|
||||||
// Иначе мы стартовали с НЕПРАВИЛЬНЫМ ключом (например после потери Vault dev-mode state) —
|
await db.migrate.latest();
|
||||||
// и любой /send или /sign будет тихо валиться с GCM auth error. Лучше упасть сразу.
|
logger.info('Migrations complete');
|
||||||
await runCryptoIntegritySelfTest();
|
|
||||||
|
|
||||||
// KeyDB / Redis ping — idempotency critical для money flow; fail-fast если недоступен.
|
await db.destroy();
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
startKeyRotation();
|
app.listen(env.port, () => {
|
||||||
|
|
||||||
const server = app.listen(env.port, () => {
|
|
||||||
logger.info(`Server running on port ${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) => {
|
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,86 +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 Taproot (bech32m P2TR, bc1p). Клиентские кошельки остаются SegWit — они платят сюда.
|
|
||||||
* Требует initEccLib(ecc) (см. lib/btc-ecc.ts), иначе построение выхода на bc1p падает. */
|
|
||||||
export const APP_FEE_WALLET_BTC = 'bc1pgak39caltz9j3r3wk9c0sy89a29hax9navxvdvaj3nmwuxpgp6fq2rxm4m';
|
|
||||||
|
|
||||||
/** 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,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* Side-effect модуль: инициализирует ECC-бэкенд bitcoinjs-lib один раз для taproot.
|
|
||||||
*
|
|
||||||
* Зачем: на bitcoinjs-lib ≥6 построение/валидация выхода на Taproot-адрес (bech32m `bc1p…`) требует
|
|
||||||
* `initEccLib(ecc)` — без него `address.toOutputScript`/`psbt.addOutput({address: 'bc1p…'})` бросают
|
|
||||||
* `No ECC Library provided`. Нам это нужно, чтобы платить app-комиссию на Taproot fee-кошелёк
|
|
||||||
* (`APP_FEE_WALLET_BTC = bc1p…`) с обычных SegWit-кошельков клиентов.
|
|
||||||
*
|
|
||||||
* Чисто аддитивно: задействуется ТОЛЬКО в taproot-путях bitcoinjs. p2wpkh-генерация (клиентские bc1q),
|
|
||||||
* ECDSA-подпись входов и валидация/отправка на bc1q/legacy — без изменений. Идемпотентно.
|
|
||||||
*
|
|
||||||
* Импортировать ПЕРВЫМ (до bitcoinjs-операций) в модулях, которые строят BTC-выходы:
|
|
||||||
* `wallet-signer.service.ts` (sendBtc), `wallet-signer-bridge.ts` (signAndBroadcastBtcDeposit),
|
|
||||||
* `wallet-generator.service.ts`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as bitcoin from 'bitcoinjs-lib';
|
|
||||||
import * as ecc from 'tiny-secp256k1';
|
|
||||||
|
|
||||||
bitcoin.initEccLib(ecc);
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* DeFi contract constants + scoped EVM allowlist для нового custodial-функционала
|
|
||||||
* (Lido staking + вывод, Uniswap v3 LP).
|
|
||||||
*
|
|
||||||
* В отличие от `evm-tx-policy.ts` (который защищает КЛИЕНТСКИЙ /sign-raw-evm-tx с произвольным
|
|
||||||
* calldata), здесь calldata строится НА СЕРВЕРЕ из фиксированных ABI + адресов из env — как в
|
|
||||||
* bridge-execute.service.ts. Поэтому Relay-политику не трогаем. Этот allowlist — defense-in-depth:
|
|
||||||
* даже server-built tx обязана иметь `to` из явного множества, иначе подпись отклоняется.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ethers } from 'ethers';
|
|
||||||
import { env } from '../config/env';
|
|
||||||
|
|
||||||
/** ETH mainnet RPC failover-набор (read + provider для server-built tx чтения). */
|
|
||||||
export const ETH_RPCS = [
|
|
||||||
'https://ethereum-rpc.publicnode.com',
|
|
||||||
'https://eth.llamarpc.com',
|
|
||||||
'https://rpc.ankr.com/eth',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Checksummed-адреса (env хранит lowercase, getAddress даёт корректный EIP-55). */
|
|
||||||
export function lidoStEthAddress(): string {
|
|
||||||
return ethers.utils.getAddress(env.defi.lidoStEth);
|
|
||||||
}
|
|
||||||
export function lidoWithdrawalQueueAddress(): string {
|
|
||||||
return ethers.utils.getAddress(env.defi.lidoWithdrawalQueue);
|
|
||||||
}
|
|
||||||
export function curveStEthPoolAddress(): string {
|
|
||||||
return ethers.utils.getAddress(env.defi.curveStEthPool);
|
|
||||||
}
|
|
||||||
export function uniswapNfpmAddress(): string {
|
|
||||||
return ethers.utils.getAddress(env.defi.uniswapV3Nfpm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Курируемые Uniswap v3 пулы (lowercase). */
|
|
||||||
export function uniswapLpPools(): string[] {
|
|
||||||
return env.defi.uniswapLpPools;
|
|
||||||
}
|
|
||||||
export function isCuratedPool(poolAddress: string): boolean {
|
|
||||||
return env.defi.uniswapLpPools.includes(poolAddress.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Множество разрешённых `to`-адресов для server-built DeFi-tx (основные вызовы через
|
|
||||||
* signAndBroadcastRawEvm). approve идёт через signAndBroadcastEvmApprove (отдельный helper).
|
|
||||||
* Pool token-адреса (для approve→NFPM) передаются динамически через `extraAllowed`.
|
|
||||||
*/
|
|
||||||
function baseAllowed(): Set<string> {
|
|
||||||
return new Set<string>([
|
|
||||||
env.defi.lidoStEth.toLowerCase(),
|
|
||||||
env.defi.lidoWithdrawalQueue.toLowerCase(),
|
|
||||||
env.defi.curveStEthPool.toLowerCase(),
|
|
||||||
env.defi.uniswapV3Nfpm.toLowerCase(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Защита: каждый server-built EVM-таргет ДОЛЖЕН быть в allowlist перед подписью.
|
|
||||||
* @param to целевой контракт
|
|
||||||
* @param extraAllowed доп. адреса (напр. token0/token1 пула)
|
|
||||||
*/
|
|
||||||
export function assertDefiEvmTarget(to: string, extraAllowed: string[] = []): void {
|
|
||||||
const allowed = baseAllowed();
|
|
||||||
for (const a of extraAllowed) allowed.add(a.toLowerCase());
|
|
||||||
if (!allowed.has(to.toLowerCase())) {
|
|
||||||
throw new Error(`DeFi policy: target ${to} not in allowlist`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ABIs ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Lido stETH. */
|
|
||||||
export const LIDO_ABI = [
|
|
||||||
'function submit(address _referral) payable returns (uint256)',
|
|
||||||
'function balanceOf(address owner) view returns (uint256)',
|
|
||||||
'function approve(address spender, uint256 amount) returns (bool)',
|
|
||||||
'function getPooledEthByShares(uint256 _sharesAmount) view returns (uint256)',
|
|
||||||
'function getSharesByPooledEth(uint256 _ethAmount) view returns (uint256)',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Lido Withdrawal Queue (ERC721). */
|
|
||||||
export const LIDO_WQ_ABI = [
|
|
||||||
'function requestWithdrawals(uint256[] _amounts, address _owner) returns (uint256[] requestIds)',
|
|
||||||
'function claimWithdrawal(uint256 _requestId)',
|
|
||||||
'function getWithdrawalRequests(address _owner) view returns (uint256[] requestsIds)',
|
|
||||||
'function getWithdrawalStatus(uint256[] _requestIds) view returns (tuple(uint256 amountOfStETH, uint256 amountOfShares, address owner, uint256 timestamp, bool isFinalized, bool isClaimed)[] statuses)',
|
|
||||||
'function MIN_STETH_WITHDRAWAL_AMOUNT() view returns (uint256)',
|
|
||||||
'function MAX_STETH_WITHDRAWAL_AMOUNT() view returns (uint256)',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Curve stETH/ETH StableSwap pool. i=0 ETH, i=1 stETH. */
|
|
||||||
export const CURVE_STETH_ABI = [
|
|
||||||
// НЕ payable: Curve забирает stETH через transferFrom (нужен approve), msg.value не используется.
|
|
||||||
'function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) returns (uint256)',
|
|
||||||
'function get_dy(int128 i, int128 j, uint256 dx) view returns (uint256)',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Uniswap v3 NonfungiblePositionManager. */
|
|
||||||
export const NFPM_ABI = [
|
|
||||||
'function balanceOf(address owner) view returns (uint256)',
|
|
||||||
'function ownerOf(uint256 tokenId) view returns (address)',
|
|
||||||
'function tokenOfOwnerByIndex(address owner, uint256 index) view returns (uint256)',
|
|
||||||
'function positions(uint256 tokenId) view returns (uint96 nonce, address operator, address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint128 tokensOwed0, uint128 tokensOwed1)',
|
|
||||||
'function mint(tuple(address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint256 amount0Desired, uint256 amount1Desired, uint256 amount0Min, uint256 amount1Min, address recipient, uint256 deadline) params) payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1)',
|
|
||||||
'function decreaseLiquidity(tuple(uint256 tokenId, uint128 liquidity, uint256 amount0Min, uint256 amount1Min, uint256 deadline) params) payable returns (uint256 amount0, uint256 amount1)',
|
|
||||||
'function collect(tuple(uint256 tokenId, address recipient, uint128 amount0Max, uint128 amount1Max) params) payable returns (uint256 amount0, uint256 amount1)',
|
|
||||||
'function burn(uint256 tokenId) payable',
|
|
||||||
'function multicall(bytes[] data) payable returns (bytes[] results)',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Uniswap v3 Pool (read-only нужное). */
|
|
||||||
export const UNIV3_POOL_ABI = [
|
|
||||||
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
|
|
||||||
'function liquidity() view returns (uint128)',
|
|
||||||
'function token0() view returns (address)',
|
|
||||||
'function token1() view returns (address)',
|
|
||||||
'function fee() view returns (uint24)',
|
|
||||||
'function tickSpacing() view returns (int24)',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Минимальный ERC20. */
|
|
||||||
export const ERC20_META_ABI = [
|
|
||||||
'function decimals() view returns (uint8)',
|
|
||||||
'function symbol() view returns (string)',
|
|
||||||
'function balanceOf(address owner) view returns (uint256)',
|
|
||||||
'function allowance(address owner, address spender) view returns (uint256)',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Canonical WETH9 (mainnet) — для авто-wrap нативного ETH в LP-депозите. */
|
|
||||||
export const WETH_ADDRESS = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
|
|
||||||
export const WETH_ABI = [
|
|
||||||
'function deposit() payable',
|
|
||||||
'function withdraw(uint256 wad)',
|
|
||||||
];
|
|
||||||
export function isWeth(addr: string): boolean {
|
|
||||||
return typeof addr === 'string' && addr.toLowerCase() === WETH_ADDRESS;
|
|
||||||
}
|
|
||||||
@@ -1,184 +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}`);
|
|
||||||
}
|
|
||||||
// ── Anti-unlimited-approval (CRITICAL) ──
|
|
||||||
// НИКОГДА не подписываем «бесконечный» approval (MAX_UINT256 и near-max сентинелы): открытый
|
|
||||||
// unlimited-approval позволяет солверу/роутеру вывести ЛЮБЫЕ будущие поступления токена с кошелька.
|
|
||||||
// amount = второй 32-byte параметр (hex 74..138). Cap 2^200 ≫ любого реального количества токенов
|
|
||||||
// (макс realistic ~2^128), но ловит unlimited. Клиент обязан аппрувить ТОЧНУЮ сумму свопа.
|
|
||||||
const approveAmount = ethers.BigNumber.from('0x' + p.data.slice(74, 138));
|
|
||||||
const MAX_REASONABLE_APPROVE = ethers.BigNumber.from(2).pow(200);
|
|
||||||
if (approveAmount.gt(MAX_REASONABLE_APPROVE)) {
|
|
||||||
throw new Error('Sign-raw policy: unlimited/oversized approval rejected — approve the exact swap amount, not MAX (открытый approval позволил бы вывести будущие поступления).');
|
|
||||||
}
|
|
||||||
// `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 };
|
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 {
|
function log(level: string, message: string): void {
|
||||||
if ((LEVELS[level] ?? 0) < MIN_LEVEL) return;
|
if ((LEVELS[level] ?? 0) < MIN_LEVEL) return;
|
||||||
|
|
||||||
@@ -66,7 +41,7 @@ function log(level: string, message: string): void {
|
|||||||
file: caller.file,
|
file: caller.file,
|
||||||
line: caller.line,
|
line: caller.line,
|
||||||
trace_id: getTraceId(),
|
trace_id: getTraceId(),
|
||||||
message: sanitize(message),
|
message,
|
||||||
};
|
};
|
||||||
process.stdout.write(JSON.stringify(entry) + '\n');
|
process.stdout.write(JSON.stringify(entry) + '\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import type { NextFunction, Request, Response } from 'express';
|
|
||||||
import { db } from '../config/database';
|
|
||||||
|
|
||||||
type Labels = Record<string, string>;
|
|
||||||
|
|
||||||
const SERVICE = 'cryptowallet';
|
|
||||||
const buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
|
||||||
const httpRequests = new Map<string, number>();
|
|
||||||
const httpLatencySum = new Map<string, number>();
|
|
||||||
const httpLatencyCount = new Map<string, number>();
|
|
||||||
const httpLatencyBuckets = new Map<string, number>();
|
|
||||||
|
|
||||||
function key(parts: string[]): string {
|
|
||||||
return parts.join('\u0000');
|
|
||||||
}
|
|
||||||
|
|
||||||
function inc(map: Map<string, number>, parts: string[], by = 1): void {
|
|
||||||
const k = key(parts);
|
|
||||||
map.set(k, (map.get(k) || 0) + by);
|
|
||||||
}
|
|
||||||
|
|
||||||
function labels(values: Labels): string {
|
|
||||||
return Object.entries(values)
|
|
||||||
.map(([k, v]) => `${k}="${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`)
|
|
||||||
.join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sample(name: string, value: number, values?: Labels): string {
|
|
||||||
return values ? `${name}{${labels(values)}} ${value}` : `${name} ${value}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeName(req: Request): string {
|
|
||||||
return req.route?.path ? String(req.baseUrl || '') + String(req.route.path) : req.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function metricsMiddleware(req: Request, res: Response, next: NextFunction): void {
|
|
||||||
if (req.path === '/metrics' || req.path === '/api/metrics') {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const started = process.hrtime.bigint();
|
|
||||||
res.on('finish', () => {
|
|
||||||
const elapsed = Number(process.hrtime.bigint() - started) / 1_000_000_000;
|
|
||||||
const route = routeName(req);
|
|
||||||
const method = req.method;
|
|
||||||
const statusCode = String(res.statusCode);
|
|
||||||
inc(httpRequests, [method, route, statusCode]);
|
|
||||||
inc(httpLatencySum, [method, route], elapsed);
|
|
||||||
inc(httpLatencyCount, [method, route]);
|
|
||||||
for (const bucket of buckets) {
|
|
||||||
if (elapsed <= bucket) {
|
|
||||||
inc(httpLatencyBuckets, [method, route, String(bucket)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function walletMetrics(): Promise<string[]> {
|
|
||||||
const lines: string[] = [];
|
|
||||||
const totalRow = await db('wallets').count<{ count: string }[]>('* as count').first();
|
|
||||||
lines.push(sample('cryptowallet_wallets_total', Number(totalRow?.count || 0), { service: SERVICE }));
|
|
||||||
|
|
||||||
const byChain = await db('wallets').select('chain').count<{ chain: string; count: string }[]>('* as count').groupBy('chain');
|
|
||||||
for (const row of byChain) {
|
|
||||||
lines.push(sample('cryptowallet_wallets_by_chain_total', Number(row.count || 0), { service: SERVICE, chain: row.chain }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const windows: [string, string | null][] = [
|
|
||||||
['all', null],
|
|
||||||
['30d', "now() - interval '30 days'"],
|
|
||||||
['7d', "now() - interval '7 days'"],
|
|
||||||
['24h', "now() - interval '24 hours'"],
|
|
||||||
['1h', "now() - interval '1 hour'"],
|
|
||||||
];
|
|
||||||
for (const [window, threshold] of windows) {
|
|
||||||
const query = db('wallets').count<{ count: string }[]>('* as count').first();
|
|
||||||
if (threshold) {
|
|
||||||
query.whereRaw(`created_at >= ${threshold}`);
|
|
||||||
}
|
|
||||||
const row = await query;
|
|
||||||
lines.push(sample('cryptowallet_wallets_created_total', Number(row?.count || 0), { service: SERVICE, window }));
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
function httpMetrics(): string[] {
|
|
||||||
const lines: string[] = ['# TYPE cryptowallet_http_requests_total counter'];
|
|
||||||
for (const [k, value] of [...httpRequests.entries()].sort()) {
|
|
||||||
const [method, route, statusCode] = k.split('\u0000');
|
|
||||||
lines.push(sample('cryptowallet_http_requests_total', value, { service: SERVICE, method, route, status_code: statusCode }));
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('# TYPE cryptowallet_http_request_duration_seconds histogram');
|
|
||||||
for (const [k, count] of [...httpLatencyCount.entries()].sort()) {
|
|
||||||
const [method, route] = k.split('\u0000');
|
|
||||||
for (const bucket of buckets) {
|
|
||||||
lines.push(sample('cryptowallet_http_request_duration_seconds_bucket', httpLatencyBuckets.get(key([method, route, String(bucket)])) || 0, { service: SERVICE, method, route, le: String(bucket) }));
|
|
||||||
}
|
|
||||||
lines.push(sample('cryptowallet_http_request_duration_seconds_bucket', count, { service: SERVICE, method, route, le: '+Inf' }));
|
|
||||||
lines.push(sample('cryptowallet_http_request_duration_seconds_sum', httpLatencySum.get(k) || 0, { service: SERVICE, method, route }));
|
|
||||||
lines.push(sample('cryptowallet_http_request_duration_seconds_count', count, { service: SERVICE, method, route }));
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function metricsHandler(_req: Request, res: Response): Promise<void> {
|
|
||||||
const lines = [
|
|
||||||
'# TYPE cryptowallet_wallets_total gauge',
|
|
||||||
'# TYPE cryptowallet_wallets_by_chain_total gauge',
|
|
||||||
'# TYPE cryptowallet_wallets_created_total gauge',
|
|
||||||
];
|
|
||||||
try {
|
|
||||||
lines.push(...await walletMetrics());
|
|
||||||
lines.push(sample('cryptowallet_metrics_scrape_success', 1, { service: SERVICE }));
|
|
||||||
} catch {
|
|
||||||
lines.push(sample('cryptowallet_metrics_scrape_success', 0, { service: SERVICE }));
|
|
||||||
}
|
|
||||||
lines.push(...httpMetrics());
|
|
||||||
res.type('text/plain; version=0.0.4; charset=utf-8').send(`${lines.join('\n')}\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,169 +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.
|
|
||||||
try {
|
|
||||||
return (await undiciFetch(input as any, {
|
|
||||||
...(init as any),
|
|
||||||
dispatcher: agent,
|
|
||||||
})) as unknown as Response;
|
|
||||||
} catch (err: any) {
|
|
||||||
// Таймаут/отмена (AbortSignal) — это НЕ отказ прокси, fallback не нужен (сохраняем 504-семантику).
|
|
||||||
if (err?.name === 'AbortError') throw err;
|
|
||||||
// Прокси недоступен (connect ECONNREFUSED / timeout до прокси) → самовосстановление: пробуем
|
|
||||||
// прямой выход. Relay/Jumper/Jupiter — публичные API, прямой fetch их достаёт, если у сервера
|
|
||||||
// есть direct egress. Срабатывает ТОЛЬКО при throw (HTTP-ошибка ОТ прокси не бросает — это
|
|
||||||
// обычный Response с !ok). Идемпотентно: при живом прокси сюда не заходим, поведение прежнее.
|
|
||||||
const reason = err?.cause?.code ?? err?.code ?? err?.name ?? 'unknown';
|
|
||||||
logger.warn(`Outbound proxy failed (${reason}) — fallback на прямой fetch`);
|
|
||||||
return fetch(input as any, init as any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* Авто-выбор «хорошего» Solana-валидатора для нативного стейкинга — чтобы работало из коробки
|
|
||||||
* без операторской настройки. Каскад с кэшем (KeyDB 24ч):
|
|
||||||
* 1. env override SOL_STAKE_VALIDATOR_VOTE (оператор может закрепить).
|
|
||||||
* 2. Stakewiz API (без ключа): фильтр delinquent/commission/skip_rate/uptime, сорт по wiz_score.
|
|
||||||
* 3. RPC getVoteAccounts: фильтр epochVoteAccount/commission/stake, сорт по (commission, credit growth).
|
|
||||||
* 4. Hard-coded last-resort (проверенный активный валидатор, 0% комиссия).
|
|
||||||
*
|
|
||||||
* Менять валидатор на новых стейках безопасно: stake-аккаунт запоминает свой voter в delegation,
|
|
||||||
* unstake/withdraw читают аккаунт, а не этот резолвер.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Connection, PublicKey } from '@solana/web3.js';
|
|
||||||
import { env } from '../config/env';
|
|
||||||
import { getRedis } from '../config/redis';
|
|
||||||
import { logger } from './logger';
|
|
||||||
|
|
||||||
/** Last-resort fallback — Yurbason (0% commission, давний, проверен активным). */
|
|
||||||
const FALLBACK_VOTE = '9gANMngbGUmAaLXL1RC3JdiaLjRowJXNbzCTh53ht7mq';
|
|
||||||
const CACHE_KEY = 'sol:validator:default';
|
|
||||||
const CACHE_TTL_SEC = 24 * 60 * 60; // 24h
|
|
||||||
const STAKEWIZ_URL = 'https://api.stakewiz.com/validators';
|
|
||||||
const HTTP_TIMEOUT_MS = 6000;
|
|
||||||
|
|
||||||
let _conn: Connection | null = null;
|
|
||||||
function defaultConn(): Connection {
|
|
||||||
if (!_conn) _conn = new Connection('https://api.mainnet-beta.solana.com', 'confirmed');
|
|
||||||
return _conn;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidVote(addr: unknown): addr is string {
|
|
||||||
if (typeof addr !== 'string' || !addr) return false;
|
|
||||||
try { new PublicKey(addr); return true; } catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Резолвит vote-адрес валидатора. Никогда не throws — деградирует до hard-coded fallback.
|
|
||||||
*/
|
|
||||||
export async function resolveStakeValidator(conn?: Connection): Promise<string> {
|
|
||||||
// 1) env override
|
|
||||||
if (isValidVote(env.defi.solStakeValidatorVote)) {
|
|
||||||
return env.defi.solStakeValidatorVote;
|
|
||||||
}
|
|
||||||
|
|
||||||
// cache
|
|
||||||
try {
|
|
||||||
const cached = await getRedis().get(CACHE_KEY);
|
|
||||||
if (cached && isValidVote(cached)) return cached;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
let chosen: string | null = null;
|
|
||||||
|
|
||||||
// 2) Stakewiz (внешний, untrusted) — выбор кросс-проверяется против on-chain перед доверием
|
|
||||||
chosen = await fromStakewiz().catch(() => null);
|
|
||||||
if (chosen) {
|
|
||||||
const ok = await verifyOnChain(conn ?? defaultConn(), chosen).catch(() => false);
|
|
||||||
if (!ok) {
|
|
||||||
logger.warn(`SOL validator: Stakewiz pick ${chosen} failed on-chain verification — falling back to RPC`);
|
|
||||||
chosen = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) RPC fallback (on-chain authoritative)
|
|
||||||
if (!chosen) {
|
|
||||||
chosen = await fromRpc(conn ?? defaultConn()).catch(() => null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) last-resort
|
|
||||||
if (!chosen || !isValidVote(chosen)) {
|
|
||||||
logger.warn('SOL validator auto-select failed (Stakewiz + RPC) — using hard-coded fallback');
|
|
||||||
chosen = FALLBACK_VOTE;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await getRedis().set(CACHE_KEY, chosen, 'EX', CACHE_TTL_SEC);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
return chosen;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fromStakewiz(): Promise<string | null> {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
|
||||||
try {
|
|
||||||
const res = await fetch(STAKEWIZ_URL, { headers: { Accept: 'application/json' }, signal: controller.signal });
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const list = (await res.json()) as any[];
|
|
||||||
if (!Array.isArray(list)) return null;
|
|
||||||
const candidates = list
|
|
||||||
.filter((v) => v && v.delinquent === false)
|
|
||||||
.filter((v) => Number(v.commission) <= 2)
|
|
||||||
.filter((v) => Number(v.skip_rate) < 1)
|
|
||||||
.filter((v) => Number(v.uptime) >= 99.8)
|
|
||||||
.filter((v) => isValidVote(v.vote_identity))
|
|
||||||
.sort((a, b) => Number(b.wiz_score) - Number(a.wiz_score));
|
|
||||||
return candidates.length ? String(candidates[0].vote_identity) : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Кросс-проверка: vote-адрес (напр. от Stakewiz) ДОЛЖЕН существовать on-chain в current[],
|
|
||||||
* не быть delinquent, и иметь commission ≤ 10%. Защита от спуфнутого/устаревшего внешнего ответа.
|
|
||||||
*/
|
|
||||||
async function verifyOnChain(conn: Connection, vote: string): Promise<boolean> {
|
|
||||||
const accounts = await conn.getVoteAccounts();
|
|
||||||
if (accounts.delinquent.some((v: any) => v.votePubkey === vote)) return false;
|
|
||||||
const cur = accounts.current.find((v: any) => v.votePubkey === vote);
|
|
||||||
return !!cur && Number(cur.commission) <= 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fromRpc(conn: Connection): Promise<string | null> {
|
|
||||||
const accounts = await conn.getVoteAccounts();
|
|
||||||
const delinquent = new Set(accounts.delinquent.map((v: any) => v.votePubkey));
|
|
||||||
const candidates = accounts.current
|
|
||||||
.filter((v: any) => v.epochVoteAccount === true)
|
|
||||||
.filter((v: any) => Number(v.commission) <= 5)
|
|
||||||
.filter((v: any) => !delinquent.has(v.votePubkey))
|
|
||||||
.filter((v: any) => Number(v.activatedStake) > 100_000_000)
|
|
||||||
.map((v: any) => {
|
|
||||||
const ec = Array.isArray(v.epochCredits) ? v.epochCredits : [];
|
|
||||||
const last = ec[ec.length - 1];
|
|
||||||
const prev = ec[ec.length - 2];
|
|
||||||
const creditGrowth = last && prev ? Number(last[1]) - Number(prev[1]) : 0;
|
|
||||||
return { votePubkey: v.votePubkey as string, commission: Number(v.commission), creditGrowth };
|
|
||||||
})
|
|
||||||
.sort((a, b) => (a.commission !== b.commission ? a.commission - b.commission : b.creditGrowth - a.creditGrowth));
|
|
||||||
return candidates.length && isValidVote(candidates[0].votePubkey) ? candidates[0].votePubkey : null;
|
|
||||||
}
|
|
||||||
@@ -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,314 +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/SOL/TRX) ИЛИ символ нативной монеты.
|
|
||||||
// На BSC нативка = BNB (символ ≠ chain code 'BSC') — без этой ветки цена BNB не резолвилась
|
|
||||||
// (→ сетевая/0.7% комиссия в BNB приходила без USD). NATIVE_SYMBOLS[BSC]='BNB'.
|
|
||||||
if (upper === chain || upper === NATIVE_SYMBOLS[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,160 +0,0 @@
|
|||||||
/**
|
|
||||||
* Uniswap v3 pool stats — TVL / 24h volume / 24h fees / APR.
|
|
||||||
*
|
|
||||||
* Источник: GeckoTerminal API (бесплатный, БЕЗ ключа — та же on-chain data network что у Uniswap).
|
|
||||||
* Раньше использовался The Graph gateway, но он требует платный THEGRAPH_API_KEY (hosted service
|
|
||||||
* закрыт) → без ключа возвращал null. GeckoTerminal не требует авторизации.
|
|
||||||
*
|
|
||||||
* fees24h выводится из volume24h × feeTier (не полагаемся на отдельное поле GT).
|
|
||||||
* APR = fees24h / TVL × 365 × 100.
|
|
||||||
*
|
|
||||||
* Fallback (если GeckoTerminal недоступен): on-chain TVL = balanceOf(token0/token1) × USD-цена из
|
|
||||||
* price-oracle (CoinGecko). Объём/комиссии/APR в фолбэке остаются null (on-chain недоступны дёшево).
|
|
||||||
*
|
|
||||||
* Только чтение, НИКОГДА не влияет на суммы транзакций. KeyDB-кэш 5 мин. Всё graceful → null-поля.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ethers } from 'ethers';
|
|
||||||
import { getRedis } from '../config/redis';
|
|
||||||
import { logger } from './logger';
|
|
||||||
import { ETH_RPCS, UNIV3_POOL_ABI, ERC20_META_ABI } from './defi-contracts';
|
|
||||||
import { pickProxiedEvmProvider } from './outbound-proxy';
|
|
||||||
import { getPricesBySymbols } from '../services/price-oracle.service';
|
|
||||||
|
|
||||||
const GECKOTERMINAL_POOL_URL = (addr: string) =>
|
|
||||||
`https://api.geckoterminal.com/api/v2/networks/eth/pools/${addr}`;
|
|
||||||
const CACHE_TTL_SEC = 300;
|
|
||||||
const HTTP_TIMEOUT_MS = 8000;
|
|
||||||
|
|
||||||
export interface PoolStats {
|
|
||||||
poolAddress: string;
|
|
||||||
totalValueLockedUSD: number | null;
|
|
||||||
volumeUSD24h: number | null;
|
|
||||||
feesUSD24h: number | null;
|
|
||||||
aprPercent: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Статистика пула (с кэшем). null-поля если источники недоступны.
|
|
||||||
* @param feeTier uint24 fee пула (напр. 3000 = 0.3%) — нужен для расчёта fees/APR. Если не задан,
|
|
||||||
* fees/APR останутся null.
|
|
||||||
*/
|
|
||||||
export async function getPoolStats(poolAddress: string, feeTier?: number): Promise<PoolStats> {
|
|
||||||
const id = poolAddress.toLowerCase();
|
|
||||||
const cacheKey = `univ3:poolstats:${id}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cached = await getRedis().get(cacheKey);
|
|
||||||
if (cached) return JSON.parse(cached) as PoolStats;
|
|
||||||
} catch { /* ignore cache errors */ }
|
|
||||||
|
|
||||||
const empty: PoolStats = {
|
|
||||||
poolAddress: id,
|
|
||||||
totalValueLockedUSD: null,
|
|
||||||
volumeUSD24h: null,
|
|
||||||
feesUSD24h: null,
|
|
||||||
aprPercent: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
let stats = empty;
|
|
||||||
|
|
||||||
// ── Primary: GeckoTerminal ──
|
|
||||||
const gt = await fetchGeckoTerminal(id, feeTier);
|
|
||||||
if (gt) {
|
|
||||||
stats = gt;
|
|
||||||
} else {
|
|
||||||
// ── Fallback: on-chain TVL (balances × prices) ──
|
|
||||||
const onchainTvl = await fetchOnChainTvl(id).catch(() => null);
|
|
||||||
if (onchainTvl !== null) {
|
|
||||||
stats = { ...empty, totalValueLockedUSD: onchainTvl };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await getRedis().set(cacheKey, JSON.stringify(stats), 'EX', CACHE_TTL_SEC);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchGeckoTerminal(id: string, feeTier?: number): Promise<PoolStats | null> {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
|
||||||
try {
|
|
||||||
const res = await fetch(GECKOTERMINAL_POOL_URL(id), {
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`GeckoTerminal HTTP ${res.status} for pool ${id}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const j: any = await res.json();
|
|
||||||
const attr = j?.data?.attributes;
|
|
||||||
if (!attr) return null;
|
|
||||||
|
|
||||||
const tvl = num(attr.reserve_in_usd);
|
|
||||||
const vol24h = num(attr.volume_usd?.h24);
|
|
||||||
// fees = volume × feeRate. feeTier uint24: 3000 = 0.3% = 3000/1e6.
|
|
||||||
const fees24h = vol24h !== null && feeTier ? vol24h * (feeTier / 1_000_000) : null;
|
|
||||||
const apr = fees24h !== null && tvl && tvl > 0 ? (fees24h / tvl) * 365 * 100 : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
poolAddress: id,
|
|
||||||
totalValueLockedUSD: tvl,
|
|
||||||
volumeUSD24h: vol24h,
|
|
||||||
feesUSD24h: fees24h,
|
|
||||||
aprPercent: apr,
|
|
||||||
};
|
|
||||||
} catch (err: any) {
|
|
||||||
logger.warn(`GeckoTerminal fetch failed for ${id}: ${err?.message}`);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Best-effort on-chain TVL: balanceOf(token0)+balanceOf(token1) × USD-цена по символу. */
|
|
||||||
async function fetchOnChainTvl(poolAddr: string): Promise<number | null> {
|
|
||||||
const provider = await pickProxiedEvmProvider(ETH_RPCS, 1);
|
|
||||||
const pool = new ethers.Contract(poolAddr, UNIV3_POOL_ABI, provider);
|
|
||||||
const [token0, token1] = await Promise.all([
|
|
||||||
pool.token0() as Promise<string>,
|
|
||||||
pool.token1() as Promise<string>,
|
|
||||||
]);
|
|
||||||
const t0 = new ethers.Contract(token0, ERC20_META_ABI, provider);
|
|
||||||
const t1 = new ethers.Contract(token1, ERC20_META_ABI, provider);
|
|
||||||
const [dec0, dec1, sym0, sym1, bal0, bal1] = await Promise.all([
|
|
||||||
t0.decimals() as Promise<number>,
|
|
||||||
t1.decimals() as Promise<number>,
|
|
||||||
t0.symbol().catch(() => '') as Promise<string>,
|
|
||||||
t1.symbol().catch(() => '') as Promise<string>,
|
|
||||||
t0.balanceOf(poolAddr) as Promise<ethers.BigNumber>,
|
|
||||||
t1.balanceOf(poolAddr) as Promise<ethers.BigNumber>,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const prices = await getPricesBySymbols([
|
|
||||||
{ chain: 'ETH', symbol: normSym(sym0) },
|
|
||||||
{ chain: 'ETH', symbol: normSym(sym1) },
|
|
||||||
]);
|
|
||||||
const p0 = prices.get(`ETH:${normSym(sym0)}`);
|
|
||||||
const p1 = prices.get(`ETH:${normSym(sym1)}`);
|
|
||||||
if (p0 == null && p1 == null) return null;
|
|
||||||
|
|
||||||
const human = (bal: ethers.BigNumber, dec: number) => Number(ethers.utils.formatUnits(bal, dec));
|
|
||||||
let tvl = 0;
|
|
||||||
if (p0 != null) tvl += human(bal0, Number(dec0)) * p0;
|
|
||||||
if (p1 != null) tvl += human(bal1, Number(dec1)) * p1;
|
|
||||||
return Number.isFinite(tvl) && tvl > 0 ? tvl : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** WETH→ETH для резолва цены через token-registry (WETH часто не зарегистрирован отдельно). */
|
|
||||||
function normSym(sym: string): string {
|
|
||||||
const s = (sym || '').toUpperCase();
|
|
||||||
return s === 'WETH' ? 'ETH' : s;
|
|
||||||
}
|
|
||||||
|
|
||||||
function num(v: unknown): number | null {
|
|
||||||
const n = Number(v);
|
|
||||||
return Number.isFinite(n) ? n : null;
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
/**
|
|
||||||
* Uniswap v3 concentrated-liquidity математика на BigInt (без внешнего SDK).
|
|
||||||
*
|
|
||||||
* - getSqrtRatioAtTick — каноничный TickMath (точный, magic constants как в v3-core).
|
|
||||||
* - priceToTick — человеческая цена (token1/token0) → tick (приблизительно, затем align).
|
|
||||||
* - getLiquidityForAmounts / getAmountsForLiquidity — каноничный LiquidityAmounts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const Q96 = 2n ** 96n;
|
|
||||||
export const MIN_TICK = -887272;
|
|
||||||
export const MAX_TICK = 887272;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TickMath.getSqrtRatioAtTick — sqrtPriceX96 = floor(sqrt(1.0001^tick) * 2^96).
|
|
||||||
* Точная битовая реализация (как в Uniswap v3-core).
|
|
||||||
*/
|
|
||||||
export function getSqrtRatioAtTick(tick: number): bigint {
|
|
||||||
if (tick < MIN_TICK || tick > MAX_TICK) throw new Error(`tick ${tick} out of range`);
|
|
||||||
const absTick = BigInt(tick < 0 ? -tick : tick);
|
|
||||||
|
|
||||||
let ratio = (absTick & 0x1n) !== 0n
|
|
||||||
? 0xfffcb933bd6fad37aa2d162d1a594001n
|
|
||||||
: 0x100000000000000000000000000000000n;
|
|
||||||
const m = (c: bigint) => { ratio = (ratio * c) >> 128n; };
|
|
||||||
if ((absTick & 0x2n) !== 0n) m(0xfff97272373d413259a46990580e213an);
|
|
||||||
if ((absTick & 0x4n) !== 0n) m(0xfff2e50f5f656932ef12357cf3c7fdccn);
|
|
||||||
if ((absTick & 0x8n) !== 0n) m(0xffe5caca7e10e4e61c3624eaa0941cd0n);
|
|
||||||
if ((absTick & 0x10n) !== 0n) m(0xffcb9843d60f6159c9db58835c926644n);
|
|
||||||
if ((absTick & 0x20n) !== 0n) m(0xff973b41fa98c081472e6896dfb254c0n);
|
|
||||||
if ((absTick & 0x40n) !== 0n) m(0xff2ea16466c96a3843ec78b326b52861n);
|
|
||||||
if ((absTick & 0x80n) !== 0n) m(0xfe5dee046a99a2a811c461f1969c3053n);
|
|
||||||
if ((absTick & 0x100n) !== 0n) m(0xfcbe86c7900a88aedcffc83b479aa3a4n);
|
|
||||||
if ((absTick & 0x200n) !== 0n) m(0xf987a7253ac413176f2b074cf7815e54n);
|
|
||||||
if ((absTick & 0x400n) !== 0n) m(0xf3392b0822b70005940c7a398e4b70f3n);
|
|
||||||
if ((absTick & 0x800n) !== 0n) m(0xe7159475a2c29b7443b29c7fa6e889d9n);
|
|
||||||
if ((absTick & 0x1000n) !== 0n) m(0xd097f3bdfd2022b8845ad8f792aa5825n);
|
|
||||||
if ((absTick & 0x2000n) !== 0n) m(0xa9f746462d870fdf8a65dc1f90e061e5n);
|
|
||||||
if ((absTick & 0x4000n) !== 0n) m(0x70d869a156d2a1b890bb3df62baf32f7n);
|
|
||||||
if ((absTick & 0x8000n) !== 0n) m(0x31be135f97d08fd981231505542fcfa6n);
|
|
||||||
if ((absTick & 0x10000n) !== 0n) m(0x9aa508b5b7a84e1c677de54f3e99bc9n);
|
|
||||||
if ((absTick & 0x20000n) !== 0n) m(0x5d6af8dedb81196699c329225ee604n);
|
|
||||||
if ((absTick & 0x40000n) !== 0n) m(0x2216e584f5fa1ea926041bedfe98n);
|
|
||||||
if ((absTick & 0x80000n) !== 0n) m(0x48a170391f7dc42444e8fa2n);
|
|
||||||
|
|
||||||
if (tick > 0) ratio = ((2n ** 256n) - 1n) / ratio;
|
|
||||||
|
|
||||||
// ratio is Q128.128 → sqrtPriceX96 (Q96): shift right 32, round up
|
|
||||||
const shifted = ratio >> 32n;
|
|
||||||
return (ratio & ((1n << 32n) - 1n)) === 0n ? shifted : shifted + 1n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Человеческая цена token1/token0 → tick (приблизительно). Затем выравнивай alignTick. */
|
|
||||||
export function priceToTick(humanPrice: number, dec0: number, dec1: number): number {
|
|
||||||
if (!(humanPrice > 0)) throw new Error('price must be > 0');
|
|
||||||
const priceRaw = humanPrice * Math.pow(10, dec1 - dec0);
|
|
||||||
const tick = Math.round(Math.log(priceRaw) / Math.log(1.0001));
|
|
||||||
return Math.min(Math.max(tick, MIN_TICK), MAX_TICK);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tick → человеческая цена token1/token0 (для отображения). */
|
|
||||||
export function tickToPrice(tick: number, dec0: number, dec1: number): number {
|
|
||||||
const priceRaw = Math.pow(1.0001, tick);
|
|
||||||
return priceRaw * Math.pow(10, dec0 - dec1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** sqrtPriceX96 → человеческая цена token1/token0. */
|
|
||||||
export function sqrtPriceX96ToPrice(sqrtPriceX96: bigint, dec0: number, dec1: number): number {
|
|
||||||
const ratio = Number(sqrtPriceX96) / Number(Q96);
|
|
||||||
const priceRaw = ratio * ratio;
|
|
||||||
return priceRaw * Math.pow(10, dec0 - dec1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function alignTick(tick: number, spacing: number): number {
|
|
||||||
const aligned = Math.round(tick / spacing) * spacing;
|
|
||||||
return Math.min(Math.max(aligned, MIN_TICK), MAX_TICK);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── LiquidityAmounts ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function sortSqrt(a: bigint, b: bigint): [bigint, bigint] {
|
|
||||||
return a > b ? [b, a] : [a, b];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLiquidityForAmount0(sqrtA: bigint, sqrtB: bigint, amount0: bigint): bigint {
|
|
||||||
[sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB);
|
|
||||||
const intermediate = (sqrtA * sqrtB) / Q96;
|
|
||||||
return (amount0 * intermediate) / (sqrtB - sqrtA);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLiquidityForAmount1(sqrtA: bigint, sqrtB: bigint, amount1: bigint): bigint {
|
|
||||||
[sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB);
|
|
||||||
return (amount1 * Q96) / (sqrtB - sqrtA);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLiquidityForAmounts(
|
|
||||||
sqrtP: bigint, sqrtA: bigint, sqrtB: bigint, amount0: bigint, amount1: bigint,
|
|
||||||
): bigint {
|
|
||||||
[sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB);
|
|
||||||
if (sqrtP <= sqrtA) return getLiquidityForAmount0(sqrtA, sqrtB, amount0);
|
|
||||||
if (sqrtP < sqrtB) {
|
|
||||||
const l0 = getLiquidityForAmount0(sqrtP, sqrtB, amount0);
|
|
||||||
const l1 = getLiquidityForAmount1(sqrtA, sqrtP, amount1);
|
|
||||||
return l0 < l1 ? l0 : l1;
|
|
||||||
}
|
|
||||||
return getLiquidityForAmount1(sqrtA, sqrtB, amount1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAmount0ForLiquidity(sqrtA: bigint, sqrtB: bigint, L: bigint): bigint {
|
|
||||||
[sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB);
|
|
||||||
if (sqrtA <= 0n) throw new Error('sqrt price must be positive'); // guard div-by-zero
|
|
||||||
return (((L << 96n) * (sqrtB - sqrtA)) / sqrtB) / sqrtA;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAmount1ForLiquidity(sqrtA: bigint, sqrtB: bigint, L: bigint): bigint {
|
|
||||||
[sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB);
|
|
||||||
return (L * (sqrtB - sqrtA)) / Q96;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAmountsForLiquidity(
|
|
||||||
sqrtP: bigint, sqrtA: bigint, sqrtB: bigint, L: bigint,
|
|
||||||
): { amount0: bigint; amount1: bigint } {
|
|
||||||
[sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB);
|
|
||||||
if (sqrtP <= sqrtA) {
|
|
||||||
return { amount0: getAmount0ForLiquidity(sqrtA, sqrtB, L), amount1: 0n };
|
|
||||||
}
|
|
||||||
if (sqrtP < sqrtB) {
|
|
||||||
return {
|
|
||||||
amount0: getAmount0ForLiquidity(sqrtP, sqrtB, L),
|
|
||||||
amount1: getAmount1ForLiquidity(sqrtA, sqrtP, L),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { amount0: 0n, amount1: getAmount1ForLiquidity(sqrtA, sqrtB, L) };
|
|
||||||
}
|
|
||||||
@@ -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);
|
req.auth = await verifyAccessToken(token);
|
||||||
next();
|
next();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Лог детали server-side, клиенту — единое generic сообщение.
|
|
||||||
// Иначе err.message distinguishes "expired" vs "bad signature" vs "kid unknown" → info oracle.
|
|
||||||
logger.warn(`Auth failed: ${err.message}`);
|
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 { Request, Response, NextFunction } from 'express';
|
||||||
import { timingSafeEqual } from 'crypto';
|
import { verifyCsrfPair } from '../services/csrf.service';
|
||||||
import { verifyCsrfToken, isCsrfConfigured, getCsrfConfigSummary } from '../services/csrf.service';
|
|
||||||
import { env } from '../config/env';
|
|
||||||
import { logger } from '../lib/logger';
|
|
||||||
|
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
|
|
||||||
/**
|
|
||||||
* CSRF middleware с double-submit pattern.
|
export async function csrfProtect(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
*
|
if (!UNSAFE.has(req.method)) {
|
||||||
* Требует ОБА 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)) {
|
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF полностью отключён через ENV (csrfPath пустой) — пропускаем явно.
|
const headerToken = req.get('X-CSRF-Token') ?? undefined;
|
||||||
if (!env.vault.csrfPath) {
|
const cookieToken = req.cookies?.csrf_token as string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await verifyCsrfPair(cookieToken, headerToken);
|
||||||
next();
|
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 { Request, Response, NextFunction } from 'express';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
interface HttpError extends Error {
|
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
|
||||||
status?: number;
|
logger.error(err.message);
|
||||||
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}`);
|
|
||||||
res.status(500).json({ success: false, error: 'Internal server error' });
|
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 { generateUlid } from '../utils/ulid';
|
||||||
import { traceStore } from '../lib/trace-store';
|
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 {
|
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;
|
const traceId = req.headers['x-trace-id'] as string
|
||||||
|
|| req.headers['x-request-id'] as string
|
||||||
// Validate client-supplied trace-ID — иначе log injection / trace forgery
|
|| generateUlid();
|
||||||
const traceId = (typeof supplied === 'string' && TRACE_ID_RE.test(supplied))
|
|
||||||
? supplied
|
|
||||||
: generateUlid();
|
|
||||||
|
|
||||||
res.setHeader('X-Trace-ID', traceId);
|
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 { db } from '../config/database';
|
||||||
|
import { generateUlid } from '../utils/ulid';
|
||||||
|
|
||||||
export interface UserRow {
|
export interface UserRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -9,168 +9,41 @@ export interface UserRow {
|
|||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
middle_name: string | null;
|
middle_name: string | null;
|
||||||
birth_date: string | null;
|
birth_date: string | null;
|
||||||
crypto_wallet: string | null; // DEPRECATED — оставлено для backward-compat. ETH = users.erc20.
|
crypto_wallet: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
|
bik: string | null;
|
||||||
|
account_number: string | null;
|
||||||
|
card_number: string | null;
|
||||||
inn: string | null;
|
inn: string | null;
|
||||||
kyc_verified: boolean;
|
kyc_verified: boolean;
|
||||||
kyc_verified_at: Date | null;
|
kyc_verified_at: Date | null;
|
||||||
is_deleted: boolean;
|
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;
|
created_at: Date;
|
||||||
updated_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 = {
|
export const UserModel = {
|
||||||
/**
|
async findByEmail(email: string): Promise<UserRow | undefined> {
|
||||||
* Public-safe lookup. НЕ возвращает encrypted_* / password_hash.
|
return db('users').where({ email, is_deleted: false }).first();
|
||||||
*/
|
|
||||||
async findById(id: string) {
|
|
||||||
return db('users')
|
|
||||||
.where({ id, is_deleted: false })
|
|
||||||
.select(...PUBLIC_USER_COLUMNS)
|
|
||||||
.first();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
async findById(id: string): Promise<UserRow | undefined> {
|
||||||
* Создать запись пользователя если её нет.
|
return db('users').where({ id, 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 create(data: {
|
||||||
* Set-once: возвращает 'claimed' / 'already_has' / 'no_user' (H32).
|
email: string;
|
||||||
* Defense-in-depth: distinguishes wrong outcomes так что caller отдаёт правильный код:
|
password_hash: string;
|
||||||
* - claimed → каждый параллельный call'у вернёт already_has потом (мы выиграли gонку)
|
}): Promise<UserRow> {
|
||||||
* - already_has → existing encrypted_mnemonic в DB
|
const [user] = await db('users').insert({ id: generateUlid(), ...data }).returning('*');
|
||||||
* - no_user → row not found OR is_deleted=true
|
return user;
|
||||||
*/
|
|
||||||
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 update(id: string, data: Partial<Omit<UserRow, 'id' | 'created_at'>>): Promise<UserRow | undefined> {
|
||||||
* Claim placeholder row перед derive — экономит CPU + heap-secret для loser race.
|
const [user] = await db('users')
|
||||||
* Используется как pre-step в createWallet flow (H27).
|
.where({ id })
|
||||||
*/
|
.update({ ...data, updated_at: db.fn.now() })
|
||||||
async claimWalletSlot(id: string, trx?: Knex.Transaction): Promise<'claimed' | 'already_has' | 'no_user'> {
|
.returning('*');
|
||||||
const k = trx || db;
|
return user;
|
||||||
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})`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { Knex } from 'knex';
|
|
||||||
import { db } from '../config/database';
|
import { db } from '../config/database';
|
||||||
import { generateUlid } from '../utils/ulid';
|
import { generateUlid } from '../utils/ulid';
|
||||||
|
|
||||||
@@ -16,21 +15,10 @@ export const WalletModel = {
|
|||||||
return db('wallets').where({ user_id: userId });
|
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(
|
async createMany(
|
||||||
wallets: { user_id: string; chain: string; address: string; derivation_path: string }[],
|
wallets: { user_id: string; chain: string; address: string; derivation_path: string }[]
|
||||||
trx?: Knex.Transaction,
|
|
||||||
): Promise<WalletRow[]> {
|
): Promise<WalletRow[]> {
|
||||||
const withIds = wallets.map((w) => ({ id: generateUlid(), ...w }));
|
const withIds = wallets.map((w) => ({ id: generateUlid(), ...w }));
|
||||||
return (trx || db)('wallets').insert(withIds).returning('*');
|
return db('wallets').insert(withIds).returning('*');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import { scanApprovals, revokeApproval, revokeAllApprovals } from '../controllers/approvals.controller';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// Mounted at /api/wallets — пути /:chain/approvals* не пересекаются с wallet/staking/lp routes.
|
|
||||||
// IMPORTANT: более специфичные /:chain/approvals/revoke* раньше /:chain/approvals.
|
|
||||||
router.post('/:chain/approvals/revoke-all', revokeAllApprovals);
|
|
||||||
router.post('/:chain/approvals/revoke', revokeApproval);
|
|
||||||
router.get('/:chain/approvals', scanApprovals);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -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) {
|
async function broadcastTx(req: Request, res: Response) {
|
||||||
const { hex } = req.body;
|
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)) {
|
||||||
if (!hex || typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex) || hex.length > 200_000) {
|
|
||||||
res.status(400).json({ success: false, error: 'Invalid transaction hex' });
|
res.status(400).json({ success: false, error: 'Invalid transaction hex' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -131,8 +130,7 @@ async function broadcastTx(req: Request, res: Response) {
|
|||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Don't leak Blockstream error body (could contain UTXO state oracle).
|
res.status(response.status).json({ success: false, error: text || 'Broadcast failed' });
|
||||||
res.status(502).json({ success: false, error: 'BTC broadcast failed' });
|
|
||||||
return;
|
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,503 +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';
|
|
||||||
import { computeAppFee } from '../lib/app-fee';
|
|
||||||
import { formatSmallestUnits } from '../lib/amount-units';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
const LIFI_API_URL = 'https://li.quest/v1';
|
|
||||||
const LIFI_TIMEOUT_MS = 20_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Вычисляет нашу app-комиссию 0.7% (в токене + USDT) из LiFi-ответа (action.fromToken + fromAmount,
|
|
||||||
* estimate.fromAmountUSD). Чисто информативно (показать в quote) — сбор не трогаем (0.7% берётся
|
|
||||||
* атомарно в /bridge/execute), поэтому LiFi fee-параметры НЕ инжектим (иначе двойное списание).
|
|
||||||
*/
|
|
||||||
function computeJumperAppCommission(body: any): {
|
|
||||||
ratePercent: number;
|
|
||||||
inToken: { symbol: string | null; amount: string | null };
|
|
||||||
usd: number | null;
|
|
||||||
} | null {
|
|
||||||
const act = body?.action;
|
|
||||||
const est = body?.estimate;
|
|
||||||
const fromToken = act?.fromToken;
|
|
||||||
const fromAmount = act?.fromAmount ?? est?.fromAmount;
|
|
||||||
if (!fromToken || fromAmount == null) return null;
|
|
||||||
let amountToken: string | null = null;
|
|
||||||
try {
|
|
||||||
const dec = Number(fromToken.decimals ?? 0);
|
|
||||||
const feeSmallest = computeAppFee(String(fromAmount)); // fromAmount × 0.7%
|
|
||||||
amountToken = formatSmallestUnits(feeSmallest.toString(), dec);
|
|
||||||
} catch { amountToken = null; }
|
|
||||||
const fromUsd = Number(est?.fromAmountUSD);
|
|
||||||
const usd = Number.isFinite(fromUsd) ? Math.round(fromUsd * 0.007 * 1e6) / 1e6 : null;
|
|
||||||
return {
|
|
||||||
ratePercent: 0.7,
|
|
||||||
inToken: { symbol: fromToken.symbol ?? null, amount: amountToken },
|
|
||||||
usd,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
// /quote — доинжектим вычисляемую appCommission (наша 0.7% в токене + USDT) для видимости.
|
|
||||||
if (jumperPath === '/quote') {
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(text);
|
|
||||||
obj.appCommission = computeJumperAppCommission(obj);
|
|
||||||
res.send(JSON.stringify(obj));
|
|
||||||
} catch {
|
|
||||||
res.send(text); // не JSON — passthrough
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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}`);
|
|
||||||
// reason = код/имя сетевой ошибки (ECONNREFUSED/ENOTFOUND/UND_ERR_*) без хоста/IP/кредов прокси.
|
|
||||||
const reason = error?.cause?.code ?? error?.code ?? error?.name ?? 'unknown';
|
|
||||||
res.status(502).json({ success: false, error: 'Jumper proxy error', reason });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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', appCommission: computeJumperAppCommission(nearRes.body) });
|
|
||||||
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', appCommission: computeJumperAppCommission(bestRes.body) });
|
|
||||||
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}`);
|
|
||||||
// reason = код/имя сетевой ошибки (ECONNREFUSED/ENOTFOUND/UND_ERR_*) без хоста/IP/кредов прокси.
|
|
||||||
const reason = error?.cause?.code ?? error?.code ?? error?.name ?? 'unknown';
|
|
||||||
res.status(502).json({ success: false, error: 'Quote-best failed', reason });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import { lpPools, lpQuote, lpAdd, lpRemove, lpPositions } from '../controllers/lp.controller';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// Mounted at /api/wallets — пути /:chain/lp/* не пересекаются с wallet/staking routes.
|
|
||||||
router.get('/:chain/lp/pools', lpPools);
|
|
||||||
router.post('/:chain/lp/quote', lpQuote);
|
|
||||||
router.post('/:chain/lp/add', lpAdd);
|
|
||||||
router.post('/:chain/lp/remove', lpRemove);
|
|
||||||
router.get('/:chain/lp/positions', lpPositions);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -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,63 +1,9 @@
|
|||||||
import { NextFunction, Request, Response, Router } from 'express';
|
import { NextFunction, Request, Response, Router } from 'express';
|
||||||
import { env } from '../config/env';
|
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, formatSmallestUnits } from '../lib/amount-units';
|
|
||||||
import { computeAppFee } from '../lib/app-fee';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const RELAY_API_URL = 'https://api.relay.link';
|
const RELAY_API_URL = 'https://api.relay.link';
|
||||||
const RELAY_TIMEOUT_MS = 20_000;
|
const ALLOWED_PATHS = new Set(['/quote/v2', '/intents/status/v3']);
|
||||||
|
|
||||||
/**
|
|
||||||
* Вычисляет нашу app-комиссию 0.7% (в токене + USDT) из Relay-ответа `details.currencyIn`.
|
|
||||||
* Чисто информативно (показать в quote/cost-estimate) — сбор комиссии не трогаем (отдельная /app-fee tx),
|
|
||||||
* поэтому НЕ инжектим Relay appFees (иначе было бы двойное списание).
|
|
||||||
*/
|
|
||||||
function computeRelayAppCommission(full: any): {
|
|
||||||
ratePercent: number;
|
|
||||||
inToken: { symbol: string | null; amount: string | null };
|
|
||||||
usd: number | null;
|
|
||||||
} | null {
|
|
||||||
const cIn = full?.details?.currencyIn;
|
|
||||||
if (!cIn || cIn.amount == null) return null;
|
|
||||||
let amountToken: string | null = null;
|
|
||||||
try {
|
|
||||||
const dec = Number(cIn.currency?.decimals ?? 0);
|
|
||||||
const feeSmallest = computeAppFee(String(cIn.amount)); // amount × 0.7%
|
|
||||||
amountToken = formatSmallestUnits(feeSmallest.toString(), dec);
|
|
||||||
} catch { amountToken = null; }
|
|
||||||
const inUsd = Number(cIn.amountUsd);
|
|
||||||
const usd = Number.isFinite(inUsd) ? Math.round(inUsd * 0.007 * 1e6) / 1e6 : null;
|
|
||||||
return {
|
|
||||||
ratePercent: 0.7,
|
|
||||||
inToken: { symbol: cIn.currency?.symbol ?? null, amount: amountToken },
|
|
||||||
usd,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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',
|
|
||||||
// добавлять по мере необходимости
|
|
||||||
]);
|
|
||||||
|
|
||||||
router.use(proxyRelayRequest);
|
router.use(proxyRelayRequest);
|
||||||
|
|
||||||
@@ -66,148 +12,25 @@ export default router;
|
|||||||
async function proxyRelayRequest(req: Request, res: Response, next: NextFunction) {
|
async function proxyRelayRequest(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const relayPath = req.path;
|
const relayPath = req.path;
|
||||||
|
if (!relayPath.startsWith('/execute/') && !ALLOWED_PATHS.has(relayPath)) {
|
||||||
// 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) {
|
|
||||||
res.status(404).json({ success: false, error: 'Relay endpoint not allowed' });
|
res.status(404).json({ success: false, error: 'Relay endpoint not allowed' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect local-only /cost-estimate endpoint — internally forwarded к Relay /quote,
|
const relayUrl = new URL(`${RELAY_API_URL}${relayPath}`);
|
||||||
// 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}`);
|
|
||||||
|
|
||||||
Object.entries(req.query).forEach(([key, value]) => {
|
Object.entries(req.query).forEach(([key, value]) => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
value.forEach((item) => relayUrl.searchParams.append(key, String(item)));
|
value.forEach((item) => relayUrl.searchParams.append(key, String(item)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value !== 'undefined') {
|
if (typeof value !== 'undefined') {
|
||||||
relayUrl.searchParams.set(key, String(value));
|
relayUrl.searchParams.set(key, String(value));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Explicit timeout via AbortController — иначе hang upstream'а держит request indefinitely.
|
const response = await fetch(relayUrl.toString(), {
|
||||||
const controller = new AbortController();
|
|
||||||
const t = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS);
|
|
||||||
|
|
||||||
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,
|
method: req.method,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
@@ -215,113 +38,15 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
|
|||||||
...(env.relayApiKey ? { Authorization: `Bearer ${env.relayApiKey}` } : {}),
|
...(env.relayApiKey ? { Authorization: `Bearer ${env.relayApiKey}` } : {}),
|
||||||
},
|
},
|
||||||
body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}),
|
body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}),
|
||||||
signal: controller.signal,
|
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
clearTimeout(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force JSON content-type — иначе compromised upstream может вернуть text/html
|
const contentType = response.headers.get('content-type') ?? 'application/json';
|
||||||
// → reflected XSS если frontend рендерит ответ напрямую.
|
const payload = await response.text();
|
||||||
res.status(upstream.status);
|
|
||||||
res.type('application/json');
|
|
||||||
|
|
||||||
const text = await upstream.text();
|
res.status(response.status);
|
||||||
if (!upstream.ok) {
|
res.type(contentType);
|
||||||
logger.warn(`Relay upstream ${upstream.status}: ${text.slice(0, 200)}`);
|
res.send(payload);
|
||||||
// Пробрасываем Relay error JSON клиенту — он сам пишет structured payload
|
} catch (error) {
|
||||||
// {message, errorCode, requestId, ...}. Content-Type уже forced на JSON выше,
|
next(error);
|
||||||
// так что 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 },
|
|
||||||
},
|
|
||||||
// Наша app-комиссия 0.7% в токене + USDT (информативно; собирается отдельной /app-fee tx).
|
|
||||||
appCommission: computeRelayAppCommission(full),
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// /quote — доинжектим вычисляемую appCommission (наша 0.7% в токене + USDT) для видимости в Swagger.
|
|
||||||
// Остальное (steps[], fees, details) проходит без изменений. Сбор комиссии не меняется.
|
|
||||||
if (relayPath === '/quote') {
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(text);
|
|
||||||
obj.appCommission = computeRelayAppCommission(obj);
|
|
||||||
res.send(JSON.stringify(obj));
|
|
||||||
} catch {
|
|
||||||
res.send(text); // не JSON — passthrough как есть
|
|
||||||
}
|
|
||||||
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}`);
|
|
||||||
// reason = код/имя сетевой ошибки (ECONNREFUSED/ENOTFOUND/UND_ERR_*) без хоста/IP/кредов прокси.
|
|
||||||
const reason = error?.cause?.code ?? error?.code ?? error?.name ?? 'unknown';
|
|
||||||
res.status(502).json({ success: false, error: 'Relay proxy error', reason });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,20 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import {
|
|
||||||
stakeQuote,
|
|
||||||
stakeExecute,
|
|
||||||
stakePositions,
|
|
||||||
unstakeExecute,
|
|
||||||
unstakeClaim,
|
|
||||||
} from '../controllers/staking.controller';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// Mounted at /api/wallets — paths не пересекаются с wallet.routes (нет /:chain/stake там).
|
|
||||||
// IMPORTANT: более специфичные пути раньше: /:chain/stake/quote ПЕРЕД /:chain/stake.
|
|
||||||
router.post('/:chain/stake/quote', stakeQuote);
|
|
||||||
router.post('/:chain/stake', stakeExecute);
|
|
||||||
router.get('/:chain/stake/positions', stakePositions);
|
|
||||||
router.post('/:chain/unstake/claim', unstakeClaim);
|
|
||||||
router.post('/:chain/unstake', unstakeExecute);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -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
|
* POST /api/tron/triggersmartcontract
|
||||||
* Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction.
|
* Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction
|
||||||
* Whitelisted contracts + function selectors only.
|
|
||||||
*/
|
*/
|
||||||
// Максимальный 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) {
|
async function triggerSmartContract(req: Request, res: Response) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
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> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
@@ -281,22 +210,11 @@ async function triggerSmartContract(req: Request, res: Response) {
|
|||||||
headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
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`, {
|
const response = await fetch(`${TRONGRID_BASE}/wallet/triggersmartcontract`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
body: JSON.stringify(forwardBody),
|
body: JSON.stringify(req.body),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
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,29 +3,6 @@ import { WalletController } from '../controllers/wallet.controller';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/create', WalletController.createWallet);
|
|
||||||
router.get('/', WalletController.getWallets);
|
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);
|
|
||||||
// Read-only «стоимость операции» (наша 0.7% в native+USD + проверка баланса токена/газа) для свапа/бриджа.
|
|
||||||
router.post('/:chain/op-cost', WalletController.opCost);
|
|
||||||
router.post('/:chain/app-fee', WalletController.appFeeTransfer);
|
|
||||||
router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx);
|
|
||||||
|
|
||||||
export default router;
|
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 { fetchVaultKV2 } from '../config/vault';
|
||||||
import { env } from '../config/env';
|
import { env, getVaultToken } from '../config/env';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
/**
|
export const CSRF_COOKIE_NAME = 'csrf_token';
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const ITSDANGEROUS_EPOCH = 1293840000;
|
export const CSRF_HEADER_NAME = 'X-CSRF-Token';
|
||||||
|
|
||||||
const DEFAULT_SALT = 'itsdangerous.Signer';
|
const TTL_SECONDS = 3600;
|
||||||
const LEGACY_VERIFY_SALTS = [
|
|
||||||
'csrf-salt',
|
|
||||||
'csrf',
|
|
||||||
'csrf-token',
|
|
||||||
'wtf',
|
|
||||||
'wtf-csrf',
|
|
||||||
'itsdangerous.Signer',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** Порядок: сначала то, что реально ставит itsdangerous 2.x / Flask-WTF. */
|
let signingKeyBytes: Uint8Array | null = null;
|
||||||
const KEY_DERIVATIONS = [
|
|
||||||
'django-concat',
|
|
||||||
'legacy-hmac-signer',
|
|
||||||
'hmac',
|
|
||||||
'concat',
|
|
||||||
'none',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type CsrfKeyDerivation = (typeof KEY_DERIVATIONS)[number];
|
|
||||||
|
|
||||||
export interface CsrfConfig {
|
export function setCsrfSigningKey(secret: string): void {
|
||||||
secret: string;
|
if (secret.length < 32) {
|
||||||
salt: string;
|
throw new Error('CSRF secret must be at least 32 characters');
|
||||||
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)',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
signingKeyBytes = new Uint8Array(createHash('sha256').update(secret, 'utf8').digest());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCsrfConfig(
|
|
||||||
addr: string,
|
export function hasCsrfSigningKey(): boolean {
|
||||||
token: string,
|
return signingKeyBytes !== null;
|
||||||
mount: string,
|
}
|
||||||
path: string,
|
|
||||||
): Promise<CsrfConfig> {
|
|
||||||
const secrets = await fetchVaultKV2(addr, token, mount, path);
|
function getKey(): Uint8Array {
|
||||||
if (!secrets) {
|
if (!signingKeyBytes) {
|
||||||
throw new Error('Failed to load CSRF secret from Vault');
|
throw new Error('CSRF signing key not configured');
|
||||||
}
|
}
|
||||||
|
return signingKeyBytes;
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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). */
|
export async function issueCsrfToken(): Promise<string> {
|
||||||
function deriveKeyDjangoConcat(secret: string, salt: string, digest: string): Buffer {
|
const nonce = randomBytes(24).toString('base64url');
|
||||||
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 {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (issuedAt > now + TIMESTAMP_SKEW_SEC) return 'future';
|
return new SignJWT({ scope: 'csrf', nce: nonce })
|
||||||
if (now - issuedAt > maxAgeSec) return 'expired';
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
return 'ok';
|
.setIssuedAt(now)
|
||||||
|
.setNotBefore(now)
|
||||||
|
.setExpirationTime(now + TTL_SECONDS)
|
||||||
|
.sign(getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* itsdangerous 2.x (prod auth): unix в payload.
|
export async function verifyCsrfPair(
|
||||||
* Старый URLSafeTimedSerializer / test-jwt-signer: raw + ITSDANGEROUS_EPOCH.
|
cookieToken: string | undefined,
|
||||||
*/
|
headerToken: string | undefined,
|
||||||
function verifyCsrfTimestamp(
|
): Promise<void> {
|
||||||
tsStr: string,
|
if (!cookieToken || !headerToken) {
|
||||||
maxAgeSec: number,
|
const e = new Error('CSRF token missing') as Error & { status: number };
|
||||||
): { ok: true; mode: 'unix' | 'legacy-epoch' } | { ok: false; reason: string } {
|
e.status = 403;
|
||||||
let raw: number;
|
throw e;
|
||||||
|
}
|
||||||
|
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 {
|
try {
|
||||||
raw = decodeTimestampRaw(tsStr);
|
const { payload } = await jwtVerify(cookieToken, getKey(), {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
clockTolerance: 10,
|
||||||
|
});
|
||||||
|
if (payload.scope !== 'csrf') {
|
||||||
|
throw new Error('invalid');
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, reason: 'Invalid timestamp' };
|
const e = new Error('CSRF token invalid') 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 };
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
|
||||||
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
|
||||||
|
|
||||||
const vaultSalt = current.salt;
|
export function getCsrfCookieMaxAgeMs(): number {
|
||||||
const vaultDigest = current.digest as CsrfDigest;
|
return TTL_SECONDS * 1000;
|
||||||
const salts = saltsToTry(vaultSalt);
|
}
|
||||||
const sigProbe: CsrfVerifyResult = { valid: false, actualSigLen: inferSigLenFromToken(token) };
|
|
||||||
const derivations = derivationsToTry(sigProbe);
|
|
||||||
|
export async function loadCsrfSecretFromVault(): Promise<boolean> {
|
||||||
let lastMismatch: CsrfVerifyResult = { valid: false, reason: 'Signature mismatch' };
|
const token = getVaultToken();
|
||||||
|
if (!token || !env.vault.addr) {
|
||||||
for (const keyDerivation of derivations) {
|
return false;
|
||||||
for (const salt of salts) {
|
}
|
||||||
for (const digest of digestsToTry(sigProbe.actualSigLen !== undefined ? sigProbe : lastMismatch, vaultDigest)) {
|
const data = await fetchVaultKV2(env.vault.addr, token, env.vault.mount, env.vault.csrfSecretPath);
|
||||||
const attempt = verifyCsrfTokenWithParams(
|
const key = data?.CSRF_SECRET_KEY;
|
||||||
current,
|
if (!key || key.length < 32) {
|
||||||
salt,
|
logger.warn('Vault CSRF secret missing or too short');
|
||||||
digest,
|
return false;
|
||||||
keyDerivation,
|
}
|
||||||
token,
|
setCsrfSigningKey(key);
|
||||||
);
|
logger.info('CSRF signing key loaded from Vault');
|
||||||
if (attempt.valid) {
|
return true;
|
||||||
const isPrimary =
|
}
|
||||||
keyDerivation === 'django-concat' &&
|
|
||||||
salt === vaultSalt &&
|
|
||||||
digest === vaultDigest;
|
export function finalizeCsrfConfigFromEnv(): void {
|
||||||
if (!isPrimary) {
|
if (hasCsrfSigningKey()) {
|
||||||
logger.warn(
|
return;
|
||||||
`CSRF verified with fallback key_derivation=${keyDerivation} digest=${digest} salt="${salt}" ` +
|
}
|
||||||
`(config digest=${vaultDigest} salt="${vaultSalt}"). Align auth with Vault metadata when possible.`,
|
const k = process.env.CSRF_SECRET_KEY;
|
||||||
);
|
if (k && k.length >= 32) {
|
||||||
}
|
setCsrfSigningKey(k);
|
||||||
return { valid: true };
|
logger.info('CSRF signing key loaded from environment');
|
||||||
}
|
return;
|
||||||
if (isRetryableVerifyFailure(attempt.reason)) {
|
}
|
||||||
lastMismatch = attempt;
|
logger.error('CSRF_SECRET_KEY is required (Vault path or env, min 32 characters)');
|
||||||
} else if (attempt.reason && attempt.reason !== 'Signature mismatch') {
|
process.exit(1);
|
||||||
return attempt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
reason: 'Signature mismatch (all digest/salt/key_derivation fallbacks failed)',
|
|
||||||
actualSigLen: lastMismatch.actualSigLen,
|
|
||||||
expectedSigLen: lastMismatch.expectedSigLen,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 * as jose from 'jose';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
import { fetchVaultKV2 } from '../config/vault';
|
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
export interface AccessTokenPayload {
|
export interface AccessTokenPayload {
|
||||||
@@ -20,41 +19,21 @@ export interface AuthContext {
|
|||||||
token: AccessTokenPayload;
|
token: AccessTokenPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
type KeyType = Awaited<ReturnType<typeof jose.importSPKI>>;
|
const keyMap = new Map<string, Awaited<ReturnType<typeof jose.importSPKI>>>();
|
||||||
|
|
||||||
// Whitelist надёжных асимметричных алгоритмов. Никогда не разрешаем 'none'/HS*
|
export async function loadJwtKeysFromVault(
|
||||||
// (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(
|
|
||||||
vaultAddr: string,
|
vaultAddr: string,
|
||||||
vaultToken: string,
|
vaultToken: string,
|
||||||
mount: string,
|
mount: string,
|
||||||
kidPath: string,
|
kidPath: string,
|
||||||
kidsPrefix: string,
|
kidsPrefix: string,
|
||||||
): Promise<Map<string, KeyType>> {
|
): Promise<void> {
|
||||||
|
const { fetchVaultKV2 } = await import('../config/vault');
|
||||||
|
|
||||||
const kidData = await fetchVaultKV2(vaultAddr, vaultToken, mount, kidPath);
|
const kidData = await fetchVaultKV2(vaultAddr, vaultToken, mount, kidPath);
|
||||||
if (!kidData) {
|
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[] = [];
|
const kids: string[] = [];
|
||||||
@@ -62,11 +41,10 @@ export async function fetchJwtKeysFromVault(
|
|||||||
if (kidData.previous && kidData.previous !== kidData.active) kids.push(kidData.previous);
|
if (kidData.previous && kidData.previous !== kidData.active) kids.push(kidData.previous);
|
||||||
|
|
||||||
if (kids.length === 0) {
|
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) {
|
for (const kid of kids) {
|
||||||
const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`);
|
const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`);
|
||||||
if (!kidSecret?.public_key) {
|
if (!kidSecret?.public_key) {
|
||||||
@@ -74,15 +52,16 @@ export async function fetchJwtKeysFromVault(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
|
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
|
||||||
next.set(kid, key);
|
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) {
|
logger.info(`JWT key store loaded: ${keyMap.size} key(s)`);
|
||||||
throw new Error('No public keys could be loaded from Vault');
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyAccessToken(token: string): Promise<AuthContext> {
|
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 header = jose.decodeProtectedHeader(token);
|
||||||
const kid = header.kid;
|
const kid = header.kid;
|
||||||
|
|
||||||
if (!kid || typeof kid !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(kid)) {
|
if (!kid) {
|
||||||
throw Object.assign(new Error('Missing or invalid kid in token header'), { status: 401 });
|
throw Object.assign(new Error('Missing kid in token header'), { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = keyMap.get(kid);
|
const key = keyMap.get(kid);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
logger.warn(`Unknown kid=${kid}`);
|
||||||
throw Object.assign(new Error('Unknown token kid'), { status: 401 });
|
throw Object.assign(new Error('Unknown token kid'), { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Двойная защита от algorithm confusion: проверяем точное совпадение
|
if (header.alg !== env.jwt.algorithm) {
|
||||||
if (header.alg !== env.jwt.algorithm || !ALLOWED_ALGORITHMS.has(String(header.alg))) {
|
|
||||||
throw Object.assign(new Error('Invalid token algorithm'), { status: 401 });
|
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 });
|
throw Object.assign(new Error('Invalid token type'), { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Строгая валидация sub/sid — иначе number/__proto__/10MB строки попадают в PG / в req.auth.
|
if (!payload.sub || !payload.sid) {
|
||||||
const SUB_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
throw Object.assign(new Error('Missing token claims'), { status: 401 });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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;
|
|
||||||
}
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
/**
|
|
||||||
* Native Solana staking — custodial (эквивалент Phantom).
|
|
||||||
*
|
|
||||||
* stake: SystemProgram.createAccount(stake) + StakeProgram.initialize + StakeProgram.delegate(validator)
|
|
||||||
* unstake: StakeProgram.deactivate → (cooldown 1-2 эпохи) → StakeProgram.withdraw
|
|
||||||
* positions: getParsedProgramAccounts(StakeProgram) по staker-authority = адрес юзера (без БД)
|
|
||||||
*
|
|
||||||
* Валидатор — авто-выбор (resolveStakeValidator: env override → Stakewiz → RPC → fallback). Stake-аккаунт —
|
|
||||||
* свеже-сгенерённый keypair (подписывает создание как extraSigner); его адрес возвращается и
|
|
||||||
* заново находится on-chain. App-fee 0.7% — нативный SOL-перевод в той же транзакции.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Connection,
|
|
||||||
PublicKey,
|
|
||||||
Keypair,
|
|
||||||
SystemProgram,
|
|
||||||
StakeProgram,
|
|
||||||
Authorized,
|
|
||||||
Lockup,
|
|
||||||
TransactionInstruction,
|
|
||||||
} from '@solana/web3.js';
|
|
||||||
import { getAppFeeWallet, computeAppFee } from '../lib/app-fee';
|
|
||||||
import { formatSmallestUnits } from '../lib/amount-units';
|
|
||||||
import { getPricesBySymbols } from './price-oracle.service';
|
|
||||||
import { signAndBroadcastSolBuilt } from './wallet-signer.service';
|
|
||||||
import { StakingError } from './staking.service';
|
|
||||||
import { resolveStakeValidator } from '../lib/sol-validator';
|
|
||||||
|
|
||||||
function badRequest(msg: string): StakingError {
|
|
||||||
return new StakingError(msg, 'BAD_REQUEST', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SOL = 9 decimals (lamports → SOL human-строка). */
|
|
||||||
const sol = (lamports: string): string => formatSmallestUnits(lamports, 9);
|
|
||||||
|
|
||||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
|
||||||
let _conn: Connection | null = null;
|
|
||||||
function getConn(): Connection {
|
|
||||||
if (!_conn) _conn = new Connection(SOL_RPC, 'confirmed');
|
|
||||||
return _conn;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
||||||
|
|
||||||
export interface SolStakeQuote {
|
|
||||||
amountLamports: string;
|
|
||||||
amountLamportsHuman: string;
|
|
||||||
appFeeLamports: string;
|
|
||||||
appFeeLamportsHuman: string;
|
|
||||||
appFeeUsd: number | null; // наша 0.7% в USDT (≈USD); null если цена SOL недоступна
|
|
||||||
stakeLamports: string;
|
|
||||||
stakeLamportsHuman: string;
|
|
||||||
validator: string;
|
|
||||||
estApyPercent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Превью: fee + сумма после fee + валидатор. APY — приблизительная оценка сети (~6.5%). */
|
|
||||||
export async function quoteSolStake(amountLamports: string): Promise<SolStakeQuote> {
|
|
||||||
if (typeof amountLamports !== 'string' || !/^\d+$/.test(amountLamports) || amountLamports === '0') {
|
|
||||||
throw badRequest('amount must be a positive integer string (lamports)');
|
|
||||||
}
|
|
||||||
const fee = computeAppFee(amountLamports);
|
|
||||||
const stake = BigInt(amountLamports) - fee;
|
|
||||||
if (stake <= 0n) throw badRequest('amount too small after 0.7% fee');
|
|
||||||
const validator = await resolveStakeValidator(getConn());
|
|
||||||
// USDT-эквивалент комиссии (≈USD). Цена SOL через whitelist; null если недоступна.
|
|
||||||
let solPrice: number | null = null;
|
|
||||||
try { solPrice = (await getPricesBySymbols([{ chain: 'SOL', symbol: 'SOL' }])).get('SOL:SOL') ?? null; } catch { /* optional */ }
|
|
||||||
const appFeeHuman = sol(fee.toString());
|
|
||||||
const appFeeUsd = solPrice != null ? Math.round(Number(appFeeHuman) * solPrice * 1e6) / 1e6 : null;
|
|
||||||
return {
|
|
||||||
amountLamports,
|
|
||||||
amountLamportsHuman: sol(amountLamports),
|
|
||||||
appFeeLamports: fee.toString(),
|
|
||||||
appFeeLamportsHuman: appFeeHuman,
|
|
||||||
appFeeUsd,
|
|
||||||
stakeLamports: stake.toString(),
|
|
||||||
stakeLamportsHuman: sol(stake.toString()),
|
|
||||||
validator,
|
|
||||||
estApyPercent: 6.5,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SolStakeResult {
|
|
||||||
stakeTxid: string;
|
|
||||||
stakeAccount: string;
|
|
||||||
stakeLamports: string;
|
|
||||||
stakeLamportsHuman: string;
|
|
||||||
appFeeLamports: string;
|
|
||||||
appFeeLamportsHuman: string;
|
|
||||||
validator: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** stake: create+delegate в одной транзакции (+ app-fee). */
|
|
||||||
export async function executeSolStake(p: {
|
|
||||||
mnemonic: string;
|
|
||||||
expectedFromAddress: string;
|
|
||||||
amountLamports: string;
|
|
||||||
}): Promise<SolStakeResult> {
|
|
||||||
if (typeof p.amountLamports !== 'string' || !/^\d+$/.test(p.amountLamports) || p.amountLamports === '0') {
|
|
||||||
throw badRequest('amount must be a positive integer string (lamports)');
|
|
||||||
}
|
|
||||||
const fee = computeAppFee(p.amountLamports);
|
|
||||||
const stakeLamports = BigInt(p.amountLamports) - fee;
|
|
||||||
if (stakeLamports <= 0n) throw badRequest('amount too small after 0.7% fee');
|
|
||||||
if (stakeLamports > MAX_SAFE) throw badRequest('amount exceeds Number precision; split into multiple stakes');
|
|
||||||
|
|
||||||
const conn = getConn();
|
|
||||||
const rentExempt = BigInt(await conn.getMinimumBalanceForRentExemption(StakeProgram.space));
|
|
||||||
if (stakeLamports <= rentExempt) {
|
|
||||||
throw badRequest(`amount too small: must exceed rent-exempt reserve (${rentExempt} lamports) after fee`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = new PublicKey(p.expectedFromAddress);
|
|
||||||
const validator = await resolveStakeValidator(conn);
|
|
||||||
const votePubkey = new PublicKey(validator);
|
|
||||||
const stakeAccount = Keypair.generate();
|
|
||||||
|
|
||||||
const instructions: TransactionInstruction[] = [];
|
|
||||||
|
|
||||||
// app fee 0.7% (native SOL)
|
|
||||||
if (fee > MAX_SAFE) throw badRequest('fee exceeds Number precision; amount too large');
|
|
||||||
if (fee > 0n) {
|
|
||||||
instructions.push(
|
|
||||||
SystemProgram.transfer({
|
|
||||||
fromPubkey: user,
|
|
||||||
toPubkey: new PublicKey(getAppFeeWallet('SOL')),
|
|
||||||
lamports: Number(fee),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// create + initialize stake account (StakeProgram.createAccount возвращает Transaction)
|
|
||||||
const createTx = StakeProgram.createAccount({
|
|
||||||
fromPubkey: user,
|
|
||||||
stakePubkey: stakeAccount.publicKey,
|
|
||||||
authorized: new Authorized(user, user),
|
|
||||||
lockup: new Lockup(0, 0, PublicKey.default),
|
|
||||||
lamports: Number(stakeLamports),
|
|
||||||
});
|
|
||||||
instructions.push(...createTx.instructions);
|
|
||||||
|
|
||||||
// delegate к валидатору
|
|
||||||
const delegateTx = StakeProgram.delegate({
|
|
||||||
stakePubkey: stakeAccount.publicKey,
|
|
||||||
authorizedPubkey: user,
|
|
||||||
votePubkey,
|
|
||||||
});
|
|
||||||
instructions.push(...delegateTx.instructions);
|
|
||||||
|
|
||||||
const { signature } = await signAndBroadcastSolBuilt({
|
|
||||||
mnemonic: p.mnemonic,
|
|
||||||
expectedFromAddress: p.expectedFromAddress,
|
|
||||||
instructions,
|
|
||||||
extraSigners: [stakeAccount],
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
stakeTxid: signature,
|
|
||||||
stakeAccount: stakeAccount.publicKey.toBase58(),
|
|
||||||
stakeLamports: stakeLamports.toString(),
|
|
||||||
stakeLamportsHuman: sol(stakeLamports.toString()),
|
|
||||||
appFeeLamports: fee.toString(),
|
|
||||||
appFeeLamportsHuman: sol(fee.toString()),
|
|
||||||
validator,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SolUnstakeResult {
|
|
||||||
action: 'deactivate' | 'withdraw';
|
|
||||||
txid: string;
|
|
||||||
withdrawnLamports?: string;
|
|
||||||
withdrawnLamportsHuman?: string;
|
|
||||||
note?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* unstake: один эндпоинт, два состояния.
|
|
||||||
* active/activating → deactivate (дальше cooldown 1-2 эпохи)
|
|
||||||
* inactive → withdraw всех lamports на адрес юзера
|
|
||||||
* deactivating → ещё рано (cooldown)
|
|
||||||
*/
|
|
||||||
export async function executeSolUnstake(p: {
|
|
||||||
mnemonic: string;
|
|
||||||
expectedFromAddress: string;
|
|
||||||
stakeAccount: string;
|
|
||||||
}): Promise<SolUnstakeResult> {
|
|
||||||
let stakePubkey: PublicKey;
|
|
||||||
try {
|
|
||||||
stakePubkey = new PublicKey(p.stakeAccount);
|
|
||||||
} catch {
|
|
||||||
throw badRequest('invalid stakeAccount address');
|
|
||||||
}
|
|
||||||
const user = new PublicKey(p.expectedFromAddress);
|
|
||||||
const conn = getConn();
|
|
||||||
|
|
||||||
// ownership/authority guard: stake account должен иметь staker-authority = адрес юзера
|
|
||||||
await assertStakeOwnedByUser(conn, stakePubkey, p.expectedFromAddress);
|
|
||||||
|
|
||||||
const state = await readStakeState(conn, stakePubkey);
|
|
||||||
|
|
||||||
if (state === 'active' || state === 'activating') {
|
|
||||||
const tx = StakeProgram.deactivate({ stakePubkey, authorizedPubkey: user });
|
|
||||||
const { signature } = await signAndBroadcastSolBuilt({
|
|
||||||
mnemonic: p.mnemonic,
|
|
||||||
expectedFromAddress: p.expectedFromAddress,
|
|
||||||
instructions: tx.instructions,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
action: 'deactivate',
|
|
||||||
txid: signature,
|
|
||||||
note: 'Деактивация запущена. Через 1-2 эпохи (cooldown сети) вызови /unstake снова — выведет средства.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === 'deactivating') {
|
|
||||||
throw badRequest('stake is deactivating; wait 1-2 epochs (network cooldown), then call /unstake again to withdraw');
|
|
||||||
}
|
|
||||||
|
|
||||||
// inactive → withdraw
|
|
||||||
const acct = await conn.getAccountInfo(stakePubkey);
|
|
||||||
const lamports = acct?.lamports ?? 0;
|
|
||||||
if (lamports <= 0) throw badRequest('stake account empty or not found');
|
|
||||||
if (lamports > Number.MAX_SAFE_INTEGER) throw badRequest('stake balance exceeds safe integer limit');
|
|
||||||
const wtx = StakeProgram.withdraw({
|
|
||||||
stakePubkey,
|
|
||||||
authorizedPubkey: user,
|
|
||||||
toPubkey: user,
|
|
||||||
lamports,
|
|
||||||
});
|
|
||||||
const { signature } = await signAndBroadcastSolBuilt({
|
|
||||||
mnemonic: p.mnemonic,
|
|
||||||
expectedFromAddress: p.expectedFromAddress,
|
|
||||||
instructions: wtx.instructions,
|
|
||||||
});
|
|
||||||
return { action: 'withdraw', txid: signature, withdrawnLamports: String(lamports), withdrawnLamportsHuman: sol(String(lamports)) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SolStakePosition {
|
|
||||||
stakeAccount: string;
|
|
||||||
lamports: string;
|
|
||||||
lamportsHuman: string;
|
|
||||||
state: string;
|
|
||||||
validator: string | null;
|
|
||||||
delegatedLamports: string | null;
|
|
||||||
delegatedLamportsHuman: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SolStakePositions {
|
|
||||||
protocol: 'native';
|
|
||||||
chain: 'SOL';
|
|
||||||
validator: string | null;
|
|
||||||
positions: SolStakePosition[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Позиции: все stake-аккаунты с staker-authority = адрес юзера. */
|
|
||||||
export async function readSolStakePositions(address: string): Promise<SolStakePositions> {
|
|
||||||
const conn = getConn();
|
|
||||||
let user: PublicKey;
|
|
||||||
try {
|
|
||||||
user = new PublicKey(address);
|
|
||||||
} catch {
|
|
||||||
throw badRequest('invalid SOL address');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stake account layout: state(4) + Meta{ rentReserve(8) + Authorized{ staker(32)@12, withdrawer(32)@44 } ... }
|
|
||||||
const accounts = await conn.getParsedProgramAccounts(StakeProgram.programId, {
|
|
||||||
filters: [{ memcmp: { offset: 12, bytes: user.toBase58() } }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const positions: SolStakePosition[] = [];
|
|
||||||
for (const a of accounts) {
|
|
||||||
const info: any = (a.account.data as any)?.parsed?.info;
|
|
||||||
const state = await readStakeState(conn, a.pubkey, info);
|
|
||||||
const delegated = info?.stake?.delegation?.stake ?? null;
|
|
||||||
positions.push({
|
|
||||||
stakeAccount: a.pubkey.toBase58(),
|
|
||||||
lamports: String(a.account.lamports),
|
|
||||||
lamportsHuman: sol(String(a.account.lamports)),
|
|
||||||
state,
|
|
||||||
validator: info?.stake?.delegation?.voter ?? null,
|
|
||||||
delegatedLamports: delegated,
|
|
||||||
delegatedLamportsHuman: delegated != null ? sol(String(delegated)) : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
protocol: 'native',
|
|
||||||
chain: 'SOL',
|
|
||||||
validator: await resolveStakeValidator(conn).catch(() => null),
|
|
||||||
positions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Состояние стейка с fallback. getStakeActivation() есть в @solana/web3.js 1.98, но многие
|
|
||||||
* публичные RPC его не держат → fallback на разбор parsed account info (delegation).
|
|
||||||
*/
|
|
||||||
async function readStakeState(
|
|
||||||
conn: Connection,
|
|
||||||
stakePubkey: PublicKey,
|
|
||||||
parsedInfo?: any,
|
|
||||||
): Promise<string> {
|
|
||||||
try {
|
|
||||||
return (await conn.getStakeActivation(stakePubkey)).state;
|
|
||||||
} catch {
|
|
||||||
let info = parsedInfo;
|
|
||||||
if (!info) {
|
|
||||||
try {
|
|
||||||
const acc = await conn.getParsedAccountInfo(stakePubkey);
|
|
||||||
info = (acc.value?.data as any)?.parsed?.info;
|
|
||||||
} catch {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const deleg = info?.stake?.delegation;
|
|
||||||
if (!deleg) return info ? 'inactive' : 'unknown';
|
|
||||||
const MAX_U64 = '18446744073709551615';
|
|
||||||
const deactEpoch = String(deleg.deactivationEpoch ?? '');
|
|
||||||
const actEpoch = String(deleg.activationEpoch ?? '');
|
|
||||||
const hasStake = deleg.stake && BigInt(deleg.stake) > 0n;
|
|
||||||
// КРИТИЧНО: сравниваем эпоху деактивации/активации с ТЕКУЩЕЙ. Раньше любая
|
|
||||||
// deactivationEpoch != MAX давала 'deactivating' навсегда → withdraw был недостижим,
|
|
||||||
// средства застревали. Деактивация завершается после своей эпохи → дальше 'inactive' (выводим).
|
|
||||||
const { epoch } = await conn.getEpochInfo();
|
|
||||||
const cur = BigInt(epoch);
|
|
||||||
if (deactEpoch && deactEpoch !== MAX_U64) {
|
|
||||||
return cur > BigInt(deactEpoch) ? 'inactive' : 'deactivating';
|
|
||||||
}
|
|
||||||
if (actEpoch && actEpoch !== MAX_U64 && cur <= BigInt(actEpoch)) return 'activating';
|
|
||||||
return hasStake ? 'active' : 'inactive';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Проверка: staker-authority stake-аккаунта = адрес юзера (защита от unstake чужого аккаунта). */
|
|
||||||
async function assertStakeOwnedByUser(conn: Connection, stakePubkey: PublicKey, userAddress: string): Promise<void> {
|
|
||||||
const acc = await conn.getParsedAccountInfo(stakePubkey);
|
|
||||||
const info: any = (acc.value?.data as any)?.parsed?.info;
|
|
||||||
const staker = info?.meta?.authorized?.staker;
|
|
||||||
if (!staker) {
|
|
||||||
throw badRequest('not a stake account or unreadable');
|
|
||||||
}
|
|
||||||
if (staker !== userAddress) {
|
|
||||||
throw badRequest('stake account not owned by user (staker authority mismatch)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
/**
|
|
||||||
* Staking orchestrator — custodial.
|
|
||||||
*
|
|
||||||
* Milestone 1: Lido ETH staking (stake / quote / positions).
|
|
||||||
* Паттерн как в bridge-execute.service.ts: сервер строит calldata, отнимает app-fee 0.7%,
|
|
||||||
* затем подписывает+бродкастит через wallet-signer. Mnemonic не покидает API.
|
|
||||||
*
|
|
||||||
* Lido `submit(referral)` payable минтит stETH ~1:1 к внесённому ETH. stETH — ребейзный ERC20,
|
|
||||||
* поэтому позиция = просто stETH balanceOf(адрес юзера); отдельная таблица в БД не нужна.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ethers } from 'ethers';
|
|
||||||
import { pickProxiedEvmProvider } from '../lib/outbound-proxy';
|
|
||||||
import {
|
|
||||||
ETH_RPCS,
|
|
||||||
lidoStEthAddress,
|
|
||||||
lidoWithdrawalQueueAddress,
|
|
||||||
curveStEthPoolAddress,
|
|
||||||
assertDefiEvmTarget,
|
|
||||||
LIDO_ABI,
|
|
||||||
LIDO_WQ_ABI,
|
|
||||||
CURVE_STETH_ABI,
|
|
||||||
} from '../lib/defi-contracts';
|
|
||||||
import { computeAppFee } from '../lib/app-fee';
|
|
||||||
import { formatSmallestUnits } from '../lib/amount-units';
|
|
||||||
import { getPricesBySymbols } from './price-oracle.service';
|
|
||||||
import { signAndBroadcastEvmFeeTx, signAndBroadcastRawEvm } from './wallet-signer.service';
|
|
||||||
import { signAndBroadcastEvmApprove } from './wallet-signer-bridge';
|
|
||||||
|
|
||||||
/** ETH/stETH/WETH = 18 decimals → human-строка. */
|
|
||||||
const eth = (wei: string): string => formatSmallestUnits(wei, 18);
|
|
||||||
|
|
||||||
/** Типизированная ошибка с HTTP-кодом для контроллера. */
|
|
||||||
export class StakingError extends Error {
|
|
||||||
constructor(message: string, public code: string, public httpStatus = 400) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'StakingError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function badRequest(msg: string): StakingError {
|
|
||||||
return new StakingError(msg, 'BAD_REQUEST', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lido submit обычно жжёт ~90k; 150k с запасом. */
|
|
||||||
const STAKE_GAS_LIMIT = '150000';
|
|
||||||
|
|
||||||
async function getEthProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
|
|
||||||
return pickProxiedEvmProvider(ETH_RPCS, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LidoQuote {
|
|
||||||
amountWei: string;
|
|
||||||
amountWeiHuman: string;
|
|
||||||
appFeeWei: string;
|
|
||||||
appFeeWeiHuman: string;
|
|
||||||
appFeeUsd: number | null; // наша 0.7% в USDT (≈USD); null если цена ETH недоступна
|
|
||||||
stakeAmountWei: string;
|
|
||||||
stakeAmountWeiHuman: string;
|
|
||||||
expectedStEthWei: string;
|
|
||||||
expectedStEthWeiHuman: string;
|
|
||||||
aprPercent: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Превью стейка: fee, сумма после fee, ожидаемый stETH (~1:1), APR. */
|
|
||||||
export async function quoteLidoStake(amountWei: string): Promise<LidoQuote> {
|
|
||||||
if (typeof amountWei !== 'string' || !/^\d+$/.test(amountWei) || amountWei === '0') {
|
|
||||||
throw badRequest('amount must be a positive integer string (wei)');
|
|
||||||
}
|
|
||||||
const fee = computeAppFee(amountWei);
|
|
||||||
const stakeAmount = BigInt(amountWei) - fee;
|
|
||||||
if (stakeAmount <= 0n) throw badRequest('amount too small after 0.7% fee');
|
|
||||||
const apr = await fetchLidoApr();
|
|
||||||
// USDT-эквивалент комиссии (≈USD). Цена ETH через whitelist; null если недоступна.
|
|
||||||
let ethPrice: number | null = null;
|
|
||||||
try { ethPrice = (await getPricesBySymbols([{ chain: 'ETH', symbol: 'ETH' }])).get('ETH:ETH') ?? null; } catch { /* optional */ }
|
|
||||||
const appFeeHuman = eth(fee.toString());
|
|
||||||
const appFeeUsd = ethPrice != null ? Math.round(Number(appFeeHuman) * ethPrice * 1e6) / 1e6 : null;
|
|
||||||
return {
|
|
||||||
amountWei,
|
|
||||||
amountWeiHuman: eth(amountWei),
|
|
||||||
appFeeWei: fee.toString(),
|
|
||||||
appFeeWeiHuman: appFeeHuman,
|
|
||||||
appFeeUsd,
|
|
||||||
stakeAmountWei: stakeAmount.toString(),
|
|
||||||
stakeAmountWeiHuman: eth(stakeAmount.toString()),
|
|
||||||
// Lido минтит stETH ~1:1 к внесённому ETH (после fee)
|
|
||||||
expectedStEthWei: stakeAmount.toString(),
|
|
||||||
expectedStEthWeiHuman: eth(stakeAmount.toString()),
|
|
||||||
aprPercent: apr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LidoStakeResult {
|
|
||||||
feeTxid: string;
|
|
||||||
stakeTxid: string;
|
|
||||||
stakeAmountWei: string;
|
|
||||||
stakeAmountWeiHuman: string;
|
|
||||||
appFeeWei: string;
|
|
||||||
appFeeWeiHuman: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Выполнить стейк: (1) app-fee native ETH (atomic, ждёт 1 conf), (2) Lido submit(referral) payable.
|
|
||||||
* Nonce-последовательность: fee использует nonce N (wait 1 conf) → submit берёт pending = N+1.
|
|
||||||
*/
|
|
||||||
export async function executeLidoStake(p: {
|
|
||||||
mnemonic: string;
|
|
||||||
expectedFromAddress: string;
|
|
||||||
amountWei: string;
|
|
||||||
}): Promise<LidoStakeResult> {
|
|
||||||
if (typeof p.amountWei !== 'string' || !/^\d+$/.test(p.amountWei) || p.amountWei === '0') {
|
|
||||||
throw badRequest('amount must be a positive integer string (wei)');
|
|
||||||
}
|
|
||||||
const fee = computeAppFee(p.amountWei);
|
|
||||||
const stakeAmount = BigInt(p.amountWei) - fee;
|
|
||||||
if (stakeAmount <= 0n) throw badRequest('amount too small after 0.7% fee');
|
|
||||||
|
|
||||||
// 1) app fee (native ETH) — отдельная транза, ждёт 1 conf; если revert → throw, submit не уйдёт
|
|
||||||
const feeRes = await signAndBroadcastEvmFeeTx({
|
|
||||||
chain: 'ETH',
|
|
||||||
mnemonic: p.mnemonic,
|
|
||||||
expectedFromAddress: p.expectedFromAddress,
|
|
||||||
bridgeAmount: p.amountWei,
|
|
||||||
bridgeToken: null, // native
|
|
||||||
feeTier: 'normal',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2) Lido submit(referral) payable, value = stakeAmount
|
|
||||||
const to = lidoStEthAddress();
|
|
||||||
assertDefiEvmTarget(to);
|
|
||||||
const data = new ethers.utils.Interface(LIDO_ABI).encodeFunctionData('submit', [ethers.constants.AddressZero]);
|
|
||||||
const stakeRes = await signAndBroadcastRawEvm({
|
|
||||||
chain: 'ETH',
|
|
||||||
mnemonic: p.mnemonic,
|
|
||||||
expectedFromAddress: p.expectedFromAddress,
|
|
||||||
feeTier: 'normal', // override fees из gas-oracle (tx.maxFee* игнорятся)
|
|
||||||
tx: {
|
|
||||||
to,
|
|
||||||
data,
|
|
||||||
value: stakeAmount.toString(),
|
|
||||||
chainId: 1,
|
|
||||||
gas: STAKE_GAS_LIMIT,
|
|
||||||
maxFeePerGas: '0',
|
|
||||||
maxPriorityFeePerGas: '0',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
feeTxid: feeRes.feeTxid,
|
|
||||||
stakeTxid: stakeRes.txid,
|
|
||||||
stakeAmountWei: stakeAmount.toString(),
|
|
||||||
stakeAmountWeiHuman: eth(stakeAmount.toString()),
|
|
||||||
appFeeWei: fee.toString(),
|
|
||||||
appFeeWeiHuman: eth(fee.toString()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LidoWithdrawalRequest {
|
|
||||||
requestId: string;
|
|
||||||
amountStEthWei: string;
|
|
||||||
amountStEthWeiHuman: string;
|
|
||||||
isFinalized: boolean;
|
|
||||||
isClaimed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LidoPositions {
|
|
||||||
protocol: 'lido';
|
|
||||||
chain: 'ETH';
|
|
||||||
stEthBalanceWei: string;
|
|
||||||
stEthBalanceWeiHuman: string;
|
|
||||||
aprPercent: number | null;
|
|
||||||
withdrawalRequests: LidoWithdrawalRequest[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Позиция = stETH balanceOf(юзера) + pending withdrawal requests (mode=queue).
|
|
||||||
* Читается live из чейна (без БД).
|
|
||||||
*/
|
|
||||||
export async function readLidoPositions(ethAddress: string): Promise<LidoPositions> {
|
|
||||||
const provider = await getEthProvider();
|
|
||||||
const stEth = new ethers.Contract(lidoStEthAddress(), LIDO_ABI, provider);
|
|
||||||
const wq = new ethers.Contract(lidoWithdrawalQueueAddress(), LIDO_WQ_ABI, provider);
|
|
||||||
|
|
||||||
const [bal, apr, requests] = await Promise.all([
|
|
||||||
stEth.balanceOf(ethAddress) as Promise<ethers.BigNumber>,
|
|
||||||
fetchLidoApr(),
|
|
||||||
readWithdrawalRequests(wq, ethAddress),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
protocol: 'lido',
|
|
||||||
chain: 'ETH',
|
|
||||||
stEthBalanceWei: bal.toString(),
|
|
||||||
stEthBalanceWeiHuman: eth(bal.toString()),
|
|
||||||
aprPercent: apr,
|
|
||||||
withdrawalRequests: requests,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readWithdrawalRequests(
|
|
||||||
wq: ethers.Contract,
|
|
||||||
owner: string,
|
|
||||||
): Promise<LidoWithdrawalRequest[]> {
|
|
||||||
try {
|
|
||||||
const ids: ethers.BigNumber[] = await wq.getWithdrawalRequests(owner);
|
|
||||||
if (!ids.length) return [];
|
|
||||||
const statuses: any[] = await wq.getWithdrawalStatus(ids);
|
|
||||||
return ids.map((id, i) => ({
|
|
||||||
requestId: id.toString(),
|
|
||||||
amountStEthWei: statuses[i]?.amountOfStETH?.toString() ?? '0',
|
|
||||||
amountStEthWeiHuman: eth(statuses[i]?.amountOfStETH?.toString() ?? '0'),
|
|
||||||
isFinalized: Boolean(statuses[i]?.isFinalized),
|
|
||||||
isClaimed: Boolean(statuses[i]?.isClaimed),
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Unstake ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const SLIPPAGE_DENOM = 10000n;
|
|
||||||
// Авто-slippage на выводе stETH→ETH: маленький фикс (0.3%). Безопасно — get_dy читается свежим
|
|
||||||
// перед exchange, поэтому slippage покрывает только окно исполнения (peg/impact уже в get_dy).
|
|
||||||
const UNSTAKE_SWAP_SLIPPAGE_BPS = 30n; // 0.3%
|
|
||||||
const SWAP_GAS_LIMIT = '300000';
|
|
||||||
const WQ_REQUEST_GAS_LIMIT = '350000';
|
|
||||||
const WQ_CLAIM_GAS_LIMIT = '250000';
|
|
||||||
|
|
||||||
export interface LidoUnstakeSwapResult {
|
|
||||||
mode: 'swap';
|
|
||||||
approveTxid: string;
|
|
||||||
swapTxid: string;
|
|
||||||
swapAmountStEthWei: string;
|
|
||||||
swapAmountStEthWeiHuman: string;
|
|
||||||
minEthOutWei: string;
|
|
||||||
minEthOutWeiHuman: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* mode=swap: мгновенный вывод stETH→ETH через Curve stETH/ETH пул (deepest liquidity).
|
|
||||||
* Вывод БЕЗ комиссии: approve stETH→Curve → exchange(1,0,...) на полную сумму.
|
|
||||||
*/
|
|
||||||
export async function unstakeLidoViaSwap(p: {
|
|
||||||
mnemonic: string;
|
|
||||||
expectedFromAddress: string;
|
|
||||||
amountStEthWei: string;
|
|
||||||
}): Promise<LidoUnstakeSwapResult> {
|
|
||||||
if (typeof p.amountStEthWei !== 'string' || !/^\d+$/.test(p.amountStEthWei) || p.amountStEthWei === '0') {
|
|
||||||
throw badRequest('amount must be a positive integer string (stETH wei)');
|
|
||||||
}
|
|
||||||
const swapAmount = BigInt(p.amountStEthWei);
|
|
||||||
|
|
||||||
const stEth = lidoStEthAddress();
|
|
||||||
const pool = curveStEthPoolAddress();
|
|
||||||
|
|
||||||
// approve stETH→Curve (полная сумма, вывод без комиссии)
|
|
||||||
const approveRes = await signAndBroadcastEvmApprove({
|
|
||||||
chain: 'ETH',
|
|
||||||
mnemonic: p.mnemonic,
|
|
||||||
expectedFromAddress: p.expectedFromAddress,
|
|
||||||
spender: pool,
|
|
||||||
token: stEth,
|
|
||||||
amount: swapAmount.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// min_dy из get_dy с авто-slippage
|
|
||||||
const provider = await getEthProvider();
|
|
||||||
const curve = new ethers.Contract(pool, CURVE_STETH_ABI, provider);
|
|
||||||
const dy: ethers.BigNumber = await curve.get_dy(1, 0, swapAmount.toString());
|
|
||||||
const minDy = (BigInt(dy.toString()) * (SLIPPAGE_DENOM - UNSTAKE_SWAP_SLIPPAGE_BPS)) / SLIPPAGE_DENOM;
|
|
||||||
|
|
||||||
// exchange(1=stETH, 0=ETH, swapAmount, minDy) — ETH прилетает юзеру
|
|
||||||
assertDefiEvmTarget(pool);
|
|
||||||
const data = new ethers.utils.Interface(CURVE_STETH_ABI).encodeFunctionData('exchange', [
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
swapAmount.toString(),
|
|
||||||
minDy.toString(),
|
|
||||||
]);
|
|
||||||
const swapRes = await signAndBroadcastRawEvm({
|
|
||||||
chain: 'ETH',
|
|
||||||
mnemonic: p.mnemonic,
|
|
||||||
expectedFromAddress: p.expectedFromAddress,
|
|
||||||
feeTier: 'normal',
|
|
||||||
tx: { to: pool, data, value: '0', chainId: 1, gas: SWAP_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: 'swap',
|
|
||||||
approveTxid: approveRes.txid,
|
|
||||||
swapTxid: swapRes.txid,
|
|
||||||
swapAmountStEthWei: swapAmount.toString(),
|
|
||||||
swapAmountStEthWeiHuman: eth(swapAmount.toString()),
|
|
||||||
minEthOutWei: minDy.toString(),
|
|
||||||
minEthOutWeiHuman: eth(minDy.toString()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LidoUnstakeQueueResult {
|
|
||||||
mode: 'queue';
|
|
||||||
approveTxid: string;
|
|
||||||
requestTxid: string;
|
|
||||||
requestedStEthWei: string;
|
|
||||||
requestedStEthWeiHuman: string;
|
|
||||||
note: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* mode=queue: честный вывод через Lido Withdrawal Queue. БЕЗ комиссии.
|
|
||||||
* Шаги: approve stETH→WQ → requestWithdrawals([amount], owner) на полную сумму.
|
|
||||||
* requestId(ы) появятся в GET /stake/positions; затем POST /unstake/claim.
|
|
||||||
*/
|
|
||||||
export async function unstakeLidoViaQueue(p: {
|
|
||||||
mnemonic: string;
|
|
||||||
expectedFromAddress: string;
|
|
||||||
amountStEthWei: string;
|
|
||||||
}): Promise<LidoUnstakeQueueResult> {
|
|
||||||
if (typeof p.amountStEthWei !== 'string' || !/^\d+$/.test(p.amountStEthWei) || p.amountStEthWei === '0') {
|
|
||||||
throw badRequest('amount must be a positive integer string (stETH wei)');
|
|
||||||
}
|
|
||||||
const reqAmount = BigInt(p.amountStEthWei);
|
|
||||||
// Lido WQ ограничивает один запрос ≤ 1000 stETH
|
|
||||||
if (reqAmount > ethers.utils.parseEther('1000').toBigInt()) {
|
|
||||||
throw badRequest('single withdrawal request must be ≤ 1000 stETH; split into multiple');
|
|
||||||
}
|
|
||||||
|
|
||||||
const stEth = lidoStEthAddress();
|
|
||||||
const wq = lidoWithdrawalQueueAddress();
|
|
||||||
|
|
||||||
const approveRes = await signAndBroadcastEvmApprove({
|
|
||||||
chain: 'ETH',
|
|
||||||
mnemonic: p.mnemonic,
|
|
||||||
expectedFromAddress: p.expectedFromAddress,
|
|
||||||
spender: wq,
|
|
||||||
token: stEth,
|
|
||||||
amount: reqAmount.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
assertDefiEvmTarget(wq);
|
|
||||||
const data = new ethers.utils.Interface(LIDO_WQ_ABI).encodeFunctionData('requestWithdrawals', [
|
|
||||||
[reqAmount.toString()],
|
|
||||||
p.expectedFromAddress,
|
|
||||||
]);
|
|
||||||
const reqRes = await signAndBroadcastRawEvm({
|
|
||||||
chain: 'ETH',
|
|
||||||
mnemonic: p.mnemonic,
|
|
||||||
expectedFromAddress: p.expectedFromAddress,
|
|
||||||
feeTier: 'normal',
|
|
||||||
tx: { to: wq, data, value: '0', chainId: 1, gas: WQ_REQUEST_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: 'queue',
|
|
||||||
approveTxid: approveRes.txid,
|
|
||||||
requestTxid: reqRes.txid,
|
|
||||||
requestedStEthWei: reqAmount.toString(),
|
|
||||||
requestedStEthWeiHuman: eth(reqAmount.toString()),
|
|
||||||
note: 'Withdrawal requested (без комиссии). Check GET /wallets/ETH/stake/positions for requestId + isFinalized, then POST /wallets/ETH/unstake/claim.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Claim finalized withdrawal request (mode=queue). */
|
|
||||||
export async function claimLidoWithdrawal(p: {
|
|
||||||
mnemonic: string;
|
|
||||||
expectedFromAddress: string;
|
|
||||||
requestId: string;
|
|
||||||
}): Promise<{ claimTxid: string }> {
|
|
||||||
if (typeof p.requestId !== 'string' || !/^\d+$/.test(p.requestId)) {
|
|
||||||
throw badRequest('requestId must be a positive integer string');
|
|
||||||
}
|
|
||||||
const wq = lidoWithdrawalQueueAddress();
|
|
||||||
|
|
||||||
// Pre-check: requestId должен принадлежать юзеру — иначе on-chain revert + сожжённый газ.
|
|
||||||
// Деградация: если чтение упало (RPC) — не блокируем (claim сам отвергнет чужой requestId on-chain).
|
|
||||||
try {
|
|
||||||
const provider = await getEthProvider();
|
|
||||||
const wqRead = new ethers.Contract(wq, LIDO_WQ_ABI, provider);
|
|
||||||
const ids: ethers.BigNumber[] = await wqRead.getWithdrawalRequests(p.expectedFromAddress);
|
|
||||||
if (!ids.some((id) => id.toString() === p.requestId)) {
|
|
||||||
throw badRequest('requestId not found for this user (см. GET /wallets/ETH/stake/positions)');
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err instanceof StakingError) throw err;
|
|
||||||
/* RPC read failure → proceed (on-chain claim защищает) */
|
|
||||||
}
|
|
||||||
|
|
||||||
assertDefiEvmTarget(wq);
|
|
||||||
const data = new ethers.utils.Interface(LIDO_WQ_ABI).encodeFunctionData('claimWithdrawal', [p.requestId]);
|
|
||||||
const res = await signAndBroadcastRawEvm({
|
|
||||||
chain: 'ETH',
|
|
||||||
mnemonic: p.mnemonic,
|
|
||||||
expectedFromAddress: p.expectedFromAddress,
|
|
||||||
feeTier: 'normal',
|
|
||||||
tx: { to: wq, data, value: '0', chainId: 1, gas: WQ_CLAIM_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' },
|
|
||||||
});
|
|
||||||
return { claimTxid: res.txid };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lido APR (public API) с кэшем 10 мин + fallback ────────────────────
|
|
||||||
let _aprCache: { value: number; at: number } | null = null;
|
|
||||||
const APR_TTL_MS = 10 * 60_000;
|
|
||||||
|
|
||||||
async function fetchLidoApr(): Promise<number | null> {
|
|
||||||
const now = Date.now();
|
|
||||||
if (_aprCache && now - _aprCache.at < APR_TTL_MS) return _aprCache.value;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const t = setTimeout(() => controller.abort(), 5000);
|
|
||||||
try {
|
|
||||||
const res = await fetch('https://eth-api.lido.fi/v1/protocol/steth/apr/last', {
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const j: any = await res.json();
|
|
||||||
const apr = Number(j?.data?.apr ?? j?.apr);
|
|
||||||
if (Number.isFinite(apr)) {
|
|
||||||
_aprCache = { value: apr, at: now };
|
|
||||||
return apr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* network/timeout → fallback на stale/null */
|
|
||||||
} finally {
|
|
||||||
clearTimeout(t);
|
|
||||||
}
|
|
||||||
return _aprCache?.value ?? null;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,788 +0,0 @@
|
|||||||
/**
|
|
||||||
* Uniswap v3 liquidity provision — custodial. 2 куриных пула, произвольный диапазон.
|
|
||||||
*
|
|
||||||
* getPools — список пулов: токены, fee, текущая цена, TVL, APR (subgraph).
|
|
||||||
* quoteAdd — из [priceLower, priceUpper] → тики + нужные amount0/amount1 + slippage-мины.
|
|
||||||
* executeAdd— fee 0.7% (оба токена) → approve×2 → NFPM.mint() → tokenId.
|
|
||||||
* remove — NFPM.multicall(decreaseLiquidity + collect [+ burn]).
|
|
||||||
* positions — enumerate NFPM по owner, фильтр по куриным пулам, текущая стоимость + комиссии.
|
|
||||||
*
|
|
||||||
* Цена в API — token1 per token0 (raw pool convention, human-decimal-adjusted). Текущую цену и
|
|
||||||
* символы токенов смотри в GET /lp/pools, чтобы знать в каких числах задавать диапазон.
|
|
||||||
* MVP: оба токена — ERC20 (WETH как ERC20, без нативного ETH-leg). Авто-zap — позже.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ethers } from 'ethers';
|
|
||||||
import { pickProxiedEvmProvider } from '../lib/outbound-proxy';
|
|
||||||
import {
|
|
||||||
ETH_RPCS,
|
|
||||||
uniswapNfpmAddress,
|
|
||||||
uniswapLpPools,
|
|
||||||
isCuratedPool,
|
|
||||||
assertDefiEvmTarget,
|
|
||||||
isWeth,
|
|
||||||
NFPM_ABI,
|
|
||||||
UNIV3_POOL_ABI,
|
|
||||||
ERC20_META_ABI,
|
|
||||||
} from '../lib/defi-contracts';
|
|
||||||
import { computeAppFee } from '../lib/app-fee';
|
|
||||||
import { formatSmallestUnits } from '../lib/amount-units';
|
|
||||||
import { getPricesBySymbols } from './price-oracle.service';
|
|
||||||
import { signAndBroadcastRawEvm } from './wallet-signer.service';
|
|
||||||
import {
|
|
||||||
signAndBroadcastEvmApprove,
|
|
||||||
signAndBroadcastWrapEth,
|
|
||||||
readErc20Balance,
|
|
||||||
readEvmNativeBalance,
|
|
||||||
} from './wallet-signer-bridge';
|
|
||||||
import { signAndBroadcastEvmFeeTx } from './wallet-signer.service';
|
|
||||||
import { getPoolStats } from '../lib/uniswap-subgraph';
|
|
||||||
import {
|
|
||||||
getSqrtRatioAtTick,
|
|
||||||
priceToTick,
|
|
||||||
tickToPrice,
|
|
||||||
alignTick,
|
|
||||||
sqrtPriceX96ToPrice,
|
|
||||||
getLiquidityForAmounts,
|
|
||||||
getAmountsForLiquidity,
|
|
||||||
} from '../lib/uniswap-v3-math';
|
|
||||||
import { StakingError } from './staking.service';
|
|
||||||
import { logger } from '../lib/logger';
|
|
||||||
|
|
||||||
/** Переиспользуем типизированную ошибку (контроллер маппит httpStatus). */
|
|
||||||
function badRequest(msg: string): StakingError {
|
|
||||||
return new StakingError(msg, 'BAD_REQUEST', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MINT_GAS_LIMIT = '600000';
|
|
||||||
const REMOVE_GAS_LIMIT = '500000';
|
|
||||||
// Оценочные gas-лимиты шагов LP-депозита (для networkFee в quote). Соответствуют реальным:
|
|
||||||
// mint=MINT_GAS_LIMIT, approve≈APPROVE_GAS_LIMIT, fee≈ERC20 transfer, wrap≈WETH.deposit.
|
|
||||||
const EST_GAS_MINT = 600_000n;
|
|
||||||
const EST_GAS_APPROVE = 80_000n;
|
|
||||||
const EST_GAS_FEE = 65_000n;
|
|
||||||
const EST_GAS_WRAP = 60_000n;
|
|
||||||
const DEFAULT_SLIPPAGE_BPS = 100n; // 1%
|
|
||||||
const MAX_SLIPPAGE_BPS = 500n; // 5% — верхняя граница (дефолт 1%)
|
|
||||||
const SLIPPAGE_DENOM = 10000n;
|
|
||||||
const MAX_UINT128 = ((1n << 128n) - 1n).toString();
|
|
||||||
const MAX_UINT256 = (1n << 256n) - 1n;
|
|
||||||
const POSITIONS_SCAN_CAP = 50;
|
|
||||||
|
|
||||||
/** Валидация amount-строки в smallest units: целое, неотрицательное, ≤ uint256. */
|
|
||||||
function parseAmountSmallest(s: string, label: string): bigint {
|
|
||||||
if (typeof s !== 'string' || !/^\d+$/.test(s)) throw badRequest(`${label} must be a non-negative integer string`);
|
|
||||||
const v = BigInt(s);
|
|
||||||
if (v > MAX_UINT256) throw badRequest(`${label} exceeds uint256 maximum`);
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Валидация slippageBps: 0..5000. Возвращает bigint bps. */
|
|
||||||
function resolveSlippageBps(slippageBps?: number): bigint {
|
|
||||||
if (slippageBps === undefined) return DEFAULT_SLIPPAGE_BPS;
|
|
||||||
if (!Number.isFinite(slippageBps) || slippageBps < 0 || slippageBps > Number(MAX_SLIPPAGE_BPS)) {
|
|
||||||
throw badRequest(`slippageBps must be between 0 and ${Number(MAX_SLIPPAGE_BPS)}`);
|
|
||||||
}
|
|
||||||
return BigInt(Math.floor(slippageBps));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
|
|
||||||
return pickProxiedEvmProvider(ETH_RPCS, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PoolData {
|
|
||||||
poolAddress: string;
|
|
||||||
token0: string;
|
|
||||||
token1: string;
|
|
||||||
fee: number;
|
|
||||||
tickSpacing: number;
|
|
||||||
sqrtPriceX96: bigint;
|
|
||||||
tick: number;
|
|
||||||
liquidity: bigint;
|
|
||||||
dec0: number;
|
|
||||||
dec1: number;
|
|
||||||
sym0: string;
|
|
||||||
sym1: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readPool(provider: ethers.providers.Provider, poolAddr: string): Promise<PoolData> {
|
|
||||||
const pool = new ethers.Contract(poolAddr, UNIV3_POOL_ABI, provider);
|
|
||||||
const [token0, token1, fee, tickSpacing, slot0, liquidity] = await Promise.all([
|
|
||||||
pool.token0() as Promise<string>,
|
|
||||||
pool.token1() as Promise<string>,
|
|
||||||
pool.fee() as Promise<number>,
|
|
||||||
pool.tickSpacing() as Promise<number>,
|
|
||||||
pool.slot0() as Promise<any>,
|
|
||||||
pool.liquidity() as Promise<ethers.BigNumber>,
|
|
||||||
]);
|
|
||||||
const t0 = new ethers.Contract(token0, ERC20_META_ABI, provider);
|
|
||||||
const t1 = new ethers.Contract(token1, ERC20_META_ABI, provider);
|
|
||||||
const [dec0, dec1, sym0, sym1] = await Promise.all([
|
|
||||||
t0.decimals() as Promise<number>,
|
|
||||||
t1.decimals() as Promise<number>,
|
|
||||||
t0.symbol().catch(() => '?') as Promise<string>,
|
|
||||||
t1.symbol().catch(() => '?') as Promise<string>,
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
poolAddress: poolAddr.toLowerCase(),
|
|
||||||
token0,
|
|
||||||
token1,
|
|
||||||
fee: Number(fee),
|
|
||||||
tickSpacing: Number(tickSpacing),
|
|
||||||
sqrtPriceX96: BigInt(slot0.sqrtPriceX96.toString()),
|
|
||||||
tick: Number(slot0.tick),
|
|
||||||
liquidity: BigInt(liquidity.toString()),
|
|
||||||
dec0: Number(dec0),
|
|
||||||
dec1: Number(dec1),
|
|
||||||
sym0,
|
|
||||||
sym1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── getPools ───────────────────────────────────────────────────────────
|
|
||||||
export interface PoolInfo {
|
|
||||||
poolAddress: string;
|
|
||||||
token0: string;
|
|
||||||
token1: string;
|
|
||||||
symbol0: string;
|
|
||||||
symbol1: string;
|
|
||||||
decimals0: number;
|
|
||||||
decimals1: number;
|
|
||||||
feeTier: number;
|
|
||||||
currentPrice: number; // token1 per token0 (human)
|
|
||||||
currentTick: number;
|
|
||||||
tvlUSD: number | null;
|
|
||||||
volumeUSD24h: number | null;
|
|
||||||
aprPercent: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPools(): Promise<PoolInfo[]> {
|
|
||||||
const provider = await getProvider();
|
|
||||||
const pools = uniswapLpPools();
|
|
||||||
const out: PoolInfo[] = [];
|
|
||||||
for (const addr of pools) {
|
|
||||||
try {
|
|
||||||
const p = await readPool(provider, addr);
|
|
||||||
const stats = await getPoolStats(addr, p.fee);
|
|
||||||
out.push({
|
|
||||||
poolAddress: p.poolAddress,
|
|
||||||
token0: p.token0,
|
|
||||||
token1: p.token1,
|
|
||||||
symbol0: p.sym0,
|
|
||||||
symbol1: p.sym1,
|
|
||||||
decimals0: p.dec0,
|
|
||||||
decimals1: p.dec1,
|
|
||||||
feeTier: p.fee,
|
|
||||||
currentPrice: sqrtPriceX96ToPrice(p.sqrtPriceX96, p.dec0, p.dec1),
|
|
||||||
currentTick: p.tick,
|
|
||||||
tvlUSD: stats.totalValueLockedUSD,
|
|
||||||
volumeUSD24h: stats.volumeUSD24h,
|
|
||||||
aprPercent: stats.aprPercent,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
// один битый пул не валит весь список — но логируем для observability
|
|
||||||
logger.warn(`LP getPools: failed to read pool ${addr}: ${err?.message || err}`);
|
|
||||||
out.push({
|
|
||||||
poolAddress: addr,
|
|
||||||
token0: '', token1: '', symbol0: '?', symbol1: '?', decimals0: 0, decimals1: 0,
|
|
||||||
feeTier: 0, currentPrice: 0, currentTick: 0, tvlUSD: null, volumeUSD24h: null, aprPercent: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── quoteAdd ───────────────────────────────────────────────────────────
|
|
||||||
/** Достаточность баланса по одной ноге (human-числа). sufficient=null → не проверялось / покрыто нативным ETH. */
|
|
||||||
interface LegBalance {
|
|
||||||
symbol: string;
|
|
||||||
have: string | null;
|
|
||||||
need: string;
|
|
||||||
sufficient: boolean | null;
|
|
||||||
shortfall: string;
|
|
||||||
}
|
|
||||||
export interface AddQuote {
|
|
||||||
poolAddress: string;
|
|
||||||
tickLower: number;
|
|
||||||
tickUpper: number;
|
|
||||||
priceLower: number;
|
|
||||||
priceUpper: number;
|
|
||||||
amount0Desired: string;
|
|
||||||
amount0DesiredHuman: string;
|
|
||||||
amount1Desired: string;
|
|
||||||
amount1DesiredHuman: string;
|
|
||||||
amount0Min: string;
|
|
||||||
amount0MinHuman: string;
|
|
||||||
amount1Min: string;
|
|
||||||
amount1MinHuman: string;
|
|
||||||
liquidity: string;
|
|
||||||
aprPercent: number | null;
|
|
||||||
// ── Наша app-комиссия 0.7% по каждой ноге (0.7% от вносимой суммы) ──
|
|
||||||
appFee0: string;
|
|
||||||
appFee0Human: string;
|
|
||||||
appFee1: string;
|
|
||||||
appFee1Human: string;
|
|
||||||
appFee0Usd: number | null;
|
|
||||||
appFee1Usd: number | null;
|
|
||||||
appFeeTotalUsd: number | null;
|
|
||||||
appFeeTotalEth: string | null; // комиссия в монете сети (ETH-эквивалент)
|
|
||||||
// ── Оценка сетевой (gas) комиссии всего LP-депозита (wrap?+fee×ноги+approve×ноги+mint) ──
|
|
||||||
networkFee: {
|
|
||||||
estGasUnits: string;
|
|
||||||
gasPriceGwei: string | null;
|
|
||||||
amountEthHuman: string | null; // оценка газа в ETH
|
|
||||||
amountUsd: number | null; // та же оценка в USD
|
|
||||||
};
|
|
||||||
// ── Проверка баланса (только если передан ownerAddress) ──
|
|
||||||
balance?: {
|
|
||||||
token0: LegBalance;
|
|
||||||
token1: LegBalance;
|
|
||||||
gas: { symbol: string; have: string | null; needReserve: string; sufficient: boolean | null };
|
|
||||||
sufficient: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddParams {
|
|
||||||
poolAddress: string;
|
|
||||||
priceLower: number;
|
|
||||||
priceUpper: number;
|
|
||||||
amount0Desired: string; // smallest units (после fee для execute; до fee для quote — informational)
|
|
||||||
amount1Desired: string;
|
|
||||||
slippageBps?: number;
|
|
||||||
ownerAddress?: string; // если задан — добавляем проверку баланса token0/token1/gas
|
|
||||||
useNativeEth?: boolean; // WETH-нога покрывается нативным ETH (авто-wrap) — влияет на проверку баланса
|
|
||||||
}
|
|
||||||
|
|
||||||
/** number → human-строка без scientific notation и хвостовых нулей. */
|
|
||||||
function trimNum(n: number): string {
|
|
||||||
if (!Number.isFinite(n)) return '0';
|
|
||||||
let s = n.toFixed(12).replace(/0+$/, '').replace(/\.$/, '');
|
|
||||||
if (s === '' || s === '-0') s = '0';
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
function roundUsd(n: number): number {
|
|
||||||
return Math.round(n * 1e6) / 1e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function computeAddPlan(provider: ethers.providers.Provider, p: AddParams): Promise<{
|
|
||||||
pool: PoolData;
|
|
||||||
tickLower: number;
|
|
||||||
tickUpper: number;
|
|
||||||
amount0Used: bigint;
|
|
||||||
amount1Used: bigint;
|
|
||||||
amount0Min: bigint;
|
|
||||||
amount1Min: bigint;
|
|
||||||
liquidity: bigint;
|
|
||||||
}> {
|
|
||||||
if (!isCuratedPool(p.poolAddress)) throw badRequest('pool not in curated list');
|
|
||||||
if (!(p.priceLower > 0) || !(p.priceUpper > 0) || p.priceLower >= p.priceUpper) {
|
|
||||||
throw badRequest('priceLower/priceUpper must be > 0 and priceLower < priceUpper');
|
|
||||||
}
|
|
||||||
const a0 = parseAmountSmallest(p.amount0Desired, 'amount0Desired');
|
|
||||||
const a1 = parseAmountSmallest(p.amount1Desired, 'amount1Desired');
|
|
||||||
if (a0 === 0n && a1 === 0n) {
|
|
||||||
throw badRequest('at least one of amount0Desired/amount1Desired must be > 0');
|
|
||||||
}
|
|
||||||
const slipBps = resolveSlippageBps(p.slippageBps);
|
|
||||||
|
|
||||||
const pool = await readPool(provider, p.poolAddress);
|
|
||||||
|
|
||||||
let tLow = alignTick(priceToTick(p.priceLower, pool.dec0, pool.dec1), pool.tickSpacing);
|
|
||||||
let tHigh = alignTick(priceToTick(p.priceUpper, pool.dec0, pool.dec1), pool.tickSpacing);
|
|
||||||
if (tLow > tHigh) [tLow, tHigh] = [tHigh, tLow];
|
|
||||||
if (tLow === tHigh) tHigh += pool.tickSpacing;
|
|
||||||
|
|
||||||
const sqrtA = getSqrtRatioAtTick(tLow);
|
|
||||||
const sqrtB = getSqrtRatioAtTick(tHigh);
|
|
||||||
const L = getLiquidityForAmounts(pool.sqrtPriceX96, sqrtA, sqrtB, a0, a1);
|
|
||||||
if (L <= 0n) throw badRequest('computed liquidity is zero — increase amounts or widen range');
|
|
||||||
const used = getAmountsForLiquidity(pool.sqrtPriceX96, sqrtA, sqrtB, L);
|
|
||||||
|
|
||||||
const minOf = (x: bigint) => (x * (SLIPPAGE_DENOM - slipBps)) / SLIPPAGE_DENOM;
|
|
||||||
|
|
||||||
return {
|
|
||||||
pool,
|
|
||||||
tickLower: tLow,
|
|
||||||
tickUpper: tHigh,
|
|
||||||
amount0Used: used.amount0,
|
|
||||||
amount1Used: used.amount1,
|
|
||||||
amount0Min: minOf(used.amount0),
|
|
||||||
amount1Min: minOf(used.amount1),
|
|
||||||
liquidity: L,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function quoteAdd(p: AddParams): Promise<AddQuote> {
|
|
||||||
const provider = await getProvider();
|
|
||||||
const plan = await computeAddPlan(provider, p);
|
|
||||||
const stats = await getPoolStats(plan.pool.poolAddress, plan.pool.fee);
|
|
||||||
const pool = plan.pool;
|
|
||||||
|
|
||||||
// ── Наша app-комиссия 0.7% (база — вносимая сумма, как в execute: computeAppFee(amount{0,1}Desired)) ──
|
|
||||||
const in0 = BigInt(p.amount0Desired);
|
|
||||||
const in1 = BigInt(p.amount1Desired);
|
|
||||||
const fee0 = in0 > 0n ? computeAppFee(p.amount0Desired) : 0n;
|
|
||||||
const fee1 = in1 > 0n ? computeAppFee(p.amount1Desired) : 0n;
|
|
||||||
const fee0Human = formatSmallestUnits(fee0.toString(), pool.dec0);
|
|
||||||
const fee1Human = formatSmallestUnits(fee1.toString(), pool.dec1);
|
|
||||||
|
|
||||||
// Цены (whitelist через getCoingeckoId внутри). WETH-нога → цена ETH. XAUt и пр. вне whitelist → null.
|
|
||||||
let ethPrice: number | null = null;
|
|
||||||
let price0: number | null = null;
|
|
||||||
let price1: number | null = null;
|
|
||||||
try {
|
|
||||||
const priceMap = await getPricesBySymbols([
|
|
||||||
{ chain: 'ETH', symbol: 'ETH' },
|
|
||||||
{ chain: 'ETH', symbol: pool.sym0 },
|
|
||||||
{ chain: 'ETH', symbol: pool.sym1 },
|
|
||||||
]);
|
|
||||||
ethPrice = priceMap.get('ETH:ETH') ?? null;
|
|
||||||
price0 = isWeth(pool.token0) ? ethPrice : (priceMap.get(`ETH:${pool.sym0}`) ?? null);
|
|
||||||
price1 = isWeth(pool.token1) ? ethPrice : (priceMap.get(`ETH:${pool.sym1}`) ?? null);
|
|
||||||
} catch { /* prices optional */ }
|
|
||||||
|
|
||||||
// ── Оценка сетевой (gas) комиссии: суммируем gas-лимиты шагов, которые реально пойдут ──
|
|
||||||
const t0IsWethN = isWeth(pool.token0);
|
|
||||||
const t1IsWethN = isWeth(pool.token1);
|
|
||||||
let estGas = EST_GAS_MINT; // mint всегда
|
|
||||||
if (in0 > 0n) estGas += EST_GAS_APPROVE + EST_GAS_FEE; // нога token0: approve + fee
|
|
||||||
if (in1 > 0n) estGas += EST_GAS_APPROVE + EST_GAS_FEE; // нога token1
|
|
||||||
if (p.useNativeEth && (t0IsWethN || t1IsWethN)) estGas += EST_GAS_WRAP; // авто-wrap ETH→WETH
|
|
||||||
let gasPriceWei = 0n;
|
|
||||||
try {
|
|
||||||
const fd = await provider.getFeeData();
|
|
||||||
const gp = fd.maxFeePerGas ?? fd.gasPrice ?? null;
|
|
||||||
if (gp) gasPriceWei = BigInt(gp.toString());
|
|
||||||
} catch { /* gas price optional */ }
|
|
||||||
const networkFeeWei = estGas * gasPriceWei;
|
|
||||||
const networkFeeEthHuman = gasPriceWei > 0n ? formatSmallestUnits(networkFeeWei.toString(), 18) : null;
|
|
||||||
const networkFeeUsd = (networkFeeEthHuman != null && ethPrice != null)
|
|
||||||
? roundUsd(Number(networkFeeEthHuman) * ethPrice) : null;
|
|
||||||
const gasPriceGwei = gasPriceWei > 0n ? formatSmallestUnits(gasPriceWei.toString(), 9) : null;
|
|
||||||
|
|
||||||
const fee0Usd = price0 != null ? roundUsd(Number(fee0Human) * price0) : null;
|
|
||||||
const fee1Usd = price1 != null ? roundUsd(Number(fee1Human) * price1) : null;
|
|
||||||
const usdParts = [fee0Usd, fee1Usd].filter((v): v is number => v != null);
|
|
||||||
const appFeeTotalUsd = usdParts.length ? roundUsd(usdParts.reduce((a, b) => a + b, 0)) : null;
|
|
||||||
const appFeeTotalEth = (appFeeTotalUsd != null && ethPrice != null && ethPrice > 0)
|
|
||||||
? trimNum(appFeeTotalUsd / ethPrice)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// ── Проверка баланса (только если есть ownerAddress) ──
|
|
||||||
let balance: AddQuote['balance'];
|
|
||||||
if (p.ownerAddress) {
|
|
||||||
const owner = p.ownerAddress;
|
|
||||||
const t0IsWeth = isWeth(pool.token0);
|
|
||||||
const t1IsWeth = isWeth(pool.token1);
|
|
||||||
const legBalance = async (need: bigint, tokenAddr: string, isWethLeg: boolean, sym: string, dec: number): Promise<LegBalance> => {
|
|
||||||
const needHuman = formatSmallestUnits(need.toString(), dec);
|
|
||||||
if (need === 0n) return { symbol: sym, have: '0', need: needHuman, sufficient: true, shortfall: '0' };
|
|
||||||
// WETH-нога при useNativeEth покрывается нативным ETH → проверяется в gas-ноге, здесь не блокируем.
|
|
||||||
if (p.useNativeEth && isWethLeg) return { symbol: sym, have: null, need: needHuman, sufficient: null, shortfall: '0' };
|
|
||||||
try {
|
|
||||||
const bal = await readErc20Balance('ETH', tokenAddr, owner);
|
|
||||||
const sufficient = bal >= need;
|
|
||||||
return { symbol: sym, have: formatSmallestUnits(bal.toString(), dec), need: needHuman, sufficient, shortfall: sufficient ? '0' : formatSmallestUnits((need - bal).toString(), dec) };
|
|
||||||
} catch (err: any) {
|
|
||||||
logger.warn(`LP quote balance read failed ${sym}: ${err?.message}`);
|
|
||||||
return { symbol: sym, have: null, need: needHuman, sufficient: null, shortfall: '0' };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const token0 = await legBalance(in0, pool.token0, t0IsWeth, pool.sym0, pool.dec0);
|
|
||||||
const token1 = await legBalance(in1, pool.token1, t1IsWeth, pool.sym1, pool.dec1);
|
|
||||||
|
|
||||||
const reserve = ethers.utils.parseEther('0.001').toBigInt();
|
|
||||||
let ethNeed = reserve;
|
|
||||||
if (p.useNativeEth) {
|
|
||||||
const wrapAmt = t0IsWeth ? in0 : t1IsWeth ? in1 : 0n;
|
|
||||||
ethNeed = wrapAmt + reserve;
|
|
||||||
}
|
|
||||||
let gasHave: string | null = null;
|
|
||||||
let gasSufficient: boolean | null = null;
|
|
||||||
try {
|
|
||||||
const ethBal = await readEvmNativeBalance('ETH', owner);
|
|
||||||
gasHave = formatSmallestUnits(ethBal.toString(), 18);
|
|
||||||
gasSufficient = ethBal >= ethNeed;
|
|
||||||
} catch (err: any) {
|
|
||||||
logger.warn(`LP quote ETH balance read failed: ${err?.message}`);
|
|
||||||
}
|
|
||||||
const gas = { symbol: 'ETH', have: gasHave, needReserve: formatSmallestUnits(ethNeed.toString(), 18), sufficient: gasSufficient };
|
|
||||||
balance = {
|
|
||||||
token0, token1, gas,
|
|
||||||
sufficient: token0.sufficient !== false && token1.sufficient !== false && gas.sufficient !== false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
poolAddress: pool.poolAddress,
|
|
||||||
tickLower: plan.tickLower,
|
|
||||||
tickUpper: plan.tickUpper,
|
|
||||||
priceLower: tickToPrice(plan.tickLower, pool.dec0, pool.dec1),
|
|
||||||
priceUpper: tickToPrice(plan.tickUpper, pool.dec0, pool.dec1),
|
|
||||||
amount0Desired: plan.amount0Used.toString(),
|
|
||||||
amount0DesiredHuman: formatSmallestUnits(plan.amount0Used.toString(), pool.dec0),
|
|
||||||
amount1Desired: plan.amount1Used.toString(),
|
|
||||||
amount1DesiredHuman: formatSmallestUnits(plan.amount1Used.toString(), pool.dec1),
|
|
||||||
amount0Min: plan.amount0Min.toString(),
|
|
||||||
amount0MinHuman: formatSmallestUnits(plan.amount0Min.toString(), pool.dec0),
|
|
||||||
amount1Min: plan.amount1Min.toString(),
|
|
||||||
amount1MinHuman: formatSmallestUnits(plan.amount1Min.toString(), pool.dec1),
|
|
||||||
liquidity: plan.liquidity.toString(),
|
|
||||||
aprPercent: stats.aprPercent,
|
|
||||||
appFee0: fee0.toString(),
|
|
||||||
appFee0Human: fee0Human,
|
|
||||||
appFee1: fee1.toString(),
|
|
||||||
appFee1Human: fee1Human,
|
|
||||||
appFee0Usd: fee0Usd,
|
|
||||||
appFee1Usd: fee1Usd,
|
|
||||||
appFeeTotalUsd,
|
|
||||||
appFeeTotalEth,
|
|
||||||
networkFee: {
|
|
||||||
estGasUnits: estGas.toString(),
|
|
||||||
gasPriceGwei,
|
|
||||||
amountEthHuman: networkFeeEthHuman,
|
|
||||||
amountUsd: networkFeeUsd,
|
|
||||||
},
|
|
||||||
...(balance ? { balance } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── executeAdd ─────────────────────────────────────────────────────────
|
|
||||||
export interface AddResult {
|
|
||||||
feeTxids: string[];
|
|
||||||
approveTxids: string[];
|
|
||||||
mintTxid: string;
|
|
||||||
wrapTxid?: string;
|
|
||||||
tickLower: number;
|
|
||||||
tickUpper: number;
|
|
||||||
appFee0: string;
|
|
||||||
appFee0Human: string;
|
|
||||||
appFee1: string;
|
|
||||||
appFee1Human: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function executeAddLiquidity(p: {
|
|
||||||
mnemonic: string;
|
|
||||||
expectedFromAddress: string;
|
|
||||||
poolAddress: string;
|
|
||||||
priceLower: number;
|
|
||||||
priceUpper: number;
|
|
||||||
amount0Desired: string;
|
|
||||||
amount1Desired: string;
|
|
||||||
slippageBps?: number;
|
|
||||||
useNativeEth?: boolean;
|
|
||||||
}): Promise<AddResult> {
|
|
||||||
if (!isCuratedPool(p.poolAddress)) throw badRequest('pool not in curated list');
|
|
||||||
const a0 = parseAmountSmallest(p.amount0Desired, 'amount0Desired');
|
|
||||||
const a1 = parseAmountSmallest(p.amount1Desired, 'amount1Desired');
|
|
||||||
if (a0 === 0n && a1 === 0n) {
|
|
||||||
throw badRequest('at least one of amount0Desired/amount1Desired must be > 0');
|
|
||||||
}
|
|
||||||
resolveSlippageBps(p.slippageBps); // validate 0..5000 ДО любых broadcast
|
|
||||||
|
|
||||||
const provider = await getProvider();
|
|
||||||
const pool = await readPool(provider, p.poolAddress);
|
|
||||||
const nfpm = uniswapNfpmAddress();
|
|
||||||
|
|
||||||
// ── Pre-flight: баланс ОБОИХ ног ДО любых транзакций ──
|
|
||||||
// Иначе при нехватке второго токена wrap+комиссия проходили, флоу рвался на полпути, юзер оставался
|
|
||||||
// с WETH + потерянной комиссией. Теперь нехватка → throw ДО любого broadcast, НИЧЕГО не движется.
|
|
||||||
const t0IsWeth = isWeth(pool.token0);
|
|
||||||
const t1IsWeth = isWeth(pool.token1);
|
|
||||||
if (a0 > 0n && !(p.useNativeEth && t0IsWeth)) {
|
|
||||||
const bal = await readErc20Balance('ETH', pool.token0, p.expectedFromAddress);
|
|
||||||
if (bal < a0) {
|
|
||||||
throw badRequest(`Недостаточно ${pool.sym0}: нужно ${formatSmallestUnits(a0.toString(), pool.dec0)}, есть ${formatSmallestUnits(bal.toString(), pool.dec0)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (a1 > 0n && !(p.useNativeEth && t1IsWeth)) {
|
|
||||||
const bal = await readErc20Balance('ETH', pool.token1, p.expectedFromAddress);
|
|
||||||
if (bal < a1) {
|
|
||||||
throw badRequest(`Недостаточно ${pool.sym1}: нужно ${formatSmallestUnits(a1.toString(), pool.dec1)}, есть ${formatSmallestUnits(bal.toString(), pool.dec1)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// WETH-нога при useNativeEth: заворачиваем ТОЛЬКО недостающее (учитываем уже имеющийся WETH от прошлых
|
|
||||||
// попыток — не плодим WETH и не требуем лишний ETH). wethDeficit переиспользуется в шаге wrap ниже.
|
|
||||||
let wethDeficit = 0n;
|
|
||||||
if (p.useNativeEth) {
|
|
||||||
const wrapAmt = t0IsWeth ? a0 : t1IsWeth ? a1 : 0n;
|
|
||||||
const wethLegAddr = t0IsWeth ? pool.token0 : t1IsWeth ? pool.token1 : '';
|
|
||||||
if (wrapAmt > 0n && wethLegAddr) {
|
|
||||||
let wethBal = 0n;
|
|
||||||
try { wethBal = await readErc20Balance('ETH', wethLegAddr, p.expectedFromAddress); } catch { /* assume 0 */ }
|
|
||||||
wethDeficit = wethBal >= wrapAmt ? 0n : wrapAmt - wethBal;
|
|
||||||
const ethBal = await readEvmNativeBalance('ETH', p.expectedFromAddress);
|
|
||||||
const need = wethDeficit + ethers.utils.parseEther('0.001').toBigInt(); // недостающий WETH + буфер на газ
|
|
||||||
if (ethBal < need) {
|
|
||||||
throw badRequest(`Недостаточно ETH: нужно ~${formatSmallestUnits(need.toString(), 18)} (wrap недостающего WETH + газ), есть ${formatSmallestUnits(ethBal.toString(), 18)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// useNativeEth: заворачиваем ТОЛЬКО недостающий WETH (wethDeficit). Если WETH уже хватает — пропускаем
|
|
||||||
// (используем имеющийся). Дальше WETH = обычный ERC20: fee/approve/mint без изменений.
|
|
||||||
let wrapTxid: string | undefined;
|
|
||||||
if (p.useNativeEth && wethDeficit > 0n) {
|
|
||||||
const w = await signAndBroadcastWrapEth({
|
|
||||||
mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, amountWei: wethDeficit.toString(),
|
|
||||||
});
|
|
||||||
wrapTxid = w.txid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// app fee 0.7% по каждому токену (net = то что реально пойдёт в пул)
|
|
||||||
const fee0 = a0 > 0n ? computeAppFee(p.amount0Desired) : 0n;
|
|
||||||
const fee1 = a1 > 0n ? computeAppFee(p.amount1Desired) : 0n;
|
|
||||||
const net0 = a0 - fee0;
|
|
||||||
const net1 = a1 - fee1;
|
|
||||||
|
|
||||||
// Порядок approve → fee → mint. approve-first минимизирует blast radius: если approve упадёт —
|
|
||||||
// fee ещё НЕ списан (ничего не потеряно). Если fee упадёт после approve — allowance безвреден.
|
|
||||||
// Только падение mint после fee теряет fee (неизбежно без on-chain escrow).
|
|
||||||
const approveTxids: string[] = [];
|
|
||||||
try {
|
|
||||||
if (net0 > 0n) {
|
|
||||||
const r = await signAndBroadcastEvmApprove({
|
|
||||||
chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress,
|
|
||||||
spender: nfpm, token: pool.token0, amount: net0.toString(),
|
|
||||||
});
|
|
||||||
approveTxids.push(r.txid);
|
|
||||||
}
|
|
||||||
if (net1 > 0n) {
|
|
||||||
const r = await signAndBroadcastEvmApprove({
|
|
||||||
chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress,
|
|
||||||
spender: nfpm, token: pool.token1, amount: net1.toString(),
|
|
||||||
});
|
|
||||||
approveTxids.push(r.txid);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
throw new StakingError(`approve failed before fee — nothing charged: ${err?.message || err}`, 'APPROVE_FAILED', 502);
|
|
||||||
}
|
|
||||||
|
|
||||||
const feeTxids: string[] = [];
|
|
||||||
if (fee0 > 0n) {
|
|
||||||
const r = await signAndBroadcastEvmFeeTx({
|
|
||||||
chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress,
|
|
||||||
bridgeAmount: p.amount0Desired, bridgeToken: pool.token0, feeTier: 'normal',
|
|
||||||
});
|
|
||||||
feeTxids.push(r.feeTxid);
|
|
||||||
}
|
|
||||||
if (fee1 > 0n) {
|
|
||||||
const r = await signAndBroadcastEvmFeeTx({
|
|
||||||
chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress,
|
|
||||||
bridgeAmount: p.amount1Desired, bridgeToken: pool.token1, feeTier: 'normal',
|
|
||||||
});
|
|
||||||
feeTxids.push(r.feeTxid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// план mint на net-суммах
|
|
||||||
const plan = await computeAddPlan(provider, {
|
|
||||||
poolAddress: p.poolAddress,
|
|
||||||
priceLower: p.priceLower,
|
|
||||||
priceUpper: p.priceUpper,
|
|
||||||
amount0Desired: net0.toString(),
|
|
||||||
amount1Desired: net1.toString(),
|
|
||||||
slippageBps: p.slippageBps,
|
|
||||||
});
|
|
||||||
|
|
||||||
const deadline = Math.floor(Date.now() / 1000) + 1200;
|
|
||||||
const iface = new ethers.utils.Interface(NFPM_ABI);
|
|
||||||
const data = iface.encodeFunctionData('mint', [{
|
|
||||||
token0: pool.token0,
|
|
||||||
token1: pool.token1,
|
|
||||||
fee: pool.fee,
|
|
||||||
tickLower: plan.tickLower,
|
|
||||||
tickUpper: plan.tickUpper,
|
|
||||||
amount0Desired: net0.toString(),
|
|
||||||
amount1Desired: net1.toString(),
|
|
||||||
amount0Min: plan.amount0Min.toString(),
|
|
||||||
amount1Min: plan.amount1Min.toString(),
|
|
||||||
recipient: p.expectedFromAddress,
|
|
||||||
deadline,
|
|
||||||
}]);
|
|
||||||
|
|
||||||
assertDefiEvmTarget(nfpm);
|
|
||||||
let mint: { txid: string };
|
|
||||||
try {
|
|
||||||
mint = await signAndBroadcastRawEvm({
|
|
||||||
chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, feeTier: 'normal',
|
|
||||||
tx: { to: nfpm, data, value: '0', chainId: 1, gas: MINT_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' },
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
throw new StakingError(
|
|
||||||
`mint failed after fee charged (feeTxids: ${feeTxids.join(',') || 'none'}): ${err?.message || err}`,
|
|
||||||
'MINT_FAILED', 502,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
feeTxids,
|
|
||||||
approveTxids,
|
|
||||||
mintTxid: mint.txid,
|
|
||||||
wrapTxid,
|
|
||||||
tickLower: plan.tickLower,
|
|
||||||
tickUpper: plan.tickUpper,
|
|
||||||
appFee0: fee0.toString(),
|
|
||||||
appFee0Human: formatSmallestUnits(fee0.toString(), pool.dec0),
|
|
||||||
appFee1: fee1.toString(),
|
|
||||||
appFee1Human: formatSmallestUnits(fee1.toString(), pool.dec1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── executeRemove ──────────────────────────────────────────────────────
|
|
||||||
export interface RemoveResult {
|
|
||||||
removeTxid: string;
|
|
||||||
removedLiquidity: string;
|
|
||||||
burned: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function executeRemoveLiquidity(p: {
|
|
||||||
mnemonic: string;
|
|
||||||
expectedFromAddress: string;
|
|
||||||
tokenId: string;
|
|
||||||
percent: number;
|
|
||||||
}): Promise<RemoveResult> {
|
|
||||||
if (!/^\d+$/.test(p.tokenId)) throw badRequest('tokenId must be a positive integer string');
|
|
||||||
if (!Number.isFinite(p.percent) || p.percent <= 0 || p.percent > 100) {
|
|
||||||
throw badRequest('percent must be in (0, 100]');
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = await getProvider();
|
|
||||||
const nfpm = uniswapNfpmAddress();
|
|
||||||
const c = new ethers.Contract(nfpm, NFPM_ABI, provider);
|
|
||||||
|
|
||||||
// ownership guard
|
|
||||||
const owner: string = await c.ownerOf(p.tokenId);
|
|
||||||
if (owner.toLowerCase() !== p.expectedFromAddress.toLowerCase()) {
|
|
||||||
throw badRequest('position not owned by user');
|
|
||||||
}
|
|
||||||
const pos: any = await c.positions(p.tokenId);
|
|
||||||
const liquidity = BigInt(pos.liquidity.toString());
|
|
||||||
if (liquidity <= 0n) throw badRequest('position has no liquidity');
|
|
||||||
|
|
||||||
const removeLiq = (liquidity * BigInt(Math.round(p.percent))) / 100n;
|
|
||||||
if (removeLiq <= 0n) throw badRequest('computed removal liquidity is zero');
|
|
||||||
|
|
||||||
const deadline = Math.floor(Date.now() / 1000) + 1200;
|
|
||||||
const iface = new ethers.utils.Interface(NFPM_ABI);
|
|
||||||
const calls: string[] = [];
|
|
||||||
calls.push(iface.encodeFunctionData('decreaseLiquidity', [{
|
|
||||||
tokenId: p.tokenId,
|
|
||||||
liquidity: removeLiq.toString(),
|
|
||||||
amount0Min: '0',
|
|
||||||
amount1Min: '0',
|
|
||||||
deadline,
|
|
||||||
}]));
|
|
||||||
// collect тело + накопленные комиссии юзеру
|
|
||||||
calls.push(iface.encodeFunctionData('collect', [{
|
|
||||||
tokenId: p.tokenId,
|
|
||||||
recipient: p.expectedFromAddress,
|
|
||||||
amount0Max: MAX_UINT128,
|
|
||||||
amount1Max: MAX_UINT128,
|
|
||||||
}]));
|
|
||||||
const burned = p.percent >= 100;
|
|
||||||
if (burned) {
|
|
||||||
calls.push(iface.encodeFunctionData('burn', [p.tokenId]));
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = iface.encodeFunctionData('multicall', [calls]);
|
|
||||||
assertDefiEvmTarget(nfpm);
|
|
||||||
const res = await signAndBroadcastRawEvm({
|
|
||||||
chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, feeTier: 'normal',
|
|
||||||
tx: { to: nfpm, data, value: '0', chainId: 1, gas: REMOVE_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { removeTxid: res.txid, removedLiquidity: removeLiq.toString(), burned };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── readPositions ──────────────────────────────────────────────────────
|
|
||||||
export interface LpPosition {
|
|
||||||
tokenId: string;
|
|
||||||
poolAddress: string;
|
|
||||||
symbol0: string;
|
|
||||||
symbol1: string;
|
|
||||||
feeTier: number;
|
|
||||||
tickLower: number;
|
|
||||||
tickUpper: number;
|
|
||||||
priceLower: number;
|
|
||||||
priceUpper: number;
|
|
||||||
liquidity: string;
|
|
||||||
currentAmount0: string;
|
|
||||||
currentAmount0Human: string;
|
|
||||||
currentAmount1: string;
|
|
||||||
currentAmount1Human: string;
|
|
||||||
feesOwed0: string;
|
|
||||||
feesOwed0Human: string;
|
|
||||||
feesOwed1: string;
|
|
||||||
feesOwed1Human: string;
|
|
||||||
inRange: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readLpPositions(
|
|
||||||
address: string,
|
|
||||||
): Promise<{ chain: 'ETH'; positions: LpPosition[]; totalNfts: number; scanned: number; truncated: boolean }> {
|
|
||||||
const provider = await getProvider();
|
|
||||||
const nfpm = uniswapNfpmAddress();
|
|
||||||
const c = new ethers.Contract(nfpm, NFPM_ABI, provider);
|
|
||||||
|
|
||||||
// карта куриных пулов по ключу token0|token1|fee
|
|
||||||
const curated = await Promise.all(uniswapLpPools().map((a) => readPool(provider, a).catch(() => null)));
|
|
||||||
const byKey = new Map<string, PoolData>();
|
|
||||||
for (const cp of curated) {
|
|
||||||
if (cp) byKey.set(`${cp.token0.toLowerCase()}|${cp.token1.toLowerCase()}|${cp.fee}`, cp);
|
|
||||||
}
|
|
||||||
|
|
||||||
const balBn: ethers.BigNumber = await c.balanceOf(address);
|
|
||||||
const totalNfts = Number(balBn.toString());
|
|
||||||
// ERC721Enumerable не гарантирует стабильный порядок; при > cap часть позиций не сканируется.
|
|
||||||
const bal = Math.min(totalNfts, POSITIONS_SCAN_CAP);
|
|
||||||
|
|
||||||
const positions: LpPosition[] = [];
|
|
||||||
for (let i = 0; i < bal; i++) {
|
|
||||||
let tokenId: ethers.BigNumber;
|
|
||||||
try {
|
|
||||||
tokenId = await c.tokenOfOwnerByIndex(address, i);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let pos: any;
|
|
||||||
try {
|
|
||||||
pos = await c.positions(tokenId);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const key = `${String(pos.token0).toLowerCase()}|${String(pos.token1).toLowerCase()}|${Number(pos.fee)}`;
|
|
||||||
const pool = byKey.get(key);
|
|
||||||
if (!pool) continue; // не наш куриный пул
|
|
||||||
|
|
||||||
const tickLower = Number(pos.tickLower);
|
|
||||||
const tickUpper = Number(pos.tickUpper);
|
|
||||||
const liquidity = BigInt(pos.liquidity.toString());
|
|
||||||
const sqrtA = getSqrtRatioAtTick(tickLower);
|
|
||||||
const sqrtB = getSqrtRatioAtTick(tickUpper);
|
|
||||||
const amounts = getAmountsForLiquidity(pool.sqrtPriceX96, sqrtA, sqrtB, liquidity);
|
|
||||||
|
|
||||||
positions.push({
|
|
||||||
tokenId: tokenId.toString(),
|
|
||||||
poolAddress: pool.poolAddress,
|
|
||||||
symbol0: pool.sym0,
|
|
||||||
symbol1: pool.sym1,
|
|
||||||
feeTier: pool.fee,
|
|
||||||
tickLower,
|
|
||||||
tickUpper,
|
|
||||||
priceLower: tickToPrice(tickLower, pool.dec0, pool.dec1),
|
|
||||||
priceUpper: tickToPrice(tickUpper, pool.dec0, pool.dec1),
|
|
||||||
liquidity: liquidity.toString(),
|
|
||||||
currentAmount0: amounts.amount0.toString(),
|
|
||||||
currentAmount0Human: formatSmallestUnits(amounts.amount0.toString(), pool.dec0),
|
|
||||||
currentAmount1: amounts.amount1.toString(),
|
|
||||||
currentAmount1Human: formatSmallestUnits(amounts.amount1.toString(), pool.dec1),
|
|
||||||
feesOwed0: pos.tokensOwed0.toString(),
|
|
||||||
feesOwed0Human: formatSmallestUnits(pos.tokensOwed0.toString(), pool.dec0),
|
|
||||||
feesOwed1: pos.tokensOwed1.toString(),
|
|
||||||
feesOwed1Human: formatSmallestUnits(pos.tokensOwed1.toString(), pool.dec1),
|
|
||||||
inRange: pool.tick >= tickLower && pool.tick < tickUpper,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { chain: 'ETH', positions, totalNfts, scanned: bal, truncated: totalNfts > POSITIONS_SCAN_CAP };
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* Wallet generation: BIP39 mnemonic + multi-chain address derivation.
|
|
||||||
* Server-side для custodial-флоу.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import '../lib/btc-ecc'; // initEccLib(ecc) до любых BTC-операций — нужно для выхода на taproot fee-адрес (bc1p)
|
|
||||||
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,934 +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 '../lib/btc-ecc'; // initEccLib(ecc) до любых BTC-операций — нужно для выхода на taproot fee-адрес (bc1p)
|
|
||||||
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';
|
|
||||||
import { WETH_ADDRESS } from '../lib/defi-contracts';
|
|
||||||
|
|
||||||
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). Считаем один раз на все tx.
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Отправить один approve(spender, value), дождаться 1 conf. Nonce берётся 'pending' перед КАЖДОЙ tx
|
|
||||||
// (после reset предыдущая уже подтверждена → следующая получит инкремент).
|
|
||||||
const sendOne = async (value: ethers.BigNumber): Promise<string> => {
|
|
||||||
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
|
||||||
const data = token.interface.encodeFunctionData('approve', [p.spender, value]);
|
|
||||||
const sent = await signer.sendTransaction({
|
|
||||||
to: p.token, data, value: 0, chainId: expectedChainId, nonce,
|
|
||||||
gasLimit: APPROVE_GAS_LIMIT, type: 2, maxFeePerGas, maxPriorityFeePerGas,
|
|
||||||
});
|
|
||||||
await Promise.race([
|
|
||||||
sent.wait(1),
|
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error('approve confirm timeout')), 60_000)),
|
|
||||||
]);
|
|
||||||
return sent.hash;
|
|
||||||
};
|
|
||||||
|
|
||||||
const needed = ethers.BigNumber.from(p.amount);
|
|
||||||
|
|
||||||
// Текущий allowance — для idempotency + USDT-safe reset. (amount '0'/revoke ниже не попадает в эти ветки.)
|
|
||||||
let current = ethers.BigNumber.from(0);
|
|
||||||
try {
|
|
||||||
current = await token.allowance(wallet.address, p.spender);
|
|
||||||
} catch (err: any) {
|
|
||||||
logger.warn(`EVM approve: allowance read failed (assume 0): ${err?.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Уже достаточно → не шлём лишнюю tx (повторы, и WETH-нога с остаточным allowance).
|
|
||||||
if (needed.gt(0) && current.gte(needed)) {
|
|
||||||
logger.info(`EVM approve skip: allowance ${current.toString()} >= needed ${needed.toString()} (token=${p.token} spender=${p.spender})`);
|
|
||||||
return { txid: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// USDT-style: контракт реверит approve(ненулевое) пока allowance != 0 → сначала reset в 0, потом ставим нужное.
|
|
||||||
if (needed.gt(0) && current.gt(0)) {
|
|
||||||
logger.info(`EVM approve reset-to-0 (USDT-safe): ${current.toString()} → 0 → ${needed.toString()} (token=${p.token} spender=${p.spender})`);
|
|
||||||
await sendOne(ethers.constants.Zero);
|
|
||||||
}
|
|
||||||
|
|
||||||
const txid = await sendOne(needed);
|
|
||||||
logger.info(`EVM approve confirmed: chain=${p.chain} token=${p.token} spender=${p.spender} amount=${p.amount} txid=${txid}`);
|
|
||||||
return { txid };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap native ETH → WETH (WETH.deposit{value}). Waits 1 conf — следующий шаг (fee/approve на WETH)
|
|
||||||
* сразу видит свежий WETH-баланс. ETH-only. Используется в LP-депозите для пулов с WETH-ногой.
|
|
||||||
*/
|
|
||||||
export async function signAndBroadcastWrapEth(p: {
|
|
||||||
mnemonic: string;
|
|
||||||
expectedFromAddress: string;
|
|
||||||
amountWei: string;
|
|
||||||
}): Promise<{ txid: string }> {
|
|
||||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
|
|
||||||
if (wallet.address.toLowerCase() !== p.expectedFromAddress.toLowerCase()) {
|
|
||||||
throw new Error(`Derived ETH address ${wallet.address} ≠ stored ${p.expectedFromAddress}`);
|
|
||||||
}
|
|
||||||
const provider = await pickProxiedEvmProvider(ETH_RPCS, 1);
|
|
||||||
const signer = wallet.connect(provider);
|
|
||||||
|
|
||||||
const amount = ethers.BigNumber.from(p.amountWei);
|
|
||||||
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 gasLimit = ethers.BigNumber.from(60_000); // WETH.deposit ~27-45k
|
|
||||||
const bal = await provider.getBalance(wallet.address);
|
|
||||||
if (bal.lt(amount.add(maxFeePerGas.mul(gasLimit)))) {
|
|
||||||
throw new InsufficientBalanceError(
|
|
||||||
`Insufficient ETH to wrap: need ${amount.toString()} wei + gas, have ${bal.toString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
|
||||||
const data = new ethers.utils.Interface(['function deposit() payable']).encodeFunctionData('deposit', []);
|
|
||||||
const sent = await signer.sendTransaction({
|
|
||||||
to: ethers.utils.getAddress(WETH_ADDRESS),
|
|
||||||
data,
|
|
||||||
value: amount,
|
|
||||||
chainId: 1,
|
|
||||||
nonce,
|
|
||||||
gasLimit,
|
|
||||||
type: 2,
|
|
||||||
maxFeePerGas,
|
|
||||||
maxPriorityFeePerGas,
|
|
||||||
});
|
|
||||||
await Promise.race([
|
|
||||||
sent.wait(1),
|
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error('wrap confirm timeout')), 60_000)),
|
|
||||||
]);
|
|
||||||
logger.info(`ETH→WETH wrap confirmed: amount=${p.amountWei} 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:
|
services:
|
||||||
keydb:
|
postgres:
|
||||||
image: eqalpha/keydb
|
image: postgres:16-alpine
|
||||||
container_name: cryptowallet-keydb
|
container_name: cryptowallet-db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
expose:
|
environment:
|
||||||
- "6379"
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-cryptowallet_v2}
|
||||||
volumes:
|
volumes:
|
||||||
- keydb_data:/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
command:
|
# Наружу НЕ экспозим — только docker network
|
||||||
- keydb-server
|
|
||||||
- --requirepass
|
|
||||||
- ${REDIS_PASSWORD}
|
|
||||||
- --dir
|
|
||||||
- /data
|
|
||||||
- --appendonly
|
|
||||||
- "yes"
|
|
||||||
- --appendfsync
|
|
||||||
- everysec
|
|
||||||
- --save
|
|
||||||
- "900"
|
|
||||||
- "1"
|
|
||||||
- --save
|
|
||||||
- "300"
|
|
||||||
- "10"
|
|
||||||
- --save
|
|
||||||
- "60"
|
|
||||||
- "10000"
|
|
||||||
healthcheck:
|
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
|
interval: 5s
|
||||||
timeout: 2s
|
timeout: 5s
|
||||||
retries: 20
|
retries: 10
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
image: cryptowallet-api:latest
|
||||||
container_name: cryptowallet-api
|
container_name: cryptowallet-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
|
||||||
keydb:
|
|
||||||
condition: service_healthy
|
|
||||||
# Production: port открыт на all interfaces. TLS/WAF обязательно на reverse proxy.
|
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
# Override внутри docker network
|
||||||
|
DB_HOST: postgres
|
||||||
API_PORT: "3001"
|
API_PORT: "3001"
|
||||||
# Container hardening — post-RCE blast radius minimization.
|
NODE_ENV: production
|
||||||
read_only: true
|
depends_on:
|
||||||
tmpfs:
|
postgres:
|
||||||
- /tmp
|
condition: service_healthy
|
||||||
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
|
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
@@ -72,4 +47,5 @@ services:
|
|||||||
max-file: "5"
|
max-file: "5"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
keydb_data:
|
pgdata:
|
||||||
|
driver: local
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"turbo": "^2.4.0"
|
"turbo": "^2.4.0"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": ["tiny-secp256k1"],
|
"onlyBuiltDependencies": ["bcrypt"],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"ethers": "5.7.2"
|
"ethers": "5.7.2"
|
||||||
}
|
}
|
||||||
|
|||||||
1105
pnpm-lock.yaml
generated
1105
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
66
start.sh
66
start.sh
@@ -3,54 +3,52 @@ set -euo pipefail
|
|||||||
|
|
||||||
cd "$(dirname "$0")"
|
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; }
|
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 ]; then
|
||||||
if [ -f .env.example ]; then
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
chmod 600 .env
|
echo "[INFO] Created .env from .env.example"
|
||||||
echo "[INFO] .env создан из примера (mode 600) — заполни Vault креды и запусти снова"
|
echo "[INFO] Fill in VAULT_ROLE_ID, VAULT_SECRET_ID and re-run."
|
||||||
exit 1
|
exit 1
|
||||||
else
|
|
||||||
echo "[ERROR] нет ни .env, ни .env.example"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Защита: .env должен быть 600 (только владелец) — содержит Vault role/secret IDs.
|
# 3. Build & start
|
||||||
ENV_MODE=$(stat -c %a .env 2>/dev/null || stat -f %A .env 2>/dev/null)
|
echo "[INFO] Building image..."
|
||||||
if [ "$ENV_MODE" != "600" ]; then
|
docker compose build --pull api
|
||||||
echo "[WARN] .env mode is $ENV_MODE, enforcing 600"
|
|
||||||
chmod 600 .env
|
|
||||||
fi
|
|
||||||
|
|
||||||
# NOTE: logs/ директория НЕ нужна — audit-логи теперь в stdout (Docker logs).
|
echo "[INFO] Starting services..."
|
||||||
# Контейнер работает с read_only: true (см. docker-compose.yml).
|
docker compose up -d
|
||||||
|
|
||||||
# Не используйте `docker compose down -v` — удалит keydb_data (кэш/idempotency).
|
# 4. Wait for health
|
||||||
# Не пересоздавайте keydb без бэкапа. Обновление кода: `docker compose build api && docker compose up -d api`.
|
echo "[INFO] Waiting for API to be healthy..."
|
||||||
echo "[INFO] Building and starting containers..."
|
for i in $(seq 1 60); do
|
||||||
docker compose up -d --build
|
if curl -sf http://localhost:3001/api/health >/dev/null 2>&1; then
|
||||||
|
echo "[OK] API healthy"
|
||||||
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"
|
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
if [ "$i" = "30" ]; then
|
if [ "$i" = "60" ]; then
|
||||||
echo "[ERROR] API not healthy after 60s. Запусти 'docker compose logs --tail=50 api' для диагностики."
|
echo "[ERROR] API did not become healthy in 120s"
|
||||||
|
docker compose logs --tail=50 api
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "API (loopback only): http://127.0.0.1:3001"
|
echo "=========================================="
|
||||||
echo " Перед публичным доступом → настрой reverse proxy (Caddy/Nginx) с TLS."
|
echo " Deploy complete"
|
||||||
echo "Health: http://127.0.0.1:3001/api/health"
|
echo ""
|
||||||
echo "Docs: http://127.0.0.1:3001/api/docs"
|
echo " API: http://localhost:3001"
|
||||||
echo "Logs: docker compose logs -f api"
|
echo " Health: http://localhost:3001/api/health"
|
||||||
echo "Audit events: docker compose logs api | grep '\"level\":\"audit\"'"
|
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