feat: security audit fixes
This commit is contained in:
@@ -8,3 +8,5 @@
|
|||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
**/.vscode
|
**/.vscode
|
||||||
**/.idea
|
**/.idea
|
||||||
|
pastdeploy/
|
||||||
|
.seed-backup/
|
||||||
|
|||||||
12
.env.example
12
.env.example
@@ -8,17 +8,19 @@ VAULT_JWT_KID_PATH=jwt/kid
|
|||||||
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
||||||
|
|
||||||
# CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF)
|
# CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF)
|
||||||
VAULT_CSRF_PATH=
|
VAULT_CSRF_PATH=csrf
|
||||||
|
|
||||||
# Crypto master-key для шифрования мнемоник юзеров (AES-256-GCM).
|
# Crypto master-key для шифрования мнемоник юзеров (AES-256-GCM).
|
||||||
# В Vault лежит hex-строка длиной 64 (32 байта).
|
# В Vault лежит hex-строка длиной 64 (32 байта).
|
||||||
# Положить: vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32)
|
# Положить: vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32)
|
||||||
VAULT_CRYPTO_KEY_PATH=crypto/master
|
VAULT_CRYPTO_KEY_PATH=crypto/master
|
||||||
|
|
||||||
# ── JWT ────────────────────────────────────────────────────────────
|
# ── JWT (внешний bitok issuer) ─────────────────────────────────────
|
||||||
# Allowed: RS256 / RS384 / RS512 / ES256 / ES384 / ES512 / EdDSA / PS256 / PS384 / PS512
|
# 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=auth-service
|
JWT_ISSUER=bitok
|
||||||
JWT_AUDIENCE=elcsa
|
JWT_AUDIENCE=elcsa
|
||||||
|
|
||||||
# ── Server ─────────────────────────────────────────────────────────
|
# ── Server ─────────────────────────────────────────────────────────
|
||||||
@@ -31,7 +33,7 @@ LOG_LEVEL=INFO
|
|||||||
CORS_ORIGINS=
|
CORS_ORIGINS=
|
||||||
CORS_ALLOW_CREDENTIALS=true
|
CORS_ALLOW_CREDENTIALS=true
|
||||||
|
|
||||||
# ── External API keys (optional, fallback if Vault doesn't provide) ─
|
# ── External API keys (optional, fallback если Vault их не выдаёт) ─
|
||||||
RELAY_API_KEY=
|
RELAY_API_KEY=
|
||||||
TRON_API_KEY=
|
TRON_API_KEY=
|
||||||
JUPITER_API_KEY=
|
JUPITER_API_KEY=
|
||||||
|
|||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
dist/
|
||||||
|
**/dist/
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
11
Dockerfile
11
Dockerfile
@@ -1,24 +1,33 @@
|
|||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# Production Dockerfile — multi-stage build для shippable image.
|
||||||
|
# Финальный image: только compiled dist + prod deps + tini, runs as uid 1001.
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
FROM node:20-alpine AS base
|
FROM node:20-alpine AS base
|
||||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate \
|
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate \
|
||||||
&& apk add --no-cache python3 make g++
|
&& apk add --no-cache python3 make g++
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ── Stage 1: install ALL deps (incl. devDeps) для 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 pnpm install --frozen-lockfile --prod=false
|
||||||
|
|
||||||
|
# ── Stage 2: TypeScript compile ──
|
||||||
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 . .
|
||||||
RUN cd apps/api && pnpm build
|
RUN cd apps/api && pnpm build
|
||||||
|
|
||||||
|
# ── Stage 3: prod-only deps (без devDeps, меньше 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 pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
|
# ── Stage 4: runtime image — minimal surface ──
|
||||||
FROM node:20-alpine AS runtime
|
FROM node:20-alpine AS runtime
|
||||||
RUN apk add --no-cache tini wget \
|
RUN apk add --no-cache tini wget \
|
||||||
&& addgroup -S app -g 1001 \
|
&& addgroup -S app -g 1001 \
|
||||||
@@ -32,8 +41,6 @@ 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
|
||||||
|
|
||||||
RUN mkdir -p /app/logs && chown -R app:app /app/logs
|
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
||||||
|
|||||||
133
README.md
133
README.md
@@ -1,67 +1,114 @@
|
|||||||
# CryptoWallet API — Deployment Bundle (v5.0 custodial)
|
# CryptoWallet API — Deployment Bundle
|
||||||
|
|
||||||
Multi-chain **custodial** wallet API (ETH / BSC / BTC / TRX / SOL).
|
Multi-chain **custodial** wallet API (ETH / BSC / BTC / TRX / SOL).
|
||||||
- Сервер генерит mnemonic, хранит зашифрованной (AES-256-GCM, master-key из HashiCorp Vault)
|
- Сервер генерит mnemonic, хранит зашифрованной (AES-256-GCM, master-key из HashiCorp Vault)
|
||||||
- Сервер сам подписывает tx по запросу юзера (юзер на клиенте жмёт "подтвердить")
|
- Сервер сам подписывает tx по запросу юзера (юзер на клиенте жмёт "подтвердить")
|
||||||
|
|
||||||
Auth — JWT (BITOK), секреты — HashiCorp Vault (AppRole).
|
Auth — JWT, выданный сервисом **bitok** (внешний). Секреты — HashiCorp Vault (AppRole).
|
||||||
|
|
||||||
## Pre-deploy setup (один раз навсегда)
|
## Pre-deploy setup (один раз)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Master-key в Vault
|
# 1. Master-key в Vault
|
||||||
vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32)
|
vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32)
|
||||||
|
|
||||||
# 2. DB schema
|
# 2. CSRF secret в Vault
|
||||||
psql -h 72.56.9.76 -U postgres_user -d postgres -f cryptowallet-schema.sql
|
vault kv put dev-secrets/csrf secret_key=$(openssl rand -hex 32) salt=csrf-salt digest=sha256
|
||||||
|
|
||||||
|
# 3. DB schema (миграция идемпотентна — безопасно прогонять на existing БД)
|
||||||
|
psql -h <db-host> -U postgres_user -d postgres -f cryptowallet-schema.sql
|
||||||
|
|
||||||
|
# 4. bitok public key в Vault (для kid из JWT header)
|
||||||
|
vault kv put dev-secrets/jwt/kid active=<kid-from-bitok>
|
||||||
|
vault kv put dev-secrets/jwt/kids/<kid-from-bitok> \
|
||||||
|
algorithm=RS256 \
|
||||||
|
public_key="$(cat /path/to/bitok-public.pem)"
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ **Master-key менять нельзя** — все existing encrypted_mnemonic станут нерасшифровываемыми. Сервис логирует WARN если в Vault ключ изменился.
|
⚠️ **Master-key менять нельзя** — все existing `encrypted_mnemonic` станут нерасшифровываемыми. API на старте делает self-test: пытается декриптить любой существующий mnemonic и фейлится если ключ не подошёл.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scp -P 2222 -r deployserver/ server@176.124.213.102:~/cryptowallet/
|
# Залить bundle на сервер
|
||||||
ssh server@176.124.213.102 -p 2222
|
scp -P 2222 -r deployserver/ server@<host>:~/cryptowallet/
|
||||||
cd ~/cryptowallet && cp .env.example .env && nano .env && ./start.sh
|
|
||||||
|
# На сервере: заполнить .env, поднять
|
||||||
|
ssh server@<host> -p 2222
|
||||||
|
cd ~/cryptowallet
|
||||||
|
cp .env.example .env
|
||||||
|
chmod 600 .env
|
||||||
|
nano .env # заполни VAULT_*, JWT_*, CORS_ORIGINS
|
||||||
|
./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
В `.env` обязательны: `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`, `JWT_ISSUER`, `JWT_AUDIENCE`, `CORS_ORIGINS`.
|
В `.env` **обязательны**: `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`, `JWT_ISSUER=bitok`, `JWT_AUDIENCE`, `CORS_ORIGINS`.
|
||||||
|
|
||||||
## Endpoints (24)
|
|
||||||
|
|
||||||
| Method | Path | Описание |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | /api/health | Liveness (public) |
|
|
||||||
| GET | /api/docs | Swagger UI |
|
|
||||||
| POST | **/api/wallets/create** | **Сервер создаёт коша** (no body, returns addresses) |
|
|
||||||
| GET | /api/wallets | Список адресов юзера |
|
|
||||||
| POST | **/api/wallets/mnemonic/reveal** | Reveal seed (body confirm + 5/час) |
|
|
||||||
| GET | /api/wallets/{chain}/balance | Баланс |
|
|
||||||
| GET | /api/wallets/{chain}/transactions | История tx |
|
|
||||||
| POST | **/api/wallets/{chain}/send** | **Сервер подписывает + broadcast** |
|
|
||||||
| ... | /api/btc/* /api/tron/* /api/sol/* /api/bsc/* /api/relay/* | Proxy endpoints |
|
|
||||||
|
|
||||||
## Security highlights
|
|
||||||
|
|
||||||
- **AES-256-GCM** для encrypted_mnemonic (12-byte random IV, 16-byte auth tag, fail-secure)
|
|
||||||
- **Master-key set-once** (rotation запрещена)
|
|
||||||
- **Race-safe createWallet**: `db.transaction` + `UPDATE WHERE encrypted_mnemonic IS NULL`
|
|
||||||
- **TRX MITM defense**: local recompute txID + verify raw_data перед подписью
|
|
||||||
- **EVM gas cap** 500 gwei (применён к tx, не только check)
|
|
||||||
- **Address checksum validation** (BTC bitcoinjs-lib, TRX bs58check, SOL PublicKey, EVM EIP-55)
|
|
||||||
- **assertAddressMatch** — derived(mnemonic, path) === DB.address перед подписью
|
|
||||||
- **SOL confirmTransaction** — ждём подтверждения
|
|
||||||
- **BTC** P2WPKH bech32, fee fallback 15 sat/vB + 1.1x safety, dust 294, broadcast 20s timeout
|
|
||||||
- **POST mnemonic/reveal** + CSRF + body confirm token + 5/час + audit-log
|
|
||||||
- **Logger sanitization**: password/token/mnemonic/hex64/BIP39-phrase patterns
|
|
||||||
- **Audit log** `logs/audit.log` (wallet.create / wallet.send / mnemonic.reveal)
|
|
||||||
- **Hourly key rotation**: JWT keys + CSRF secret из Vault (master-key НЕ ротируется)
|
|
||||||
- **Fail-fast**: сервис не стартует без master-key, JWT_ISSUER, JWT_AUDIENCE
|
|
||||||
|
|
||||||
## Update / Rebuild
|
## Update / Rebuild
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scp -P 2222 -r deployserver/apps server@176.124.213.102:~/cryptowallet/
|
scp -P 2222 -r deployserver/apps server@<host>:~/cryptowallet/
|
||||||
ssh server@176.124.213.102 -p 2222 'cd cryptowallet && docker compose up -d --build'
|
ssh server@<host> -p 2222 'cd cryptowallet && docker compose up -d --build'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/health` | Liveness (public) |
|
||||||
|
| GET | `/api/docs` | Swagger UI |
|
||||||
|
| GET | `/api/docs/swagger.json` | OpenAPI JSON |
|
||||||
|
| POST | **`/api/wallets/create`** | Сервер создаёт коша (no body, returns 5 addresses) |
|
||||||
|
| GET | `/api/wallets` | Список адресов юзера |
|
||||||
|
| POST | **`/api/wallets/mnemonic/reveal`** | Reveal seed (body confirm + 5/час rate-limit) |
|
||||||
|
| GET | `/api/wallets/{chain}/balance` | Баланс (native + все известные токены чейна) |
|
||||||
|
| GET | `/api/wallets/{chain}/transactions` | История tx |
|
||||||
|
| POST | **`/api/wallets/{chain}/send`** | Сервер подписывает + broadcast. Body: `{to, amount, token?, feeTier?}` |
|
||||||
|
| GET | **`/api/wallets/{chain}/gas-suggestions`** | Slow/normal/fast tiers (ETH/BSC, parsed из eth_feeHistory) |
|
||||||
|
| POST | **`/api/wallets/{chain}/sign-raw-evm-tx`** | Подписать произвольную EVM tx (для Relay/Swap execute responses) |
|
||||||
|
| — | `/api/btc/*` `/api/tron/*` `/api/sol/*` `/api/bsc/*` `/api/relay/*` | Proxy endpoints (swap quote/build, bridge quote/execute/status) |
|
||||||
|
|
||||||
|
## Security highlights
|
||||||
|
|
||||||
|
- **AES-256-GCM** для encrypted_mnemonic (12-byte random IV, 16-byte auth tag, fail-secure)
|
||||||
|
- **Master-key set-once** (rotation запрещена в коде)
|
||||||
|
- **Crypto self-test на старте** — fail-fast если master-key не декриптит existing mnemonics
|
||||||
|
- **Race-safe createWallet** — `db.transaction` + `UPDATE WHERE encrypted_mnemonic IS NULL` (set-once primitive)
|
||||||
|
- **Atomic erc20 update** — ETH-адрес кладётся в `users.erc20` внутри той же транзакции
|
||||||
|
- **TRX MITM defense** — local recompute txID + 4-layer raw_data verification перед подписью
|
||||||
|
- **EVM gas cap** 500 gwei (применён к tx, не только check)
|
||||||
|
- **EVM gas oracle** через `eth_feeHistory` p25/p50/p75 — minimum-but-works fees (BSC floor 0.05, ETH 0.5 gwei)
|
||||||
|
- **BTC fee** tier-based (slow=144 blocks, normal=6, fast=1) + floor 2 sat/vB
|
||||||
|
- **TRX fee_limit** cap 30 TRX (раньше 100, излишне)
|
||||||
|
- **Address checksum validation** (BTC bitcoinjs-lib, TRX bs58check, SOL PublicKey, EVM EIP-55)
|
||||||
|
- **assertAddressMatch** — derived(mnemonic, path) === DB.address перед подписью
|
||||||
|
- **SOL confirmTransaction** — ждём подтверждения сети
|
||||||
|
- **BTC** P2WPKH bech32, dust 294, broadcast 20s timeout
|
||||||
|
- **POST mnemonic/reveal** + CSRF + body confirm token + 5/час rate-limit + audit-log
|
||||||
|
- **Logger sanitization** — password/token/mnemonic/hex64/BIP39-phrase patterns маскируются
|
||||||
|
- **Audit log в stdout** (структурированный JSON с `"level":"audit"`) — `wallet.create` / `wallet.send` / `mnemonic.reveal` / `wallet.sign_raw_evm`
|
||||||
|
- **Hourly key rotation** — JWT keys + CSRF secret из Vault (master-key НЕ ротируется)
|
||||||
|
- **Fail-fast** — сервис не стартует без master-key, JWT_ISSUER, JWT_AUDIENCE
|
||||||
|
- **Container hardening** — read-only fs, cap_drop ALL, no-new-privileges, pids/mem/cpu limits, non-root uid 1001, loopback-only port
|
||||||
|
- **Relay proxy** whitelist method+path — `/quote` (POST), `/intents/status/v3` (GET), `/execute/{swap|bridge}` (POST). Никаких freeform action'ов
|
||||||
|
- **Bridge UI dropdown** ETH/BSC/SOL only (TRX через Relay убран — плохая ликвидность)
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
Файловых логов **нет**. Всё в stdout, подбирается Docker log driver:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f api # все логи (structured JSON)
|
||||||
|
docker compose logs api | grep '"level":"audit"' # только audit events
|
||||||
|
docker compose logs api | grep '"level":"ERROR"' # только ошибки
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production hardening checklist (опционально)
|
||||||
|
|
||||||
|
- [ ] Vault server-mode (raft/file backend) с unseal flow
|
||||||
|
- [ ] TLS termination на reverse-proxy (Caddy / Nginx) перед `127.0.0.1:3001`
|
||||||
|
- [ ] Swagger UI скрыть за basic-auth (endpoints всё ещё доступны через `/api/docs/swagger.json`)
|
||||||
|
- [ ] Postgres backups (pg_dump → S3 по cron)
|
||||||
|
- [ ] Vault root token ротация
|
||||||
|
- [ ] Mnemonic-reveal endpoint — 2FA / time-based confirmation tokens
|
||||||
|
- [ ] Rate-limit tune под реальный трафик
|
||||||
|
|||||||
@@ -35,17 +35,50 @@ app.use(cookieParser());
|
|||||||
app.use(traceMiddleware);
|
app.use(traceMiddleware);
|
||||||
|
|
||||||
// ── PUBLIC endpoints ─────────────────────────────────────────────────────────
|
// ── PUBLIC endpoints ─────────────────────────────────────────────────────────
|
||||||
app.get('/api/health', (_req, res) => {
|
// H11 — /api/health with DB probe (не возвращает OK если DB down)
|
||||||
|
import { db } from './config/database';
|
||||||
|
app.get('/api/health', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
db.raw('select 1'),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('db-timeout')), 1000)),
|
||||||
|
]);
|
||||||
res.json({ success: true, data: { status: 'ok' } });
|
res.json({ success: true, data: { status: 'ok' } });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(503).json({ success: false, error: 'db_unavailable' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
// ── Глобальный rate limit на /api/* — ДО docs чтобы не было unauthenticated DoS на swagger.json
|
||||||
app.get('/api/docs/swagger.json', (_req, res) => {
|
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));
|
||||||
// ── Глобальный rate limit на весь API после public endpoints ────────────────
|
|
||||||
app.use('/api', globalLimiter);
|
|
||||||
|
|
||||||
// ── PROTECTED endpoints (JWT + CSRF) ─────────────────────────────────────────
|
// ── PROTECTED endpoints (JWT + CSRF) ─────────────────────────────────────────
|
||||||
const protect = [authMiddleware, csrfMiddleware];
|
const protect = [authMiddleware, csrfMiddleware];
|
||||||
|
|||||||
@@ -81,6 +81,20 @@ 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');
|
||||||
@@ -117,12 +131,15 @@ export async function initEnv(): Promise<void> {
|
|||||||
},
|
},
|
||||||
jwt: {
|
jwt: {
|
||||||
...env.jwt,
|
...env.jwt,
|
||||||
issuer: s('JWT_ISSUER') || env.jwt.issuer,
|
// H17 — trim whitespace; пустая строка после trim → fallback на env
|
||||||
audience: s('JWT_AUDIENCE') || env.jwt.audience,
|
issuer: (s('JWT_ISSUER')?.trim() || env.jwt.issuer),
|
||||||
|
audience: (s('JWT_AUDIENCE')?.trim() || env.jwt.audience),
|
||||||
},
|
},
|
||||||
cors: {
|
cors: {
|
||||||
origins: s('CORS_ORIGINS') ? s('CORS_ORIGINS')!.split(',') : env.cors.origins,
|
origins: s('CORS_ORIGINS') ? s('CORS_ORIGINS')!.split(',') : env.cors.origins,
|
||||||
allowCredentials: secrets['CORS_ALLOW_CREDENTIALS'] !== 'false',
|
// H5 — fail-secure consistent with line 53: explicit 'true' required, default false.
|
||||||
|
// Раньше: `!== 'false'` → defaults TRUE if field missing/empty (security inversion).
|
||||||
|
allowCredentials: secrets['CORS_ALLOW_CREDENTIALS'] === 'true',
|
||||||
},
|
},
|
||||||
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,
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import { getBalance, getTransactions } from '../services/wallet-ops.service';
|
|||||||
import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
|
import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
|
||||||
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
|
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
|
||||||
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service';
|
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service';
|
||||||
import { signAndBroadcast } from '../services/wallet-signer.service';
|
import { signAndBroadcast, signAndBroadcastRawEvm } from '../services/wallet-signer.service';
|
||||||
import { auditLog, auditLogStrict } from '../lib/audit-log';
|
import { getEvmFeeTiers, type FeeTier } from '../services/gas-oracle.service';
|
||||||
|
import { applyEvmTxPolicy } from '../lib/evm-tx-policy';
|
||||||
|
import { acquireSendLock } from '../lib/send-lock';
|
||||||
|
import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency';
|
||||||
|
import { auditLog, auditLogStrict, completeAudit } from '../lib/audit-log';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
|
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
|
||||||
@@ -83,6 +87,12 @@ export const WalletController = {
|
|||||||
})),
|
})),
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
|
// Дублируем ETH-адрес в users.erc20 — это поле прода-схемы
|
||||||
|
// (custodial wallet's ETH address, доступный через простой SELECT без JOIN'а на wallets).
|
||||||
|
const ethWallet = derived.find((w) => w.chain === 'ETH');
|
||||||
|
if (ethWallet) {
|
||||||
|
await UserModel.setErc20Address(userId, ethWallet.address, trx);
|
||||||
|
}
|
||||||
return derived;
|
return derived;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,22 +173,35 @@ export const WalletController = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mnemonic = decryptMnemonic(blob);
|
// CRITICAL operation — durable audit BEFORE decrypt (fail-secure).
|
||||||
|
// Если INSERT fails — отказываем decrypt'у.
|
||||||
// CRITICAL operation — fail-secure audit (если запись fail'ит — не отдаём mnemonic).
|
let auditId: string;
|
||||||
try {
|
try {
|
||||||
await auditLogStrict({
|
auditId = await auditLogStrict({
|
||||||
event: 'mnemonic.reveal',
|
event: 'mnemonic.reveal',
|
||||||
userId,
|
userId,
|
||||||
ip: req.ip || null,
|
ip: req.ip || null,
|
||||||
result: 'success',
|
|
||||||
});
|
});
|
||||||
} catch (auditErr: any) {
|
} catch (auditErr: any) {
|
||||||
logger.error(`Audit log MUST succeed for mnemonic.reveal: ${auditErr.message}`);
|
logger.error(`Audit DB INSERT MUST succeed for mnemonic.reveal: ${auditErr.message}`);
|
||||||
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mnemonic: string;
|
||||||
|
try {
|
||||||
|
mnemonic = decryptMnemonic(blob);
|
||||||
|
} catch (decryptErr: any) {
|
||||||
|
await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED');
|
||||||
|
throw decryptErr;
|
||||||
|
}
|
||||||
|
await completeAudit(auditId, 'success');
|
||||||
|
|
||||||
|
// H12: no caching (BFCache / proxy / SW могут leak seed)
|
||||||
|
res.set({
|
||||||
|
'Cache-Control': 'no-store, no-cache, max-age=0, must-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
});
|
||||||
res.json({ success: true, data: { mnemonic } });
|
res.json({ success: true, data: { mnemonic } });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error(`revealMnemonic failed for user ${userId}: ${err.stack || err.message}`);
|
logger.error(`revealMnemonic failed for user ${userId}: ${err.stack || err.message}`);
|
||||||
@@ -268,7 +291,7 @@ export const WalletController = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { to, amount, token } = req.body ?? {};
|
const { to, amount, token, feeTier } = req.body ?? {};
|
||||||
|
|
||||||
if (!isValidAddress(chain, String(to))) {
|
if (!isValidAddress(chain, String(to))) {
|
||||||
res.status(400).json({ success: false, error: 'Invalid recipient address for chain' });
|
res.status(400).json({ success: false, error: 'Invalid recipient address for chain' });
|
||||||
@@ -288,6 +311,33 @@ export const WalletController = {
|
|||||||
normalizedToken = token.toUpperCase();
|
normalizedToken = token.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let normalizedFeeTier: FeeTier | undefined;
|
||||||
|
if (feeTier !== undefined && feeTier !== null) {
|
||||||
|
if (feeTier !== 'slow' && feeTier !== 'normal' && feeTier !== 'fast') {
|
||||||
|
res.status(400).json({ success: false, error: 'Invalid feeTier (must be slow|normal|fast)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
normalizedFeeTier = feeTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// C3 — idempotency. Если client передал Idempotency-Key — проверяем retry.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C3 — per-user-per-chain mutex против nonce race / mempool collision
|
||||||
|
const releaseLock = await acquireSendLock(userId, chain);
|
||||||
|
|
||||||
let mnemonic: string | null = null;
|
let mnemonic: string | null = null;
|
||||||
try {
|
try {
|
||||||
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
||||||
@@ -302,37 +352,40 @@ export const WalletController = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CRITICAL — audit row BEFORE broadcast (fail-secure: если DB не примет — не подписываем).
|
||||||
|
let auditId: string;
|
||||||
|
try {
|
||||||
|
auditId = await auditLogStrict({
|
||||||
|
event: 'wallet.send',
|
||||||
|
userId,
|
||||||
|
ip: req.ip || null,
|
||||||
|
meta: { chain, hasToken: !!normalizedToken, to: String(to) },
|
||||||
|
});
|
||||||
|
} catch (auditErr: any) {
|
||||||
|
logger.error(`Audit DB INSERT MUST succeed for wallet.send: ${auditErr.message}`);
|
||||||
|
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
mnemonic = decryptMnemonic(blob);
|
mnemonic = decryptMnemonic(blob);
|
||||||
|
|
||||||
const result = await signAndBroadcast({
|
let result: { txid: string };
|
||||||
|
try {
|
||||||
|
result = await signAndBroadcast({
|
||||||
chain,
|
chain,
|
||||||
mnemonic,
|
mnemonic,
|
||||||
to: String(to),
|
to: String(to),
|
||||||
amount: String(amount),
|
amount: String(amount),
|
||||||
token: normalizedToken,
|
token: normalizedToken,
|
||||||
expectedFromAddress: wallet.address,
|
expectedFromAddress: wallet.address,
|
||||||
|
feeTier: normalizedFeeTier,
|
||||||
});
|
});
|
||||||
|
} catch (sendErr: any) {
|
||||||
// CRITICAL operation — fail-secure audit
|
await completeAudit(auditId, 'failure', undefined, 'BROADCAST_FAILED');
|
||||||
try {
|
throw sendErr;
|
||||||
await auditLogStrict({
|
|
||||||
event: 'wallet.send',
|
|
||||||
userId,
|
|
||||||
ip: req.ip || null,
|
|
||||||
result: 'success',
|
|
||||||
meta: { chain, hasToken: !!normalizedToken, txid: result.txid },
|
|
||||||
});
|
|
||||||
} catch (auditErr: any) {
|
|
||||||
logger.error(`Audit log MUST succeed for wallet.send (txid=${result.txid}): ${auditErr.message}`);
|
|
||||||
// Tx уже broadcast'нут — нельзя отменить. Возвращаем txid но с warning о audit.
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: { txid: result.txid, chain },
|
|
||||||
warning: 'Transaction broadcast succeeded but audit log write failed',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await completeAudit(auditId, 'success', { txid: result.txid });
|
||||||
res.json({ success: true, data: { txid: result.txid, chain } });
|
res.json({ success: true, data: { txid: result.txid, chain } });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error(`send failed for user ${userId} chain ${chain}: ${err.stack || err.message}`);
|
logger.error(`send failed for user ${userId} chain ${chain}: ${err.stack || err.message}`);
|
||||||
@@ -344,14 +397,210 @@ export const WalletController = {
|
|||||||
meta: { chain },
|
meta: { chain },
|
||||||
errorCode: 'BROADCAST_FAILED',
|
errorCode: 'BROADCAST_FAILED',
|
||||||
});
|
});
|
||||||
const msg = err?.message?.toLowerCase?.().includes('insufficient')
|
// Разделяем причины — иначе юзер видит generic "Insufficient balance" и думает что
|
||||||
? 'Insufficient balance'
|
// дело в самом токене, хотя на самом деле не хватает native для газа.
|
||||||
: err?.message?.toLowerCase?.().includes('not supported')
|
const lower = err?.message?.toLowerCase?.() ?? '';
|
||||||
? 'Token/chain combination not supported'
|
let msg: string;
|
||||||
: 'Failed to broadcast transaction';
|
if (lower.includes('insufficient native balance for gas')) {
|
||||||
|
msg = 'Insufficient native balance for gas (need BNB/ETH/TRX/SOL to pay tx fee, not just the token)';
|
||||||
|
} else if (lower.includes('insufficient token balance')) {
|
||||||
|
msg = 'Insufficient token balance';
|
||||||
|
} else if (lower.includes('insufficient')) {
|
||||||
|
msg = err.message; // ethers / chain-specific insufficient — pass through, чтобы юзер видел деталь
|
||||||
|
} else if (lower.includes('not supported')) {
|
||||||
|
msg = 'Token/chain combination not supported';
|
||||||
|
} else {
|
||||||
|
msg = 'Failed to broadcast transaction';
|
||||||
|
}
|
||||||
res.status(502).json({ success: false, error: msg });
|
res.status(502).json({ success: false, error: msg });
|
||||||
} finally {
|
} finally {
|
||||||
mnemonic = null;
|
mnemonic = null;
|
||||||
|
releaseLock();
|
||||||
|
// C3 — cache response для idempotency retry
|
||||||
|
if (idempKey) {
|
||||||
|
const status = res.statusCode;
|
||||||
|
// Best-effort serialize — Express's `res.json` уже flushed body.
|
||||||
|
// Для retry мы фиксируем только status. Body берётся из audit_log row.
|
||||||
|
saveIdempotencyResponse(userId, idempKey, status, JSON.stringify({ cached: true, status }))
|
||||||
|
.catch((e: any) => logger.warn(`saveIdempotencyResponse failed: ${e.message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wallets/:chain/gas-suggestions — slow/normal/fast tiers, парсятся из eth_feeHistory.
|
||||||
|
* Только ETH/BSC (другие чейны не EVM).
|
||||||
|
*/
|
||||||
|
async getGasSuggestions(req: Request, res: Response) {
|
||||||
|
const chain = String(req.params.chain).toUpperCase();
|
||||||
|
if (chain !== 'ETH' && chain !== 'BSC') {
|
||||||
|
res.status(400).json({ success: false, error: 'Gas suggestions available only for ETH/BSC' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tiers = await getEvmFeeTiers(chain);
|
||||||
|
res.json({ success: true, data: tiers });
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`getGasSuggestions ${chain} failed: ${err.stack || err.message}`);
|
||||||
|
res.status(502).json({ success: false, error: 'Upstream RPC error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/wallets/:chain/sign-raw-evm-tx — подписывает произвольную EVM-tx (для Relay/Swap unsigned tx из /execute).
|
||||||
|
* ⚠️ chain должен быть ETH или BSC. Подписывает arbitrary `to`+`data` — в production
|
||||||
|
* нужно whitelist'ить `to` или требовать Relay attestation.
|
||||||
|
*/
|
||||||
|
async signRawEvmTx(req: Request, res: Response) {
|
||||||
|
const userId = req.auth!.userId;
|
||||||
|
const chain = String(req.params.chain).toUpperCase();
|
||||||
|
|
||||||
|
if (chain !== 'ETH' && chain !== 'BSC') {
|
||||||
|
res.status(400).json({ success: false, error: 'Only ETH and BSC supported for raw EVM signing' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCryptoReady()) {
|
||||||
|
res.status(503).json({ success: false, error: 'Crypto service not ready' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { to, data, value, chainId, gas, maxFeePerGas, maxPriorityFeePerGas, feeTier } = req.body ?? {};
|
||||||
|
|
||||||
|
let normalizedFeeTier: FeeTier | undefined;
|
||||||
|
if (feeTier !== undefined && feeTier !== null) {
|
||||||
|
if (feeTier !== 'slow' && feeTier !== 'normal' && feeTier !== 'fast') {
|
||||||
|
res.status(400).json({ success: false, error: 'Invalid feeTier (must be slow|normal|fast)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
normalizedFeeTier = feeTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Базовая структурная валидация — детальные cap'ы внутри signer'а.
|
||||||
|
if (typeof to !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(to)) {
|
||||||
|
res.status(400).json({ success: false, error: 'Invalid "to" address' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof data !== 'string' || !/^0x[a-fA-F0-9]*$/.test(data)) {
|
||||||
|
res.status(400).json({ success: false, error: 'Invalid "data" (must be 0x-hex)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const numericFields = { value, chainId, gas, maxFeePerGas, maxPriorityFeePerGas };
|
||||||
|
for (const [k, v] of Object.entries(numericFields)) {
|
||||||
|
if (v === undefined || v === null || !/^\d+$/.test(String(v))) {
|
||||||
|
res.status(400).json({ success: false, error: `Invalid "${k}" (must be positive integer)` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C1, C2, H2 — sign-raw policy: `to` allowlist (Relay routers only) +
|
||||||
|
// selector blacklist (approve/permit etc.) + value/gas caps. Throws on violation.
|
||||||
|
try {
|
||||||
|
applyEvmTxPolicy({
|
||||||
|
chainId: Number(chainId),
|
||||||
|
to,
|
||||||
|
data,
|
||||||
|
value: String(value),
|
||||||
|
gas: String(gas),
|
||||||
|
maxFeePerGas: String(maxFeePerGas),
|
||||||
|
});
|
||||||
|
} catch (policyErr: any) {
|
||||||
|
res.status(400).json({ success: false, error: policyErr.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// C3 — idempotency
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C3 — per-user-per-chain mutex
|
||||||
|
const releaseLock = await acquireSendLock(userId, chain);
|
||||||
|
|
||||||
|
let mnemonic: string | null = null;
|
||||||
|
try {
|
||||||
|
const wallet = await WalletModel.findByUserAndChain(userId, chain as ChainCode);
|
||||||
|
if (!wallet) {
|
||||||
|
res.status(404).json({ success: false, error: 'Wallet not found for this chain' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await UserModel.getEncryptedMnemonic(userId);
|
||||||
|
if (!blob) {
|
||||||
|
res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL — audit row BEFORE broadcast
|
||||||
|
let auditId: string;
|
||||||
|
try {
|
||||||
|
auditId = await auditLogStrict({
|
||||||
|
event: 'wallet.sign_raw_evm',
|
||||||
|
userId,
|
||||||
|
ip: req.ip || null,
|
||||||
|
meta: { chain, to, chainId: Number(chainId) },
|
||||||
|
});
|
||||||
|
} catch (auditErr: any) {
|
||||||
|
logger.error(`Audit DB INSERT MUST succeed for wallet.sign_raw_evm: ${auditErr.message}`);
|
||||||
|
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mnemonic = decryptMnemonic(blob);
|
||||||
|
|
||||||
|
let result: { txid: string };
|
||||||
|
try {
|
||||||
|
result = await signAndBroadcastRawEvm({
|
||||||
|
chain: chain as 'ETH' | 'BSC',
|
||||||
|
mnemonic,
|
||||||
|
expectedFromAddress: wallet.address,
|
||||||
|
tx: {
|
||||||
|
to,
|
||||||
|
data,
|
||||||
|
value: String(value),
|
||||||
|
chainId: Number(chainId),
|
||||||
|
gas: String(gas),
|
||||||
|
maxFeePerGas: String(maxFeePerGas),
|
||||||
|
maxPriorityFeePerGas: String(maxPriorityFeePerGas),
|
||||||
|
},
|
||||||
|
feeTier: normalizedFeeTier,
|
||||||
|
});
|
||||||
|
} catch (signErr: any) {
|
||||||
|
await completeAudit(auditId, 'failure', undefined, 'BROADCAST_FAILED');
|
||||||
|
throw signErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
await completeAudit(auditId, 'success', { txid: result.txid });
|
||||||
|
res.json({ success: true, data: { txid: result.txid, chain } });
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`signRawEvmTx failed for user ${userId} chain ${chain}: ${err.stack || err.message}`);
|
||||||
|
await auditLog({
|
||||||
|
event: 'wallet.sign_raw_evm',
|
||||||
|
userId,
|
||||||
|
ip: req.ip || null,
|
||||||
|
result: 'failure',
|
||||||
|
meta: { chain },
|
||||||
|
errorCode: 'BROADCAST_FAILED',
|
||||||
|
});
|
||||||
|
res.status(502).json({ success: false, error: err?.message?.slice?.(0, 200) || 'Failed to sign/broadcast tx' });
|
||||||
|
} finally {
|
||||||
|
mnemonic = null;
|
||||||
|
releaseLock();
|
||||||
|
if (idempKey) {
|
||||||
|
const status = res.statusCode;
|
||||||
|
saveIdempotencyResponse(userId, idempKey, status, JSON.stringify({ cached: true, status }))
|
||||||
|
.catch((e: any) => logger.warn(`saveIdempotencyResponse failed: ${e.message}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import app from './app';
|
import app from './app';
|
||||||
import { env, initEnv } from './config/env';
|
import { env, initEnv } from './config/env';
|
||||||
import { refreshAllKeys, startKeyRotation, stopKeyRotation } from './services/key-rotation.service';
|
import { refreshAllKeys, startKeyRotation, stopKeyRotation } from './services/key-rotation.service';
|
||||||
import { isCryptoReady } from './services/crypto.service';
|
import { isCryptoReady, decryptMnemonic, encryptMnemonic } from './services/crypto.service';
|
||||||
|
import { db } from './config/database';
|
||||||
import { logger } from './lib/logger';
|
import { logger } from './lib/logger';
|
||||||
|
|
||||||
// Global error handlers — иначе unhandled errors идут в stderr без sanitization (leak secrets)
|
// Global error handlers — иначе unhandled errors идут в stderr без sanitization (leak secrets)
|
||||||
@@ -18,7 +19,11 @@ 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();
|
||||||
await refreshAllKeys();
|
const refreshResult = await refreshAllKeys();
|
||||||
|
if (!refreshResult.ok) {
|
||||||
|
logger.error(`Initial Vault refresh failed: ${refreshResult.reason}. Refusing to start.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Custodial: без master-key сервис не может расшифровать ни одну мнемонику — fail fast.
|
// Custodial: без master-key сервис не может расшифровать ни одну мнемонику — fail fast.
|
||||||
if (!isCryptoReady()) {
|
if (!isCryptoReady()) {
|
||||||
@@ -26,6 +31,11 @@ async function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Integrity self-test: если в БД уже есть encrypted_mnemonic, master-key должен их декриптить.
|
||||||
|
// Иначе мы стартовали с НЕПРАВИЛЬНЫМ ключом (например после потери Vault dev-mode state) —
|
||||||
|
// и любой /send или /sign будет тихо валиться с GCM auth error. Лучше упасть сразу.
|
||||||
|
await runCryptoIntegritySelfTest();
|
||||||
|
|
||||||
startKeyRotation();
|
startKeyRotation();
|
||||||
|
|
||||||
const server = app.listen(env.port, () => {
|
const server = app.listen(env.port, () => {
|
||||||
@@ -44,6 +54,59 @@ async function main() {
|
|||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
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) => {
|
||||||
logger.error(`Failed to start: ${err.message}`);
|
logger.error(`Failed to start: ${err.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,35 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Audit log — append-only JSON lines в `logs/audit.log`.
|
* Audit log — durable durable durable.
|
||||||
* Используется для критических custodial операций.
|
*
|
||||||
|
* Two sinks:
|
||||||
|
* 1. **DB `audit_log` table** — primary, used by `auditLogStrict` для critical
|
||||||
|
* операций. INSERT pending → mutation → UPDATE success/failure с txid.
|
||||||
|
* Если INSERT fails — operation must NOT proceed (fail-secure).
|
||||||
|
* 2. **stdout JSON line** — для log-aggregator (Docker logs / Loki etc).
|
||||||
|
* Best-effort, всегда (даже если DB sink fails).
|
||||||
|
*
|
||||||
* НИКОГДА не логирует mnemonic / privkey / encrypted blob.
|
* НИКОГДА не логирует mnemonic / privkey / encrypted blob.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { promises as fs } from 'fs';
|
import { ulid } from 'ulidx';
|
||||||
import path from 'path';
|
import { db } from '../config/database';
|
||||||
import { logger } from './logger';
|
|
||||||
import { getTraceId } from './trace-store';
|
import { getTraceId } from './trace-store';
|
||||||
|
import { logger } from './logger';
|
||||||
const AUDIT_DIR = path.resolve(__dirname, '../../../../logs');
|
|
||||||
const AUDIT_FILE = path.join(AUDIT_DIR, 'audit.log');
|
|
||||||
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
async function ensureFile(): Promise<void> {
|
|
||||||
if (initialized) return;
|
|
||||||
try {
|
|
||||||
await fs.mkdir(AUDIT_DIR, { recursive: true });
|
|
||||||
const handle = await fs.open(AUDIT_FILE, 'a', 0o600);
|
|
||||||
await handle.close();
|
|
||||||
try {
|
|
||||||
await fs.chmod(AUDIT_FILE, 0o600);
|
|
||||||
} catch {
|
|
||||||
// Windows chmod — игнор
|
|
||||||
}
|
|
||||||
initialized = true;
|
|
||||||
} catch (err: any) {
|
|
||||||
logger.error(`Audit log init failed: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuditEntry {
|
export interface AuditEntry {
|
||||||
event: string;
|
event: string;
|
||||||
@@ -40,36 +25,90 @@ export interface AuditEntry {
|
|||||||
errorCode?: string;
|
errorCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function buildStdoutLine(entry: AuditEntry, status: 'pending' | 'success' | 'failure'): string {
|
||||||
* Best-effort write. Если запись провалилась — только log, не throws.
|
return JSON.stringify({
|
||||||
* Используется для не-критических событий (wallet.create success, etc).
|
level: 'audit',
|
||||||
*/
|
status,
|
||||||
export async function auditLog(entry: AuditEntry): Promise<void> {
|
|
||||||
await ensureFile();
|
|
||||||
const line = JSON.stringify({
|
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
trace_id: getTraceId(),
|
trace_id: getTraceId(),
|
||||||
...entry,
|
...entry,
|
||||||
});
|
}) + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStdoutBestEffort(line: string): void {
|
||||||
try {
|
try {
|
||||||
await fs.appendFile(AUDIT_FILE, line + '\n', { mode: 0o600 });
|
process.stdout.write(line);
|
||||||
} catch (err: any) {
|
} catch {
|
||||||
logger.error(`Audit log write failed: ${err.message}`);
|
// swallow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fail-secure write. Если запись провалилась — throws.
|
* Best-effort: stdout only. Используется для info-level событий
|
||||||
* Используется для critical security событий (mnemonic.reveal, wallet.send),
|
* (wallet.create success, lookup, etc). Не блокирует request на DB.
|
||||||
* где compliance требует чтобы операция НЕ происходила без audit-trail.
|
|
||||||
*/
|
*/
|
||||||
export async function auditLogStrict(entry: AuditEntry): Promise<void> {
|
export async function auditLog(entry: AuditEntry): Promise<void> {
|
||||||
await ensureFile();
|
const status: 'success' | 'failure' = entry.result === 'failure' ? 'failure' : 'success';
|
||||||
const line = JSON.stringify({
|
writeStdoutBestEffort(buildStdoutLine(entry, status));
|
||||||
timestamp: new Date().toISOString(),
|
}
|
||||||
trace_id: getTraceId(),
|
|
||||||
...entry,
|
/**
|
||||||
});
|
* Fail-secure audit для critical custodial операций (mnemonic.reveal, wallet.send,
|
||||||
// Без try/catch — caller обрабатывает failure
|
* wallet.sign_raw_evm).
|
||||||
await fs.appendFile(AUDIT_FILE, line + '\n', { mode: 0o600 });
|
*
|
||||||
|
* Семантика: INSERT row в `audit_log` table перед mutation. Если INSERT FAILS
|
||||||
|
* (DB down, connection pool exhausted, constraint violation) — throws.
|
||||||
|
* Caller ОБЯЗАН abort'нуть mutation, не вернуть response с funds-action.
|
||||||
|
*
|
||||||
|
* Возвращает audit row id — caller использует его в `completeAudit()` после mutation.
|
||||||
|
*/
|
||||||
|
export async function auditLogStrict(entry: AuditEntry & { status?: 'pending' | 'success' | 'failure' }): Promise<string> {
|
||||||
|
const id = ulid();
|
||||||
|
const status = entry.status ?? 'pending';
|
||||||
|
|
||||||
|
// DB INSERT — fail-secure (throws on DB failure)
|
||||||
|
await db('audit_log').insert({
|
||||||
|
id,
|
||||||
|
user_id: entry.userId,
|
||||||
|
event: entry.event,
|
||||||
|
status,
|
||||||
|
error_code: entry.errorCode ?? null,
|
||||||
|
ip: entry.ip ?? null,
|
||||||
|
trace_id: getTraceId() ?? null,
|
||||||
|
meta: entry.meta ? JSON.stringify(entry.meta) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirror to stdout (best-effort, не критично)
|
||||||
|
writeStdoutBestEffort(buildStdoutLine(entry, status));
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update audit row после mutation (success или failure с txid/error).
|
||||||
|
* Best-effort — если update fails, операция уже произошла, мы just log warning.
|
||||||
|
*/
|
||||||
|
export async function completeAudit(
|
||||||
|
auditId: string,
|
||||||
|
result: 'success' | 'failure',
|
||||||
|
meta?: Record<string, unknown>,
|
||||||
|
errorCode?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db('audit_log')
|
||||||
|
.where({ id: auditId })
|
||||||
|
.update({
|
||||||
|
status: result,
|
||||||
|
error_code: errorCode ?? null,
|
||||||
|
meta: meta ? JSON.stringify(meta) : db.raw('meta'),
|
||||||
|
updated_at: db.fn.now(),
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`completeAudit failed for ${auditId}: ${err?.message}`);
|
||||||
|
}
|
||||||
|
// Mirror to stdout
|
||||||
|
writeStdoutBestEffort(buildStdoutLine(
|
||||||
|
{ event: `audit.update.${auditId}`, userId: '<see-audit-row>', meta, errorCode, result },
|
||||||
|
result,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
116
apps/api/src/lib/evm-tx-policy.ts
Normal file
116
apps/api/src/lib/evm-tx-policy.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* 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. */
|
||||||
|
const RELAY_ROUTERS: Record<number, Set<string>> = {
|
||||||
|
// Ethereum mainnet — Relay router contracts (lowercase for canonical match)
|
||||||
|
1: new Set([
|
||||||
|
'0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 ETH
|
||||||
|
'0xf70da97812cb96acdf810712aa562db8dfa3dbef', // Relay Router (legacy)
|
||||||
|
]),
|
||||||
|
// BSC mainnet
|
||||||
|
56: new Set([
|
||||||
|
'0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 BSC
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применяет security policy. Throws if disallowed.
|
||||||
|
*
|
||||||
|
* Возвращает result-обoject для info-логирования (matched router name, selector name).
|
||||||
|
*/
|
||||||
|
export function applyEvmTxPolicy(p: PolicyParams): { routerName?: string; selectorName?: string } {
|
||||||
|
// 1. `to` validation — должен быть в Relay router allowlist для этого chainId
|
||||||
|
const toLower = p.to.toLowerCase();
|
||||||
|
const routers = RELAY_ROUTERS[p.chainId];
|
||||||
|
if (!routers) {
|
||||||
|
throw new Error(`Sign-raw policy: chainId ${p.chainId} not in router allowlist`);
|
||||||
|
}
|
||||||
|
if (!routers.has(toLower)) {
|
||||||
|
throw new Error(`Sign-raw policy: 'to' address ${p.to} not in Relay router allowlist for chainId ${p.chainId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Selector blacklist — `approve()` etc. никогда не подписывается
|
||||||
|
let selectorName: string | undefined;
|
||||||
|
if (p.data.length >= 10) {
|
||||||
|
const selector = p.data.slice(0, 10).toLowerCase();
|
||||||
|
if (FORBIDDEN_SELECTORS[selector]) {
|
||||||
|
throw new Error(`Sign-raw policy: forbidden selector ${selector} (${FORBIDDEN_SELECTORS[selector]}) — drain vector`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. gas 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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. value cap
|
||||||
|
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: routers.has(toLower) ? `relay-router-${p.chainId}` : undefined,
|
||||||
|
selectorName,
|
||||||
|
};
|
||||||
|
}
|
||||||
92
apps/api/src/lib/idempotency.ts
Normal file
92
apps/api/src/lib/idempotency.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Idempotency-Key handling — C3 защита от double-spend при retry.
|
||||||
|
*
|
||||||
|
* Контракт:
|
||||||
|
* Client передаёт header `Idempotency-Key: <opaque-string-up-to-128-chars>`.
|
||||||
|
* Server:
|
||||||
|
* 1. INSERT row (user_id, key, request_hash) — PK conflict = retry detected.
|
||||||
|
* 2. На retry: SELECT existing row. Если response_status is null — operation
|
||||||
|
* ещё in-flight → return 409 "retry too soon". Если response_status set →
|
||||||
|
* return cached response (same status, same body).
|
||||||
|
* Retention: 24h. Cleanup via cron.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { db } from '../config/database';
|
||||||
|
|
||||||
|
export interface IdempotencyClaim {
|
||||||
|
fresh: boolean;
|
||||||
|
cached?: { status: number; body: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to claim the key. If first time → fresh=true, caller proceeds with mutation.
|
||||||
|
* If duplicate с existing response → fresh=false + cached response.
|
||||||
|
* If duplicate с pending in-flight → throws (caller returns 409).
|
||||||
|
*/
|
||||||
|
export async function claimIdempotency(
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
requestBody: unknown,
|
||||||
|
): Promise<IdempotencyClaim> {
|
||||||
|
const requestHash = createHash('sha256')
|
||||||
|
.update(JSON.stringify(requestBody ?? {}))
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db('idempotency_keys').insert({
|
||||||
|
user_id: userId,
|
||||||
|
key,
|
||||||
|
request_hash: requestHash,
|
||||||
|
});
|
||||||
|
return { fresh: true };
|
||||||
|
} catch (err: any) {
|
||||||
|
// PK violation = retry
|
||||||
|
const existing = await db('idempotency_keys')
|
||||||
|
.where({ user_id: userId, key })
|
||||||
|
.first();
|
||||||
|
if (!existing) throw err;
|
||||||
|
|
||||||
|
// Verify request body matches (защита от replay с другим body)
|
||||||
|
if (existing.request_hash !== requestHash) {
|
||||||
|
throw new Error(`Idempotency-Key reuse with different request body. Use a new key.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.response_status === null || existing.response_status === undefined) {
|
||||||
|
throw new Error('Operation already in flight; retry after a few seconds.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fresh: false,
|
||||||
|
cached: {
|
||||||
|
status: existing.response_status as number,
|
||||||
|
body: existing.response_body as string,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сохранить response в idempotency row (после mutation succeeds/fails). */
|
||||||
|
export async function saveIdempotencyResponse(
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
status: number,
|
||||||
|
body: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await db('idempotency_keys')
|
||||||
|
.where({ user_id: userId, key })
|
||||||
|
.update({
|
||||||
|
response_status: status,
|
||||||
|
response_body: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate header format. Returns null if missing/invalid (caller may make mandatory). */
|
||||||
|
export function extractIdempotencyKey(headerValue: unknown): string | null {
|
||||||
|
if (typeof headerValue !== 'string') return null;
|
||||||
|
const v = headerValue.trim();
|
||||||
|
if (!v) return null;
|
||||||
|
// Restrict charset: alphanum + dash/underscore, max 128
|
||||||
|
if (!/^[A-Za-z0-9_-]{1,128}$/.test(v)) return null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
38
apps/api/src/lib/send-lock.ts
Normal file
38
apps/api/src/lib/send-lock.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
80
apps/api/src/lib/token-registry.ts
Normal file
80
apps/api/src/lib/token-registry.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Реестр известных токенов 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;
|
||||||
|
contractAddress: string;
|
||||||
|
decimals: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrxToken {
|
||||||
|
symbol: string;
|
||||||
|
contractAddress: string; // T...base58
|
||||||
|
decimals: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SolToken {
|
||||||
|
symbol: string;
|
||||||
|
mint: string; // SPL mint pubkey (base58)
|
||||||
|
decimals: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ETH_TOKENS: EvmToken[] = [
|
||||||
|
{ symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 },
|
||||||
|
{ symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 },
|
||||||
|
{ symbol: 'DAI', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18 },
|
||||||
|
{ symbol: 'WBTC', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8 },
|
||||||
|
{ symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18 },
|
||||||
|
{ symbol: 'UNI', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BSC_TOKENS: EvmToken[] = [
|
||||||
|
{ symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 },
|
||||||
|
{ symbol: 'USDC', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18 },
|
||||||
|
{ symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8 },
|
||||||
|
{ symbol: 'WBNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18 },
|
||||||
|
{ symbol: 'BUSD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TRX_TOKENS: TrxToken[] = [
|
||||||
|
{ symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 },
|
||||||
|
{ symbol: 'USDC', contractAddress: 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', decimals: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SOL_TOKENS: SolToken[] = [
|
||||||
|
{ symbol: 'USDT', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 },
|
||||||
|
{ symbol: 'USDC', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 },
|
||||||
|
{ symbol: 'PUMP', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6 },
|
||||||
|
{ symbol: 'JUP', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6 },
|
||||||
|
{ symbol: 'WIF', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6 },
|
||||||
|
{ symbol: 'POPCAT', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9 },
|
||||||
|
{ symbol: 'TRUMP', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6 },
|
||||||
|
{ symbol: 'PYTH', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6 },
|
||||||
|
{ symbol: 'JTO', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9 },
|
||||||
|
{ symbol: 'W', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6 },
|
||||||
|
{ symbol: 'BONK', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5 },
|
||||||
|
{ symbol: 'ORCA', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6 },
|
||||||
|
{ symbol: 'PENGU', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6 },
|
||||||
|
{ symbol: 'RAY', mint: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
43
apps/api/src/lib/wallet-binding.ts
Normal file
43
apps/api/src/lib/wallet-binding.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -28,6 +28,15 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bearer-auth (Authorization header, no access_token cookie) — CSRF не нужен.
|
||||||
|
// Browser-based CSRF атаки требуют auto-sent cookie. Если auth идёт через explicit
|
||||||
|
// Authorization header, attacker не может его выставить cross-origin (CORS preflight блокирует).
|
||||||
|
// Это позволяет API-клиентам (Swagger UI, Postman, curl, мобилке) работать без CSRF.
|
||||||
|
if (!req.cookies?.access_token && req.headers.authorization) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// CSRF включён, но секрет не загружен → fail-secure 503.
|
// CSRF включён, но секрет не загружен → fail-secure 503.
|
||||||
if (!isCsrfConfigured()) {
|
if (!isCsrfConfigured()) {
|
||||||
logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request');
|
logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request');
|
||||||
|
|||||||
@@ -9,18 +9,15 @@ 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;
|
crypto_wallet: string | null; // DEPRECATED — оставлено для backward-compat. ETH = users.erc20.
|
||||||
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;
|
||||||
encrypted_vault: string | null; // legacy, unused
|
passport_data: string | null;
|
||||||
vault_salt: string | null; // legacy, unused
|
erc20: string | null; // ETH-адрес кастодиального кошелька (заполняется при /wallets/create)
|
||||||
encrypted_mnemonic: string | null; // AES-GCM blob (custodial)
|
encrypted_mnemonic: string | null; // AES-GCM blob (custodial). Extension над user-supplied schema.
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
@@ -100,4 +97,19 @@ export const UserModel = {
|
|||||||
.first();
|
.first();
|
||||||
return Boolean(row?.has);
|
return Boolean(row?.has);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Записать ETH-адрес custodial-кошелька в users.erc20.
|
||||||
|
* Вызывается из createWallet() внутри той же transaction что и WalletModel.createMany,
|
||||||
|
* чтобы rollback был consistent (без orphan записей).
|
||||||
|
*/
|
||||||
|
async setErc20Address(id: string, address: string, trx?: Knex.Transaction): Promise<void> {
|
||||||
|
const k = trx || db;
|
||||||
|
await k('users')
|
||||||
|
.where({ id, is_deleted: false })
|
||||||
|
.update({
|
||||||
|
erc20: address,
|
||||||
|
updated_at: k.fn.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Request, Response, Router } from 'express';
|
import { Request, Response, Router } from 'express';
|
||||||
import { ethers } from 'ethers';
|
import { ethers } from 'ethers';
|
||||||
|
import { logger } from '../lib/logger';
|
||||||
|
import { assertUserOwnsAddress } from '../lib/wallet-binding';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ async function getSwapQuote(req: Request, res: Response) {
|
|||||||
toDecimals: TOKEN_DECIMALS[to],
|
toDecimals: TOKEN_DECIMALS[to],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`BSC quote failed: ${(error as any)?.stack || (error as any)?.message}`);
|
logger.error(`BSC quote failed: ${(error as any)?.stack || (error as any)?.message}`);
|
||||||
res.status(502).json({ success: false, error: 'Failed to get BSC swap quote' });
|
res.status(502).json({ success: false, error: 'Failed to get BSC swap quote' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,6 +115,16 @@ async function buildSwapTx(req: Request, res: Response) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C17 — bind userAddress to JWT. Иначе attacker может set userAddress=victim_addr
|
||||||
|
// и output swap'а пойдёт victim'у/контракту-attacker'у (reentrancy vector).
|
||||||
|
const userId = req.auth!.userId;
|
||||||
|
try {
|
||||||
|
await assertUserOwnsAddress(userId, 'BSC', userAddress);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(403).json({ success: false, error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate amount + amountOutMin: positive integers > 0 (string-encoded BigInt).
|
// Validate amount + amountOutMin: positive integers > 0 (string-encoded BigInt).
|
||||||
// "0" truthy bypass — без этого attacker может выставить amountOutMin=0 = 100% slippage
|
// "0" truthy bypass — без этого attacker может выставить amountOutMin=0 = 100% slippage
|
||||||
// → sandwich attack осушает swap.
|
// → sandwich attack осушает swap.
|
||||||
@@ -189,7 +201,7 @@ async function buildSwapTx(req: Request, res: Response) {
|
|||||||
|
|
||||||
res.json({ success: true, transactions });
|
res.json({ success: true, transactions });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`BSC build failed: ${(error as any)?.stack || (error as any)?.message}`);
|
logger.error(`BSC build failed: ${(error as any)?.stack || (error as any)?.message}`);
|
||||||
res.status(502).json({ success: false, error: 'Failed to build BSC swap' });
|
res.status(502).json({ success: false, error: 'Failed to build BSC swap' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
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 { logger } from '../lib/logger';
|
||||||
|
import { WalletModel } from '../models/wallet.model';
|
||||||
|
import type { ChainCode } from '../lib/address-validators';
|
||||||
|
|
||||||
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 RELAY_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
// Whitelist: GET-paths + allowed `/execute/<action>` actions.
|
// 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.
|
// Без него `req.path` после `/execute/` свободен → `/execute/../admin` обходит startsWith-check.
|
||||||
const ALLOWED_GET_PATHS = new Set(['/quote/v2', '/intents/status/v3']);
|
// Relay API: `POST /quote` (раньше был `GET /quote/v2` — upstream сменили в 2025).
|
||||||
|
const ALLOWED_GET_PATHS = new Set(['/intents/status/v3']);
|
||||||
|
const ALLOWED_POST_PATHS = new Set(['/quote']);
|
||||||
const ALLOWED_EXECUTE_ACTIONS = new Set([
|
const ALLOWED_EXECUTE_ACTIONS = new Set([
|
||||||
'swap',
|
'swap',
|
||||||
'bridge',
|
'bridge',
|
||||||
@@ -24,10 +35,13 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
|
|||||||
const relayPath = req.path;
|
const relayPath = req.path;
|
||||||
|
|
||||||
// Whitelist matching — никакого freeform после `/execute/`.
|
// Whitelist matching — никакого freeform после `/execute/`.
|
||||||
|
// Каждый path matches только своему method'у, чтобы клиент не мог GET-ом дёрнуть POST endpoint.
|
||||||
let allowed = false;
|
let allowed = false;
|
||||||
if (ALLOWED_GET_PATHS.has(relayPath)) {
|
if (req.method === 'GET' && ALLOWED_GET_PATHS.has(relayPath)) {
|
||||||
allowed = true;
|
allowed = true;
|
||||||
} else if (relayPath.startsWith('/execute/')) {
|
} 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);
|
const action = relayPath.slice('/execute/'.length);
|
||||||
// action: только alphanumeric, никаких слешей/дотов
|
// action: только alphanumeric, никаких слешей/дотов
|
||||||
if (/^[a-z][a-z0-9-]{0,32}$/i.test(action) && ALLOWED_EXECUTE_ACTIONS.has(action)) {
|
if (/^[a-z][a-z0-9-]{0,32}$/i.test(action) && ALLOWED_EXECUTE_ACTIONS.has(action)) {
|
||||||
@@ -39,6 +53,65 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const relayUrl = new URL(`${RELAY_API_URL}${relayPath}`);
|
const relayUrl = new URL(`${RELAY_API_URL}${relayPath}`);
|
||||||
|
|
||||||
Object.entries(req.query).forEach(([key, value]) => {
|
Object.entries(req.query).forEach(([key, value]) => {
|
||||||
@@ -79,7 +152,16 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
|
|||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
logger.warn(`Relay upstream ${upstream.status}: ${text.slice(0, 200)}`);
|
logger.warn(`Relay upstream ${upstream.status}: ${text.slice(0, 200)}`);
|
||||||
|
// Пробрасываем Relay error JSON клиенту — он сам пишет structured payload
|
||||||
|
// {message, errorCode, requestId, ...}. Content-Type уже forced на JSON выше,
|
||||||
|
// так что HTML-injection невозможен. Parsable наружу — клиент видит реальную причину.
|
||||||
|
let parsed: unknown = null;
|
||||||
|
try { parsed = JSON.parse(text); } catch { /* not JSON — wrap in safe envelope */ }
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
res.json({ success: false, error: 'Relay upstream error', upstream: parsed });
|
||||||
|
} else {
|
||||||
res.json({ success: false, error: 'Relay upstream error' });
|
res.json({ success: false, error: 'Relay upstream error' });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Request, Response, Router } from 'express';
|
import { Request, Response, Router } from 'express';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
|
import { logger } from '../lib/logger';
|
||||||
|
import { assertUserOwnsAddress } from '../lib/wallet-binding';
|
||||||
|
import { PublicKey } from '@solana/web3.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const JUPITER_BASE = 'https://api.jup.ag/swap/v1';
|
const JUPITER_BASE = 'https://api.jup.ag/swap/v1';
|
||||||
@@ -70,7 +73,8 @@ async function getQuote(req: Request, res: Response) {
|
|||||||
url.searchParams.set('inputMint', String(inputMint));
|
url.searchParams.set('inputMint', String(inputMint));
|
||||||
url.searchParams.set('outputMint', String(outputMint));
|
url.searchParams.set('outputMint', String(outputMint));
|
||||||
url.searchParams.set('amount', String(parsedAmount));
|
url.searchParams.set('amount', String(parsedAmount));
|
||||||
url.searchParams.set('slippageBps', String(slippageBps));
|
// H38 — forward PARSED value, not raw query string ("300abc" → "300" но raw forwarded as "300abc")
|
||||||
|
url.searchParams.set('slippageBps', String(parsedSlippage));
|
||||||
|
|
||||||
// Platform fee (0.7%) — Jupiter deducts this natively
|
// Platform fee (0.7%) — Jupiter deducts this natively
|
||||||
if (env.jupiterFeeBps > 0) {
|
if (env.jupiterFeeBps > 0) {
|
||||||
@@ -87,7 +91,7 @@ async function getQuote(req: Request, res: Response) {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
// НЕ reflect'им upstream body — может содержать internal config (feeAccount, refs)
|
// НЕ reflect'им upstream body — может содержать internal config (feeAccount, refs)
|
||||||
console.error(`Jupiter quote ${response.status}: ${text.slice(0, 200)}`);
|
logger.error(`Jupiter quote ${response.status}: ${text.slice(0, 200)}`);
|
||||||
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
|
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -122,6 +126,37 @@ async function buildSwap(req: Request, res: Response) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate userPublicKey syntactically
|
||||||
|
try {
|
||||||
|
new PublicKey(userPublicKey);
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({ success: false, error: 'Invalid userPublicKey (not a valid base58 32-byte pubkey)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// C9 — bind userPublicKey to JWT (без bind: feeAccount/referral hijacking, ATA pre-staging)
|
||||||
|
const userId = req.auth!.userId;
|
||||||
|
try {
|
||||||
|
await assertUserOwnsAddress(userId, 'SOL', userPublicKey);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(403).json({ success: false, error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// C8 — re-validate input/output mints в quoteResponse против ALLOWED_MINTS.
|
||||||
|
// Иначе attacker может вызвать /quote с whitelisted mints (passes), затем POST /build
|
||||||
|
// с hand-crafted quoteResponse где outputMint = scam-mint, и Jupiter trust'нет shape.
|
||||||
|
const qInputMint = (quoteResponse as any)?.inputMint;
|
||||||
|
const qOutputMint = (quoteResponse as any)?.outputMint;
|
||||||
|
if (typeof qInputMint !== 'string' || !ALLOWED_MINTS.has(qInputMint)) {
|
||||||
|
res.status(400).json({ success: false, error: 'quoteResponse.inputMint not in allowlist' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof qOutputMint !== 'string' || !ALLOWED_MINTS.has(qOutputMint)) {
|
||||||
|
res.status(400).json({ success: false, error: 'quoteResponse.outputMint not in allowlist' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS);
|
||||||
|
|
||||||
@@ -156,7 +191,7 @@ async function buildSwap(req: Request, res: Response) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
console.error(`Jupiter swap build ${response.status}: ${text.slice(0, 200)}`);
|
logger.error(`Jupiter swap build ${response.status}: ${text.slice(0, 200)}`);
|
||||||
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
|
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Request, Response, Router } from 'express';
|
import { Request, Response, Router } from 'express';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
|
import { logger } from '../lib/logger';
|
||||||
|
import { assertUserOwnsAddress } from '../lib/wallet-binding';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const TRONGRID_BASE = 'https://api.trongrid.io';
|
const TRONGRID_BASE = 'https://api.trongrid.io';
|
||||||
@@ -199,6 +201,15 @@ async function buildSwapTx(req: Request, res: Response) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// H47 — bind userAddress to JWT (output идёт на userAddress; без bind output к attacker'у)
|
||||||
|
const userId = req.auth!.userId;
|
||||||
|
try {
|
||||||
|
await assertUserOwnsAddress(userId, 'TRX', userAddress);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(403).json({ success: false, error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
||||||
|
|
||||||
@@ -308,7 +319,7 @@ async function buildSwapTx(req: Request, res: Response) {
|
|||||||
res.status(504).json({ success: false, error: 'Build request timed out' });
|
res.status(504).json({ success: false, error: 'Build request timed out' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error(`TRON swap build failed: ${(error as any)?.stack || (error as any)?.message}`);
|
logger.error(`TRON swap build failed: ${(error as any)?.stack || (error as any)?.message}`);
|
||||||
res.status(502).json({ success: false, error: 'Failed to build swap' });
|
res.status(502).json({ success: false, error: 'Failed to build swap' });
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@@ -320,8 +331,25 @@ async function buildSwapTx(req: Request, res: Response) {
|
|||||||
async function broadcastTx(req: Request, res: Response) {
|
async function broadcastTx(req: Request, res: Response) {
|
||||||
const { signedTransaction } = req.body;
|
const { signedTransaction } = req.body;
|
||||||
|
|
||||||
if (!signedTransaction) {
|
if (!signedTransaction || typeof signedTransaction !== 'object') {
|
||||||
res.status(400).json({ success: false, error: 'Missing signedTransaction' });
|
res.status(400).json({ success: false, error: 'Missing or invalid signedTransaction' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// C6 — verify owner_address внутри signed tx равен JWT-bound TRX-адресу юзера.
|
||||||
|
// Иначе endpoint = open relay для arbitrary tx (replay leaked txs, evade IP bans).
|
||||||
|
const userId = req.auth!.userId;
|
||||||
|
const contract0 = signedTransaction?.raw_data?.contract?.[0];
|
||||||
|
const ownerAddr = contract0?.parameter?.value?.owner_address;
|
||||||
|
if (typeof ownerAddr !== 'string' || !ownerAddr) {
|
||||||
|
res.status(400).json({ success: false, error: 'Cannot extract owner_address from signedTransaction.raw_data.contract[0]' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await assertUserOwnsAddress(userId, 'TRX', ownerAddr);
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.warn(`broadcast rejected: ${err.message} userId=${userId}`);
|
||||||
|
res.status(403).json({ success: false, error: err.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ router.post('/mnemonic/reveal', WalletController.revealMnemonic);
|
|||||||
|
|
||||||
router.get('/:chain/balance', WalletController.getChainBalance);
|
router.get('/:chain/balance', WalletController.getChainBalance);
|
||||||
router.get('/:chain/transactions', WalletController.getChainTransactions);
|
router.get('/:chain/transactions', WalletController.getChainTransactions);
|
||||||
|
router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions);
|
||||||
router.post('/:chain/send', WalletController.sendFromChain);
|
router.post('/:chain/send', WalletController.sendFromChain);
|
||||||
|
router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -56,8 +56,13 @@ export async function fetchMasterKey(
|
|||||||
throw new Error('Failed to load crypto master key from Vault');
|
throw new Error('Failed to load crypto master key from Vault');
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = secrets.key || secrets.master_key || secrets.MASTER_KEY;
|
// 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 (!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"');
|
throw new Error('Crypto master key invalid: expected hex string in Vault field "key"');
|
||||||
}
|
}
|
||||||
if (!/^[0-9a-fA-F]{64}$/.test(raw)) {
|
if (!/^[0-9a-fA-F]{64}$/.test(raw)) {
|
||||||
|
|||||||
125
apps/api/src/services/gas-oracle.service.ts
Normal file
125
apps/api/src/services/gas-oracle.service.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
const FLOOR_GWEI: Record<'ETH' | 'BSC', string> = {
|
||||||
|
ETH: '0.5',
|
||||||
|
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];
|
||||||
|
}
|
||||||
@@ -11,13 +11,31 @@ let timer: NodeJS.Timeout | null = null;
|
|||||||
|
|
||||||
// Inflight guard — reentrant calls share the same promise (audit#4 C2/C3).
|
// Inflight guard — reentrant calls share the same promise (audit#4 C2/C3).
|
||||||
// Без этого две параллельные refreshAllKeys могут torn-state'ить keyMap/csrf/crypto.
|
// Без этого две параллельные refreshAllKeys могут torn-state'ить keyMap/csrf/crypto.
|
||||||
let inflight: Promise<void> | null = null;
|
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 только если необходимые получены.
|
* Atomic refresh: pre-fetch JWT/CSRF/crypto secrets, swap globals только если необходимые получены.
|
||||||
* Reentrant-safe.
|
* Reentrant-safe.
|
||||||
|
* H18 — returns RefreshResult так что caller знает реально ли refresh succeeded.
|
||||||
*/
|
*/
|
||||||
export async function refreshAllKeys(): Promise<void> {
|
export async function refreshAllKeys(): Promise<RefreshResult> {
|
||||||
if (inflight) return inflight;
|
if (inflight) return inflight;
|
||||||
inflight = doRefresh().finally(() => {
|
inflight = doRefresh().finally(() => {
|
||||||
inflight = null;
|
inflight = null;
|
||||||
@@ -25,22 +43,23 @@ export async function refreshAllKeys(): Promise<void> {
|
|||||||
return inflight;
|
return inflight;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doRefresh(): Promise<void> {
|
async function doRefresh(): Promise<RefreshResult> {
|
||||||
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath, cryptoKeyPath } = env.vault;
|
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) {
|
if (!addr || !roleId || !secretId) {
|
||||||
logger.warn('Vault not configured, skipping key refresh');
|
return fail('vault_not_configured');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// КАЖДЫЙ refresh — свежий AppRole login. Vault token TTL обычно ≤1 час, а refresh-интервал
|
// КАЖДЫЙ refresh — свежий AppRole login. Vault token TTL обычно ≤1 час.
|
||||||
// тоже ~1 час → кэшировать токен между tick'ами = expired token на 2-м tick → silent fail.
|
|
||||||
// Стоимость fresh login: один HTTP-запрос в час — пренебрежимо. Безопасность: гарантированно
|
|
||||||
// валидный токен для всех последующих fetches.
|
|
||||||
const token = await vaultAppRoleLogin(addr, roleId, secretId);
|
const token = await vaultAppRoleLogin(addr, roleId, secretId);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
logger.error('Key refresh: Vault AppRole login failed');
|
return fail('approle_login_failed');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const jwtPromise = fetchJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
|
const jwtPromise = fetchJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
|
||||||
@@ -50,21 +69,17 @@ async function doRefresh(): Promise<void> {
|
|||||||
const [jwtResult, csrfResult, cryptoResult] = await Promise.allSettled([jwtPromise, csrfPromise, cryptoPromise]);
|
const [jwtResult, csrfResult, cryptoResult] = await Promise.allSettled([jwtPromise, csrfPromise, cryptoPromise]);
|
||||||
|
|
||||||
if (jwtResult.status === 'rejected') {
|
if (jwtResult.status === 'rejected') {
|
||||||
logger.error(`Key refresh ABORTED — JWT keys fetch failed: ${jwtResult.reason?.message || jwtResult.reason}`);
|
return fail(`jwt_fetch_failed: ${jwtResult.reason?.message || jwtResult.reason}`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (csrfPath && csrfResult.status === 'rejected') {
|
if (csrfPath && csrfResult.status === 'rejected') {
|
||||||
logger.error(`Key refresh ABORTED — CSRF fetch failed: ${csrfResult.reason?.message || csrfResult.reason}`);
|
return fail(`csrf_fetch_failed: ${csrfResult.reason?.message || csrfResult.reason}`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Master-key: первый load обязателен, дальнейшие failures толерантны.
|
// Master-key: первый load обязателен, дальнейшие failures толерантны.
|
||||||
if (cryptoKeyPath && !isCryptoReady() && cryptoResult.status === 'rejected') {
|
if (cryptoKeyPath && !isCryptoReady() && cryptoResult.status === 'rejected') {
|
||||||
logger.error(`Key refresh ABORTED — Crypto master key fetch failed: ${cryptoResult.reason?.message || cryptoResult.reason}`);
|
return fail(`crypto_fetch_failed: ${cryptoResult.reason?.message || cryptoResult.reason}`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic synchronous swap. JS single-threaded — между swap'ами нет await,
|
// Atomic swap. JS single-threaded → observers видят либо все старые, либо все новые.
|
||||||
// т.е. observers видят либо все старые, либо все новые значения.
|
|
||||||
swapKeyMap(jwtResult.value);
|
swapKeyMap(jwtResult.value);
|
||||||
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
|
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
|
||||||
swapCsrfConfig(csrfResult.value);
|
swapCsrfConfig(csrfResult.value);
|
||||||
@@ -74,18 +89,29 @@ async function doRefresh(): Promise<void> {
|
|||||||
swapMasterKey(cryptoResult.value);
|
swapMasterKey(cryptoResult.value);
|
||||||
logger.info('Crypto master key loaded');
|
logger.info('Crypto master key loaded');
|
||||||
} else if (!masterKeyMatches(cryptoResult.value)) {
|
} else if (!masterKeyMatches(cryptoResult.value)) {
|
||||||
logger.warn(
|
// H16 — master-key drift detected. По умолчанию FATAL: если operator не выставил
|
||||||
'Vault crypto/master key DIFFERS from in-memory key. Service continues with old key. ' +
|
// ALLOW_MASTER_KEY_ROTATION=true explicitly, мы НЕ продолжаем silently на старом key.
|
||||||
'Rotating master-key bricks all existing encrypted_mnemonic — revert Vault or plan re-encryption migration.'
|
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(
|
logger.info(
|
||||||
`Keys refreshed atomically: JWT keys=${getKeyMapSize()}` +
|
`Keys refreshed atomically: JWT keys=${getKeyMapSize()}` +
|
||||||
(csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '') +
|
(csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '') +
|
||||||
`, Crypto=${isCryptoReady() ? 'ready' : 'NOT-READY'}`
|
`, Crypto=${isCryptoReady() ? 'ready' : 'NOT-READY'}`
|
||||||
);
|
);
|
||||||
|
return lastRefresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void {
|
export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import { ethers } from 'ethers';
|
import { ethers } from 'ethers';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
|
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
|
||||||
|
|
||||||
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||||
|
|
||||||
@@ -16,10 +17,6 @@ const BSC_RPC = 'https://bsc-dataseed.binance.org';
|
|||||||
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
|
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
|
||||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||||||
|
|
||||||
const USDT_TRC20 = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
|
||||||
const USDT_BEP20 = '0x55d398326f99059fF775485246999027B3197955';
|
|
||||||
const USDT_ERC20 = '0xdAC17F958D2ee523a2206206994597C13D831ec7';
|
|
||||||
|
|
||||||
const ERC20_ABI = [
|
const ERC20_ABI = [
|
||||||
'function balanceOf(address owner) view returns (uint256)',
|
'function balanceOf(address owner) view returns (uint256)',
|
||||||
'function transfer(address to, uint256 amount) returns (bool)',
|
'function transfer(address to, uint256 amount) returns (bool)',
|
||||||
@@ -28,31 +25,106 @@ const ERC20_ABI = [
|
|||||||
|
|
||||||
// ─────────────────────── BALANCE ───────────────────────
|
// ─────────────────────── BALANCE ───────────────────────
|
||||||
|
|
||||||
|
export interface FormattedAmount {
|
||||||
|
raw: string; // smallest units (string-encoded BigInt — без потери точности)
|
||||||
|
formatted: string; // human-readable, e.g. "0.003"
|
||||||
|
decimals: number; // decimals chain'а/токена
|
||||||
|
}
|
||||||
|
|
||||||
export interface BalanceResult {
|
export interface BalanceResult {
|
||||||
chain: ChainCode;
|
chain: ChainCode;
|
||||||
address: string;
|
address: string;
|
||||||
native: string; // в smallest units (satoshi/wei/lamports/sun)
|
native: FormattedAmount;
|
||||||
tokens?: Record<string, string>; // например { USDT: "12345678" }
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> {
|
||||||
|
const nativeDecimals = NATIVE_DECIMALS[chain];
|
||||||
switch (chain) {
|
switch (chain) {
|
||||||
case 'BTC':
|
case 'BTC':
|
||||||
return { chain, address, native: await btcBalance(address) };
|
return {
|
||||||
|
chain, address,
|
||||||
|
native: fmt(await btcBalance(address), nativeDecimals),
|
||||||
|
};
|
||||||
case 'TRX': {
|
case 'TRX': {
|
||||||
const { trx, usdt } = await trxBalance(address);
|
const { trx, tokens } = await trxBalance(address);
|
||||||
return { chain, address, native: trx, tokens: { USDT: usdt } };
|
const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals]));
|
||||||
}
|
return {
|
||||||
case 'BSC': {
|
chain, address,
|
||||||
const { native, tokens } = await evmBalance(BSC_RPC, address, [{ symbol: 'USDT', addr: USDT_BEP20 }]);
|
native: fmt(trx, nativeDecimals),
|
||||||
return { chain, address, native, tokens };
|
tokens: fmtTokens(tokens, decimalsMap),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
case 'BSC':
|
||||||
case 'ETH': {
|
case 'ETH': {
|
||||||
const { native, tokens } = await evmBalance(ETH_RPC, address, [{ symbol: 'USDT', addr: USDT_ERC20 }]);
|
const tokenList = getEvmTokens(chain);
|
||||||
return { chain, address, native, tokens };
|
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]));
|
||||||
|
return {
|
||||||
|
chain, address,
|
||||||
|
native: fmt(native, nativeDecimals),
|
||||||
|
tokens: fmtTokens(tokens, decimalsMap),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'SOL': {
|
||||||
|
const { native, tokens } = await solBalance(address);
|
||||||
|
const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals]));
|
||||||
|
return {
|
||||||
|
chain, address,
|
||||||
|
native: fmt(native, nativeDecimals),
|
||||||
|
tokens: fmtTokens(tokens, decimalsMap),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
case 'SOL':
|
|
||||||
return { chain, address, native: await solBalance(address) };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,29 +135,40 @@ async function btcBalance(address: string): Promise<string> {
|
|||||||
return sat.toString();
|
return sat.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function trxBalance(address: string): Promise<{ trx: string; usdt: string }> {
|
async function trxBalance(address: string): Promise<{ trx: string; tokens: Record<string, string> }> {
|
||||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||||
|
|
||||||
const accRes = await fetchJson(`${TRONGRID}/v1/accounts/${address}`, { headers });
|
const accRes = await fetchJson(`${TRONGRID}/v1/accounts/${address}`, { headers });
|
||||||
const trx = accRes.data?.[0]?.balance ? String(accRes.data[0].balance) : '0';
|
const trx = accRes.data?.[0]?.balance ? String(accRes.data[0].balance) : '0';
|
||||||
|
|
||||||
// USDT TRC20 balance
|
// Parallel TRC20 balanceOf для всех зарегистрированных токенов
|
||||||
const usdtRes = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
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',
|
method: 'POST',
|
||||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
owner_address: address,
|
owner_address: address,
|
||||||
contract_address: USDT_TRC20,
|
contract_address: contractAddress,
|
||||||
function_selector: 'balanceOf(address)',
|
function_selector: 'balanceOf(address)',
|
||||||
parameter: tronAddressToHex(address).padStart(64, '0'),
|
parameter: addrHex,
|
||||||
visible: true,
|
visible: true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const usdtHex = usdtRes.constant_result?.[0];
|
const hex = res.constant_result?.[0];
|
||||||
const usdt = usdtHex && !/^0+$/.test(usdtHex) ? BigInt('0x' + usdtHex).toString() : '0';
|
tokens[symbol] = hex && !/^0+$/.test(hex) ? BigInt('0x' + hex).toString() : '0';
|
||||||
|
} catch {
|
||||||
|
tokens[symbol] = '0';
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return { trx, usdt };
|
return { trx, tokens };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function evmBalance(
|
async function evmBalance(
|
||||||
@@ -112,8 +195,9 @@ async function evmBalance(
|
|||||||
return { native: native.toString(), tokens: tokenBalances };
|
return { native: native.toString(), tokens: tokenBalances };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function solBalance(address: string): Promise<string> {
|
async function solBalance(address: string): Promise<{ native: string; tokens: Record<string, string> }> {
|
||||||
const res = await fetchJson(SOL_RPC, {
|
// 1) Native SOL balance
|
||||||
|
const nativeRes = await fetchJson(SOL_RPC, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -123,7 +207,48 @@ async function solBalance(address: string): Promise<string> {
|
|||||||
params: [address],
|
params: [address],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return String(res.result?.value ?? 0);
|
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 ───────────────────────
|
// ─────────────────────── TRANSACTIONS ───────────────────────
|
||||||
|
|||||||
@@ -14,16 +14,52 @@ import * as bip39 from 'bip39';
|
|||||||
import { BIP32Factory } from 'bip32';
|
import { BIP32Factory } from 'bip32';
|
||||||
import * as ecc from 'tiny-secp256k1';
|
import * as ecc from 'tiny-secp256k1';
|
||||||
import * as bitcoin from 'bitcoinjs-lib';
|
import * as bitcoin from 'bitcoinjs-lib';
|
||||||
import { Keypair, Connection, PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
|
import { Keypair, Connection, PublicKey, SystemProgram, Transaction, ComputeBudgetProgram } from '@solana/web3.js';
|
||||||
import { derivePath } from 'ed25519-hd-key';
|
import { derivePath } from 'ed25519-hd-key';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service';
|
import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service';
|
||||||
|
import { getEvmFeeForTier, type FeeTier } from './gas-oracle.service';
|
||||||
import type { ChainCode } from '../lib/address-validators';
|
import type { ChainCode } from '../lib/address-validators';
|
||||||
|
|
||||||
const bip32 = BIP32Factory(ecc);
|
const bip32 = BIP32Factory(ecc);
|
||||||
|
|
||||||
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
|
// H29 — multiple RPC endpoints для failover. Если основной 5xx — переключаемся на следующий.
|
||||||
const BSC_RPC = 'https://bsc-dataseed.binance.org';
|
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',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Backward-compat exports (для других модулей которые могут использовать)
|
||||||
|
const ETH_RPC = ETH_RPCS[0];
|
||||||
|
const BSC_RPC = BSC_RPCS[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try RPC providers в order until one succeeds. Returns first-working StaticJsonRpcProvider.
|
||||||
|
*/
|
||||||
|
async function pickProvider(rpcs: string[], chainId: number): Promise<ethers.providers.StaticJsonRpcProvider> {
|
||||||
|
let lastErr: any;
|
||||||
|
for (const url of rpcs) {
|
||||||
|
const p = new ethers.providers.StaticJsonRpcProvider(url, chainId);
|
||||||
|
try {
|
||||||
|
// Quick aliveness check (3s timeout) — `eth_blockNumber` is cheapest read.
|
||||||
|
await Promise.race([
|
||||||
|
p.getBlockNumber(),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('rpc_alive_timeout')), 3000)),
|
||||||
|
]);
|
||||||
|
return p;
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`All ${rpcs.length} RPC endpoints failed: ${lastErr?.message || lastErr}`);
|
||||||
|
}
|
||||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||||||
const TRONGRID = 'https://api.trongrid.io';
|
const TRONGRID = 'https://api.trongrid.io';
|
||||||
const BLOCKSTREAM = 'https://blockstream.info/api';
|
const BLOCKSTREAM = 'https://blockstream.info/api';
|
||||||
@@ -46,6 +82,29 @@ export interface SendParams {
|
|||||||
amount: string;
|
amount: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
expectedFromAddress: string;
|
expectedFromAddress: string;
|
||||||
|
feeTier?: FeeTier;
|
||||||
|
// default 'normal'. Применяется для:
|
||||||
|
// ETH/BSC — eth_feeHistory p25/p50/p75 priority
|
||||||
|
// BTC — blockstream targets 144/6/1 блок
|
||||||
|
// TRX/SOL — игнорится (TRX = fixed fee_limit cap, SOL = no priority fee)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawEvmTx {
|
||||||
|
to: string;
|
||||||
|
data: string;
|
||||||
|
value: string;
|
||||||
|
chainId: number;
|
||||||
|
gas: string;
|
||||||
|
maxFeePerGas: string;
|
||||||
|
maxPriorityFeePerGas: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawEvmSignParams {
|
||||||
|
chain: 'ETH' | 'BSC';
|
||||||
|
mnemonic: string;
|
||||||
|
expectedFromAddress: string;
|
||||||
|
tx: RawEvmTx;
|
||||||
|
feeTier?: FeeTier; // если задан → override maxFee/maxPriority из tx актуальными из feeHistory
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signAndBroadcast(p: SendParams): Promise<{ txid: string }> {
|
export async function signAndBroadcast(p: SendParams): Promise<{ txid: string }> {
|
||||||
@@ -58,6 +117,89 @@ export async function signAndBroadcast(p: SendParams): Promise<{ txid: string }>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign + broadcast ARBITRARY EVM transaction (used for Relay/Swap unsigned tx from /execute).
|
||||||
|
*
|
||||||
|
* ⚠️ SECURITY: подписывает arbitrary `to` + arbitrary `data` (calldata) — UI compromise
|
||||||
|
* может подсунуть `approve(attacker, MAX)` или drain-call. Для test/dev это OK,
|
||||||
|
* для production надо whitelist'ить `to` против known Relay routers ИЛИ требовать
|
||||||
|
* Relay attestation (signed quote) от upstream.
|
||||||
|
*
|
||||||
|
* Capы: maxFeePerGas, chainId matches chain.
|
||||||
|
*/
|
||||||
|
export async function signAndBroadcastRawEvm(p: RawEvmSignParams): Promise<{ txid: string }> {
|
||||||
|
const expectedChainId = p.chain === 'ETH' ? 1 : 56;
|
||||||
|
if (p.tx.chainId !== expectedChainId) {
|
||||||
|
throw new Error(`chainId mismatch: tx.chainId=${p.tx.chainId} but chain=${p.chain} (${expectedChainId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||||
|
|
||||||
|
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
|
||||||
|
assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain);
|
||||||
|
|
||||||
|
// H29 — RPC failover
|
||||||
|
const provider = await pickProvider(rpcs, expectedChainId);
|
||||||
|
const signer = wallet.connect(provider);
|
||||||
|
|
||||||
|
// Cap fees против rogue/poisoned quote с insanely-high maxFeePerGas.
|
||||||
|
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
|
||||||
|
|
||||||
|
// H21 — explicit FeeTier validation (защита от internal callers с empty string)
|
||||||
|
if (p.feeTier !== undefined && p.feeTier !== 'slow' && p.feeTier !== 'normal' && p.feeTier !== 'fast') {
|
||||||
|
throw new Error(`Invalid feeTier ${JSON.stringify(p.feeTier)} (expected slow|normal|fast)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если клиент задал feeTier — override fees из Relay quote актуальными из feeHistory.
|
||||||
|
// Иначе используем maxFeePerGas из quote как-есть (legacy путь).
|
||||||
|
let maxFeePerGas: ethers.BigNumber;
|
||||||
|
let maxPriorityFeePerGas: ethers.BigNumber;
|
||||||
|
if (p.feeTier) {
|
||||||
|
const fee = await getEvmFeeForTier(p.chain, p.feeTier);
|
||||||
|
maxFeePerGas = ethers.BigNumber.from(fee.maxFeePerGas);
|
||||||
|
maxPriorityFeePerGas = ethers.BigNumber.from(fee.maxPriorityFeePerGas);
|
||||||
|
} else {
|
||||||
|
maxFeePerGas = ethers.BigNumber.from(p.tx.maxFeePerGas);
|
||||||
|
maxPriorityFeePerGas = ethers.BigNumber.from(p.tx.maxPriorityFeePerGas);
|
||||||
|
}
|
||||||
|
// H26 — оба ограничения: maxFee≤cap И priority≤maxFee (иначе invalid EIP-1559)
|
||||||
|
if (maxFeePerGas.gt(capWei)) {
|
||||||
|
throw new Error(`maxFeePerGas exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
|
||||||
|
}
|
||||||
|
if (maxPriorityFeePerGas.gt(maxFeePerGas)) {
|
||||||
|
throw new Error('maxPriorityFeePerGas must be ≤ maxFeePerGas (invalid EIP-1559 fee config)');
|
||||||
|
}
|
||||||
|
if (maxPriorityFeePerGas.gt(capWei)) {
|
||||||
|
throw new Error(`maxPriorityFeePerGas exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
||||||
|
|
||||||
|
const txRequest: ethers.providers.TransactionRequest = {
|
||||||
|
to: p.tx.to,
|
||||||
|
data: p.tx.data,
|
||||||
|
value: ethers.BigNumber.from(p.tx.value || '0'),
|
||||||
|
chainId: expectedChainId,
|
||||||
|
nonce,
|
||||||
|
gasLimit: ethers.BigNumber.from(p.tx.gas),
|
||||||
|
type: 2,
|
||||||
|
maxFeePerGas,
|
||||||
|
maxPriorityFeePerGas,
|
||||||
|
};
|
||||||
|
|
||||||
|
// H25 — explicit timeout (без него slow RPC stalls Express worker).
|
||||||
|
const sent = await withTimeout(signer.sendTransaction(txRequest), HTTP_TIMEOUT_MS, 'EVM raw broadcast timed out');
|
||||||
|
return { txid: sent.hash };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** H25 helper — Promise.race vs timeout. */
|
||||||
|
function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
p,
|
||||||
|
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(msg)), ms)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function assertAddressMatch(derived: string, expected: string, chain: ChainCode): void {
|
function assertAddressMatch(derived: string, expected: string, chain: ChainCode): void {
|
||||||
const norm = (s: string) =>
|
const norm = (s: string) =>
|
||||||
chain === 'ETH' || chain === 'BSC' ? s.toLowerCase() : s;
|
chain === 'ETH' || chain === 'BSC' ? s.toLowerCase() : s;
|
||||||
@@ -71,25 +213,41 @@ function assertAddressMatch(derived: string, expected: string, chain: ChainCode)
|
|||||||
async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: string): Promise<{ txid: string }> {
|
async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: string): Promise<{ txid: string }> {
|
||||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
|
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
|
||||||
assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain);
|
assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain);
|
||||||
const provider = new ethers.providers.StaticJsonRpcProvider(rpc, chainId);
|
// H29 — RPC failover (выбираем working RPC из списка для chain)
|
||||||
|
const rpcs = chainId === 1 ? ETH_RPCS : BSC_RPCS;
|
||||||
|
const provider = await pickProvider(rpcs, chainId);
|
||||||
const signer = wallet.connect(provider);
|
const signer = wallet.connect(provider);
|
||||||
|
|
||||||
const feeData = await provider.getFeeData();
|
// Gas из feeHistory (slow/normal/fast tier) — заменяет старый provider.getFeeData() который
|
||||||
|
// на BSC возвращал inflated values (~1.5 gwei вместо реальных ~0.05-0.1).
|
||||||
|
const evmChain = p.chain === 'ETH' || p.chain === 'BSC' ? p.chain : null;
|
||||||
|
if (!evmChain) {
|
||||||
|
throw new Error(`sendEvm called with non-EVM chain ${p.chain}`);
|
||||||
|
}
|
||||||
|
// H21 — explicit tier validation (empty string defensive guard)
|
||||||
|
if (p.feeTier !== undefined && p.feeTier !== 'slow' && p.feeTier !== 'normal' && p.feeTier !== 'fast') {
|
||||||
|
throw new Error(`Invalid feeTier ${JSON.stringify(p.feeTier)} (expected slow|normal|fast)`);
|
||||||
|
}
|
||||||
|
const tier: FeeTier = p.feeTier ?? 'normal';
|
||||||
|
const fee = await getEvmFeeForTier(evmChain, tier);
|
||||||
|
|
||||||
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
|
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
|
||||||
const effectiveGasPrice = feeData.maxFeePerGas ?? feeData.gasPrice;
|
const maxFeePerGas = ethers.BigNumber.from(fee.maxFeePerGas);
|
||||||
if (!effectiveGasPrice || effectiveGasPrice.gt(capWei)) {
|
const maxPriorityFeePerGas = ethers.BigNumber.from(fee.maxPriorityFeePerGas);
|
||||||
throw new Error(`Gas price exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
|
// H26 — both caps + priority ≤ maxFee invariant
|
||||||
|
if (maxFeePerGas.gt(capWei)) {
|
||||||
|
throw new Error(`maxFeePerGas exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
|
||||||
|
}
|
||||||
|
if (maxPriorityFeePerGas.gt(maxFeePerGas)) {
|
||||||
|
throw new Error('maxPriorityFeePerGas must be ≤ maxFeePerGas (invalid EIP-1559)');
|
||||||
|
}
|
||||||
|
if (maxPriorityFeePerGas.gt(capWei)) {
|
||||||
|
throw new Error(`maxPriorityFeePerGas exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
||||||
|
|
||||||
// Принудительно EIP-1559 (type 2). ETH и BSC оба поддерживают с 2021.
|
const effectiveGasPrice = maxFeePerGas; // for balance estimation
|
||||||
// Если feeData не вернул maxFeePerGas — fallback но всё равно type 2 с computed cap.
|
|
||||||
const maxFeePerGas = feeData.maxFeePerGas ?? effectiveGasPrice;
|
|
||||||
const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.BigNumber.from(0);
|
|
||||||
if (maxFeePerGas.gt(capWei)) {
|
|
||||||
throw new Error(`maxFeePerGas exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
|
|
||||||
}
|
|
||||||
const feeFields: Partial<ethers.providers.TransactionRequest> = {
|
const feeFields: Partial<ethers.providers.TransactionRequest> = {
|
||||||
type: 2,
|
type: 2,
|
||||||
maxFeePerGas,
|
maxFeePerGas,
|
||||||
@@ -117,17 +275,31 @@ async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: st
|
|||||||
throw new Error('Insufficient token balance');
|
throw new Error('Insufficient token balance');
|
||||||
}
|
}
|
||||||
const nativeBal = await provider.getBalance(wallet.address);
|
const nativeBal = await provider.getBalance(wallet.address);
|
||||||
const estGas = ethers.BigNumber.from(80000);
|
const data = iface.encodeFunctionData('transfer', [p.to, p.amount]);
|
||||||
|
// H10 — actual estimateGas + 20% safety. Hardcoded 80000 was too low for cold
|
||||||
|
// storage slots (first transfer to recipient SSTORE costs 81-90k → OOG burn).
|
||||||
|
let estGas: ethers.BigNumber;
|
||||||
|
try {
|
||||||
|
const estimated = await provider.estimateGas({ from: wallet.address, to: usdtAddr, data, value: 0 });
|
||||||
|
estGas = estimated.mul(120).div(100); // +20%
|
||||||
|
// Floor 60k (minimum realistic), ceiling 200k (sanity)
|
||||||
|
const minGas = ethers.BigNumber.from(60000);
|
||||||
|
const maxGas = ethers.BigNumber.from(200000);
|
||||||
|
if (estGas.lt(minGas)) estGas = minGas;
|
||||||
|
if (estGas.gt(maxGas)) estGas = maxGas;
|
||||||
|
} catch {
|
||||||
|
estGas = ethers.BigNumber.from(100000); // fallback если RPC estimateGas fails
|
||||||
|
}
|
||||||
if (nativeBal.lt(effectiveGasPrice.mul(estGas))) {
|
if (nativeBal.lt(effectiveGasPrice.mul(estGas))) {
|
||||||
throw new Error('Insufficient native balance for gas');
|
throw new Error('Insufficient native balance for gas');
|
||||||
}
|
}
|
||||||
const data = iface.encodeFunctionData('transfer', [p.to, p.amount]);
|
|
||||||
tx = { to: usdtAddr, data, value: 0, chainId, nonce, gasLimit: estGas, ...feeFields };
|
tx = { to: usdtAddr, data, value: 0, chainId, nonce, gasLimit: estGas, ...feeFields };
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Token ${p.token} not supported on chainId ${chainId}`);
|
throw new Error(`Token ${p.token} not supported on chainId ${chainId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sent = await signer.sendTransaction(tx);
|
// H25 — explicit timeout, иначе slow RPC stalls Express worker indefinitely
|
||||||
|
const sent = await withTimeout(signer.sendTransaction(tx), HTTP_TIMEOUT_MS, 'EVM send broadcast timed out');
|
||||||
return { txid: sent.hash };
|
return { txid: sent.hash };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,8 +318,41 @@ async function sendSol(p: SendParams): Promise<{ txid: string }> {
|
|||||||
const keypair = Keypair.fromSeed(key);
|
const keypair = Keypair.fromSeed(key);
|
||||||
assertAddressMatch(keypair.publicKey.toBase58(), p.expectedFromAddress, 'SOL');
|
assertAddressMatch(keypair.publicKey.toBase58(), p.expectedFromAddress, 'SOL');
|
||||||
|
|
||||||
const conn = new Connection(SOL_RPC, 'confirmed');
|
// C10 — lamports precision: @solana/web3.js converts BigInt → Number internally
|
||||||
|
// (u64 layout). Above 2^53 lamports = silent truncation. Reject early.
|
||||||
|
const lamports = BigInt(p.amount);
|
||||||
|
const MAX_SAFE_LAMPORTS = BigInt(Number.MAX_SAFE_INTEGER);
|
||||||
|
if (lamports > MAX_SAFE_LAMPORTS) {
|
||||||
|
throw new Error(`SOL amount ${p.amount} lamports exceeds Number precision (max ${MAX_SAFE_LAMPORTS}); split into multiple sends`);
|
||||||
|
}
|
||||||
|
if (lamports <= 0n) {
|
||||||
|
throw new Error('SOL amount must be positive');
|
||||||
|
}
|
||||||
|
|
||||||
|
// H41 — singleton Connection (per-call new() leaks WebSocket subscriptions)
|
||||||
|
const conn = getSolConnection();
|
||||||
const toPk = new PublicKey(p.to);
|
const toPk = new PublicKey(p.to);
|
||||||
|
|
||||||
|
// C11 — rent-exempt minimum check. Если recipient — fresh account и amount меньше
|
||||||
|
// rent-exempt минимума, tx fails ПОСЛЕ broadcast (5000 lamports fee burned, no transfer).
|
||||||
|
// Pre-check сохраняет fee + user-facing error.
|
||||||
|
try {
|
||||||
|
const accountInfo = await conn.getAccountInfo(toPk);
|
||||||
|
if (accountInfo === null) {
|
||||||
|
const rentMin = BigInt(await conn.getMinimumBalanceForRentExemption(0));
|
||||||
|
if (lamports < rentMin) {
|
||||||
|
throw new Error(`SOL recipient is fresh account; amount ${lamports} lamports < rent-exempt minimum ${rentMin}. Send at least ${rentMin} lamports to create account.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (preErr: any) {
|
||||||
|
// Network error checking — proceed (broadcast will surface real error)
|
||||||
|
if (!preErr.message?.includes('rent-exempt')) {
|
||||||
|
// только network/RPC failures, не наш own throw
|
||||||
|
} else {
|
||||||
|
throw preErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
|
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
|
||||||
|
|
||||||
const tx = new Transaction({
|
const tx = new Transaction({
|
||||||
@@ -155,26 +360,50 @@ async function sendSol(p: SendParams): Promise<{ txid: string }> {
|
|||||||
blockhash,
|
blockhash,
|
||||||
lastValidBlockHeight,
|
lastValidBlockHeight,
|
||||||
});
|
});
|
||||||
|
// H40 — compute-unit price для priority fee (tiers slow/normal/fast).
|
||||||
|
// Без этого tx может dropped в congestion. Default 'normal' = 1_000 microLamports.
|
||||||
|
const tier = p.feeTier ?? 'normal';
|
||||||
|
const cuPrice = tier === 'fast' ? 10_000n : tier === 'slow' ? 0n : 1_000n;
|
||||||
|
if (cuPrice > 0n) {
|
||||||
|
tx.add(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: cuPrice }));
|
||||||
|
}
|
||||||
tx.add(
|
tx.add(
|
||||||
SystemProgram.transfer({
|
SystemProgram.transfer({
|
||||||
fromPubkey: keypair.publicKey,
|
fromPubkey: keypair.publicKey,
|
||||||
toPubkey: toPk,
|
toPubkey: toPk,
|
||||||
lamports: BigInt(p.amount),
|
lamports,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
tx.sign(keypair);
|
tx.sign(keypair);
|
||||||
|
|
||||||
const sig = await conn.sendRawTransaction(tx.serialize());
|
const sig = await conn.sendRawTransaction(tx.serialize());
|
||||||
|
|
||||||
|
// H37 — distinguished error categories
|
||||||
try {
|
try {
|
||||||
await conn.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, 'confirmed');
|
await conn.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, 'confirmed');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw new Error(`SOL tx submitted but confirmation timed out (sig=${sig}): ${err.message}`);
|
const name = err?.name || '';
|
||||||
|
if (name === 'TransactionExpiredBlockheightExceededError') {
|
||||||
|
throw new Error(`SOL tx EXPIRED (blockhash invalid, tx will never confirm). sig=${sig}`);
|
||||||
|
}
|
||||||
|
if (name === 'TransactionExpiredTimeoutError') {
|
||||||
|
throw new Error(`SOL tx unconfirmed after timeout (may still land). sig=${sig}`);
|
||||||
|
}
|
||||||
|
throw new Error(`SOL confirm error (${name}): ${err.message}. sig=${sig}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { txid: sig };
|
return { txid: sig };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// H41 — singleton SOL Connection. Reusing avoids WebSocket leak under load.
|
||||||
|
let _solConnection: Connection | null = null;
|
||||||
|
function getSolConnection(): Connection {
|
||||||
|
if (!_solConnection) {
|
||||||
|
_solConnection = new Connection(SOL_RPC, 'confirmed');
|
||||||
|
}
|
||||||
|
return _solConnection;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── BITCOIN ───
|
// ─── BITCOIN ───
|
||||||
|
|
||||||
async function sendBtc(p: SendParams): Promise<{ txid: string }> {
|
async function sendBtc(p: SendParams): Promise<{ txid: string }> {
|
||||||
@@ -199,8 +428,22 @@ async function sendBtc(p: SendParams): Promise<{ txid: string }> {
|
|||||||
]);
|
]);
|
||||||
const utxos = ((utxosRes as any[]) || []).filter((u) => u.status?.confirmed);
|
const utxos = ((utxosRes as any[]) || []).filter((u) => u.status?.confirmed);
|
||||||
const feeMap = feesRes as Record<string, number>;
|
const feeMap = feesRes as Record<string, number>;
|
||||||
// Floor 15 sat/vB — иначе tx может реджектиться по min-relay-fee при congestion.
|
// Tier-based BTC fee target:
|
||||||
const feeRate = Math.max(Math.ceil(feeMap['1'] ?? feeMap['3'] ?? feeMap['6'] ?? 15), 15);
|
// slow = '144' блоков (~1 сутки) — самый дешёвый
|
||||||
|
// normal = '6' блоков (~1 час) — DEFAULT, ~5-10× дешевле чем '1'
|
||||||
|
// fast = '1' блок (~10 мин) — premium
|
||||||
|
// Floor 2 sat/vB — current bitcoin min-relay-fee на большинстве нод (1 на дефолтных, 2 на mempool.space).
|
||||||
|
// Раньше floor был 15 sat/vB и target '1' — переплачивали в среднем ×10.
|
||||||
|
const btcTier = p.feeTier ?? 'normal';
|
||||||
|
const targetByTier: Record<string, string> = { slow: '144', normal: '6', fast: '1' };
|
||||||
|
const target = targetByTier[btcTier];
|
||||||
|
// C15 — feeMap defensive parsing: если значение не number (RPC bug / malicious resp),
|
||||||
|
// Math.ceil(NaN)=NaN → BigInt(NaN) throws → производственный 500. Coerce + sanity.
|
||||||
|
const rawCandidate = feeMap[target] ?? feeMap['6'] ?? feeMap['3'] ?? feeMap['1'];
|
||||||
|
const rawNum = typeof rawCandidate === 'number' && Number.isFinite(rawCandidate) && rawCandidate > 0
|
||||||
|
? rawCandidate
|
||||||
|
: 2;
|
||||||
|
const feeRate = Math.max(Math.ceil(rawNum), 2);
|
||||||
|
|
||||||
const amountSat = BigInt(p.amount);
|
const amountSat = BigInt(p.amount);
|
||||||
if (amountSat > BigInt(Number.MAX_SAFE_INTEGER)) {
|
if (amountSat > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||||
@@ -210,6 +453,17 @@ async function sendBtc(p: SendParams): Promise<{ txid: string }> {
|
|||||||
utxos.sort((a, b) => b.value - a.value);
|
utxos.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
const psbt = new bitcoin.Psbt({ network });
|
const psbt = new bitcoin.Psbt({ network });
|
||||||
|
// H34 — anti-fee-sniping: locktime=tipHeight предотвращает miner re-org для steal этого fee
|
||||||
|
// (стандарт Bitcoin Core / Electrum). Best-effort; если /blocks/tip/height down, оставляем 0.
|
||||||
|
try {
|
||||||
|
const tipHeightRes = await fetchJson(`${BLOCKSTREAM}/blocks/tip/height`);
|
||||||
|
const tip = typeof tipHeightRes === 'number' ? tipHeightRes : Number(tipHeightRes);
|
||||||
|
if (Number.isFinite(tip) && tip > 0) {
|
||||||
|
psbt.setLocktime(tip);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// proceed with locktime=0 — degradation, не блокирует send
|
||||||
|
}
|
||||||
let totalIn = 0n;
|
let totalIn = 0n;
|
||||||
|
|
||||||
const feeFor = (ins: number, outs: number) =>
|
const feeFor = (ins: number, outs: number) =>
|
||||||
@@ -230,16 +484,32 @@ async function sendBtc(p: SendParams): Promise<{ txid: string }> {
|
|||||||
psbt.addInput({
|
psbt.addInput({
|
||||||
hash: u.txid,
|
hash: u.txid,
|
||||||
index: u.vout,
|
index: u.vout,
|
||||||
|
// C12 — RBF (BIP125): sequence ≤ 0xfffffffd позволяет bump fee если tx застрял.
|
||||||
|
// Без этого tx с низкой fee может dropped из mempool через ~14 days = permanent fund lock.
|
||||||
|
sequence: 0xfffffffd,
|
||||||
witnessUtxo: { script: payment.output, value: u.value },
|
witnessUtxo: { script: payment.output, value: u.value },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
psbt.addOutput({ address: p.to, value: Number(amountSat) });
|
psbt.addOutput({ address: p.to, value: Number(amountSat) });
|
||||||
|
|
||||||
|
// C13 — change dust handling. Если change ≤ 294 sat (P2WPKH dust threshold), он
|
||||||
|
// силtently сжигается в miner fee (без warning). Reject explicitly, чтобы юзер
|
||||||
|
// знал что надо изменить сумму. Иначе user может терять ~$0.20 per send invisibly.
|
||||||
const fee = feeFor(selectedUtxos.length, 2);
|
const fee = feeFor(selectedUtxos.length, 2);
|
||||||
const change = totalIn - amountSat - fee;
|
const change = totalIn - amountSat - fee;
|
||||||
if (change > 294n) {
|
const DUST_THRESHOLD = 294n;
|
||||||
|
if (change < 0n) {
|
||||||
|
throw new Error(`BTC insufficient balance (totalIn=${totalIn} sat, amount=${amountSat}, fee=${fee})`);
|
||||||
|
}
|
||||||
|
if (change === 0n) {
|
||||||
|
// Точно равно — no change output needed
|
||||||
|
} else if (change > DUST_THRESHOLD) {
|
||||||
psbt.addOutput({ address: fromAddr, value: Number(change) });
|
psbt.addOutput({ address: fromAddr, value: Number(change) });
|
||||||
|
} else {
|
||||||
|
// change > 0 но ≤ dust — нельзя добавить как output (network reject)
|
||||||
|
// и не нужно burning в fee silently. Reject с действенной подсказкой.
|
||||||
|
throw new Error(`BTC change ${change} sat is below dust threshold (${DUST_THRESHOLD}). Reduce amount by ${change} sat to consolidate, or increase amount to spend full UTXO.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < selectedUtxos.length; i++) {
|
for (let i = 0; i < selectedUtxos.length; i++) {
|
||||||
@@ -283,6 +553,17 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
|
|||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||||
|
|
||||||
|
// C4 — TRX amount precision: Number(p.amount) silently rounds выше 2^53 sun (~9B TRX).
|
||||||
|
// BigInt assertion гарантирует что мы не silently dropped digits.
|
||||||
|
const amountBig = BigInt(p.amount);
|
||||||
|
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); // 9_007_199_254_740_991
|
||||||
|
if (amountBig > MAX_SAFE_BIGINT) {
|
||||||
|
throw new Error(`TRX amount ${p.amount} exceeds Number precision (max ${MAX_SAFE_BIGINT} sun = ~9B TRX); split into multiple sends`);
|
||||||
|
}
|
||||||
|
if (amountBig <= 0n) {
|
||||||
|
throw new Error('TRX amount must be positive');
|
||||||
|
}
|
||||||
|
|
||||||
let txBody: any;
|
let txBody: any;
|
||||||
if (!p.token) {
|
if (!p.token) {
|
||||||
const built = await fetchJson(`${TRONGRID}/wallet/createtransaction`, {
|
const built = await fetchJson(`${TRONGRID}/wallet/createtransaction`, {
|
||||||
@@ -291,7 +572,7 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
owner_address: fromTronAddr,
|
owner_address: fromTronAddr,
|
||||||
to_address: p.to,
|
to_address: p.to,
|
||||||
amount: Number(p.amount),
|
amount: Number(amountBig), // safe — checked ≤ MAX_SAFE_INTEGER выше
|
||||||
visible: true,
|
visible: true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -308,7 +589,9 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
|
|||||||
contract_address: USDT_TRC20,
|
contract_address: USDT_TRC20,
|
||||||
function_selector: 'transfer(address,uint256)',
|
function_selector: 'transfer(address,uint256)',
|
||||||
parameter: param,
|
parameter: param,
|
||||||
fee_limit: 100_000_000,
|
// 30 TRX cap — реальный USDT transfer обычно жжёт 15-30 TRX без Energy,
|
||||||
|
// ~0 с Energy. Раньше было 100 TRX — это cap, не actual fee, но завышен.
|
||||||
|
fee_limit: 30_000_000,
|
||||||
call_value: 0,
|
call_value: 0,
|
||||||
visible: true,
|
visible: true,
|
||||||
}),
|
}),
|
||||||
@@ -400,22 +683,38 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
|
|||||||
// Sign verified txID
|
// Sign verified txID
|
||||||
const sk = new ethers.utils.SigningKey(wallet.privateKey);
|
const sk = new ethers.utils.SigningKey(wallet.privateKey);
|
||||||
const sig = sk.signDigest('0x' + txBody.txID);
|
const sig = sk.signDigest('0x' + txBody.txID);
|
||||||
|
// H42 — recoveryParam должен быть 0 или 1 строго. Undefined fallback на 0 даёт
|
||||||
|
// подпись recoverable к НЕПРАВИЛЬНОМУ public key → tx подписана но broadcast reject.
|
||||||
|
if (sig.recoveryParam !== 0 && sig.recoveryParam !== 1) {
|
||||||
|
throw new Error(`TRX signing: invalid recoveryParam ${sig.recoveryParam} (expected 0 or 1)`);
|
||||||
|
}
|
||||||
const sigHex =
|
const sigHex =
|
||||||
sig.r.slice(2) +
|
sig.r.slice(2) +
|
||||||
sig.s.slice(2) +
|
sig.s.slice(2) +
|
||||||
(sig.recoveryParam ?? 0).toString(16).padStart(2, '0');
|
sig.recoveryParam.toString(16).padStart(2, '0');
|
||||||
|
|
||||||
txBody.signature = [sigHex];
|
// H45 — clean payload to broadcast (не пересылаем upstream-injected лишние поля).
|
||||||
|
// Это defense-in-depth: компрометированный TronGrid не сможет пропихнуть extra fields
|
||||||
|
// через broadcast endpoint обратно к самому себе.
|
||||||
|
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`, {
|
const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(txBody),
|
body: JSON.stringify(cleanTxBody),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!broadcast?.result) {
|
if (!broadcast?.result) {
|
||||||
|
// H44 — include `code` для operators (DUP_TRANSACTION_ERROR, NOT_ENOUGH_EFFECTIVE_CONNECTION, etc.)
|
||||||
const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown';
|
const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown';
|
||||||
throw new Error(`TRX broadcast failed: ${msg.slice(0, 200)}`);
|
const code = broadcast?.code || 'NO_CODE';
|
||||||
|
throw new Error(`TRX broadcast failed [${code}]: ${msg.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { txid: txBody.txID };
|
return { txid: txBody.txID };
|
||||||
@@ -425,15 +724,65 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
|
|||||||
|
|
||||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode TRON base58check address → 20-byte hex (without 0x41 prefix, without checksum).
|
||||||
|
*
|
||||||
|
* C5 fix: правильный base58check decoder с проверкой:
|
||||||
|
* - length 25 bytes after decode
|
||||||
|
* - prefix byte = 0x41 (TRON mainnet)
|
||||||
|
* - SHA256(SHA256(payload))[0:4] === checksum bytes (matches TRON spec)
|
||||||
|
*
|
||||||
|
* Если любая проверка failed → throws. Это критично потому что результат используется
|
||||||
|
* в MITM защите (parameter bit-perfect compare); garbage из этого helper'а silently
|
||||||
|
* disable защиту.
|
||||||
|
*/
|
||||||
function tronAddressToHex(address: string): string {
|
function tronAddressToHex(address: string): string {
|
||||||
|
if (typeof address !== 'string' || address.length === 0) {
|
||||||
|
throw new Error('Invalid TRON address: empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: base58 decode
|
||||||
let num = 0n;
|
let num = 0n;
|
||||||
for (const ch of address) {
|
for (const ch of address) {
|
||||||
const i = BASE58_ALPHABET.indexOf(ch);
|
const i = BASE58_ALPHABET.indexOf(ch);
|
||||||
if (i === -1) throw new Error('Invalid base58 character in TRON address');
|
if (i === -1) throw new Error('Invalid base58 character in TRON address');
|
||||||
num = num * 58n + BigInt(i);
|
num = num * 58n + BigInt(i);
|
||||||
}
|
}
|
||||||
const hex = num.toString(16).padStart(50, '0');
|
|
||||||
return hex.slice(2, 42);
|
// Step 2: convert BigInt to bytes — account для leading '1's = leading zero bytes
|
||||||
|
let hex = num.toString(16);
|
||||||
|
if (hex.length % 2 !== 0) hex = '0' + hex;
|
||||||
|
let bytes = Buffer.from(hex, 'hex');
|
||||||
|
|
||||||
|
// Count leading '1's в base58 = leading zero bytes
|
||||||
|
let leadingOnes = 0;
|
||||||
|
for (const ch of address) {
|
||||||
|
if (ch === '1') leadingOnes++;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
if (leadingOnes > 0) {
|
||||||
|
bytes = Buffer.concat([Buffer.alloc(leadingOnes), bytes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: TRON address = 25 bytes (1 prefix + 20 addr + 4 checksum)
|
||||||
|
if (bytes.length !== 25) {
|
||||||
|
throw new Error(`Invalid TRON address length: expected 25 bytes, got ${bytes.length}`);
|
||||||
|
}
|
||||||
|
if (bytes[0] !== 0x41) {
|
||||||
|
throw new Error(`Invalid TRON address prefix: expected 0x41, got 0x${bytes[0].toString(16)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: verify SHA256d checksum
|
||||||
|
const payload = bytes.subarray(0, 21);
|
||||||
|
const expectedChecksum = bytes.subarray(21, 25);
|
||||||
|
const h1 = createHash('sha256').update(payload).digest();
|
||||||
|
const h2 = createHash('sha256').update(h1).digest();
|
||||||
|
if (!h2.subarray(0, 4).equals(expectedChecksum)) {
|
||||||
|
throw new Error('Invalid TRON address checksum');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: return 20-byte hex (без 0x41 prefix)
|
||||||
|
return bytes.subarray(1, 21).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
||||||
|
|||||||
@@ -83,6 +83,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"FormattedAmount": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"raw": { "type": "string", "description": "Smallest units (wei/sat/sun/lamports), string-encoded BigInt" },
|
||||||
|
"formatted": { "type": "string", "description": "Human-readable decimal", "example": "0.003" },
|
||||||
|
"decimals": { "type": "integer", "description": "Decimals of the chain/token", "example": 18 }
|
||||||
|
}
|
||||||
|
},
|
||||||
"BalanceResponse": {
|
"BalanceResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -92,11 +100,11 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"chain": { "$ref": "#/components/schemas/Chain" },
|
"chain": { "$ref": "#/components/schemas/Chain" },
|
||||||
"address": { "type": "string" },
|
"address": { "type": "string" },
|
||||||
"native": { "type": "string", "description": "Balance в smallest units (sat/wei/lamports/sun)" },
|
"native": { "$ref": "#/components/schemas/FormattedAmount" },
|
||||||
"tokens": {
|
"tokens": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": { "type": "string" },
|
"description": "Map symbol → FormattedAmount. Содержит все известные токены chain'а (ETH: USDT/USDC/DAI/WBTC/LINK/UNI, BSC: USDT/USDC/DOGE/WBNB/BUSD, TRX: USDT/USDC, SOL: 14 токенов)",
|
||||||
"example": { "USDT": "12345678" }
|
"additionalProperties": { "$ref": "#/components/schemas/FormattedAmount" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,8 +134,52 @@
|
|||||||
"required": ["to", "amount"],
|
"required": ["to", "amount"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"to": { "type": "string", "description": "Recipient address" },
|
"to": { "type": "string", "description": "Recipient address" },
|
||||||
"amount": { "type": "string", "description": "Amount в smallest units" },
|
"amount": { "type": "string", "description": "Amount в smallest units (wei для EVM, lamports для SOL, sat для BTC, sun для TRX)" },
|
||||||
"token": { "type": "string", "nullable": true, "description": "USDT для TRC20/ERC20/BEP20. Без token = native." }
|
"token": { "type": "string", "nullable": true, "description": "USDT для TRC20/ERC20/BEP20. Без token = native." },
|
||||||
|
"feeTier": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["slow", "normal", "fast"],
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Default 'normal'. ETH/BSC: eth_feeHistory p25/p50/p75 priority. BTC: blockstream targets 144/6/1 блок. TRX/SOL: игнорится."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FeeQuote": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"maxFeePerGas": { "type": "string", "description": "wei (decimal string)" },
|
||||||
|
"maxPriorityFeePerGas": { "type": "string", "description": "wei (decimal string)" },
|
||||||
|
"gweiTotal": { "type": "number" },
|
||||||
|
"gweiPriority": { "type": "number" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FeeTiers": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"chain": { "type": "string", "enum": ["ETH", "BSC"] },
|
||||||
|
"baseFeeGwei": { "type": "number", "description": "Из feeHistory.baseFeePerGas (на BSC ~0)" },
|
||||||
|
"slow": { "$ref": "#/components/schemas/FeeQuote" },
|
||||||
|
"normal": { "$ref": "#/components/schemas/FeeQuote" },
|
||||||
|
"fast": { "$ref": "#/components/schemas/FeeQuote" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SignRawEvmTxRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["to", "data", "value", "chainId", "gas", "maxFeePerGas", "maxPriorityFeePerGas"],
|
||||||
|
"properties": {
|
||||||
|
"to": { "type": "string", "description": "0x-prefixed 40-hex (контракт или EOA)" },
|
||||||
|
"data": { "type": "string", "description": "Calldata 0x-hex (может быть пустым 0x для native send)" },
|
||||||
|
"value": { "type": "string", "description": "wei (decimal string)" },
|
||||||
|
"chainId": { "type": "integer", "description": "1 (ETH) или 56 (BSC) — должен совпадать с path :chain" },
|
||||||
|
"gas": { "type": "string", "description": "gasLimit в decimal" },
|
||||||
|
"maxFeePerGas": { "type": "string", "description": "wei" },
|
||||||
|
"maxPriorityFeePerGas": { "type": "string", "description": "wei" },
|
||||||
|
"feeTier": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["slow", "normal", "fast"],
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Если задан → server переопределит maxFeePerGas/maxPriorityFeePerGas актуальным из eth_feeHistory (полезно если quote от Relay устарел)."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,7 +284,7 @@
|
|||||||
"/wallets/{chain}/send": {
|
"/wallets/{chain}/send": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Custodial send: server signs + broadcasts",
|
"summary": "Custodial send: server signs + broadcasts",
|
||||||
"description": "Юзер на клиенте жмёт 'подтвердить' → клиент шлёт {to, amount, token?}. Сервер расшифровывает мнемонику, деривит chain privkey, подписывает, broadcast'ит. Возвращает txid. Защита: TRX MITM check, EVM gas cap 500 gwei, SOL confirmTransaction, BTC timeout + safety multiplier.",
|
"description": "Юзер на клиенте жмёт 'подтвердить' → клиент шлёт {to, amount, token?, feeTier?}. Сервер расшифровывает мнемонику, деривит chain privkey, подписывает, broadcast'ит. Возвращает txid. Защита: TRX MITM check, EVM gas cap 500 gwei, SOL confirmTransaction, BTC timeout + safety multiplier. На ETH/BSC gas теперь берётся из eth_feeHistory (slow/normal/fast).",
|
||||||
"tags": ["Wallet Ops"],
|
"tags": ["Wallet Ops"],
|
||||||
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }],
|
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@@ -241,13 +293,48 @@
|
|||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": { "description": "Broadcast successful", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TxBroadcastResponse" } } } },
|
"200": { "description": "Broadcast successful", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TxBroadcastResponse" } } } },
|
||||||
"400": { "description": "Invalid input" },
|
"400": { "description": "Invalid input (incl. invalid feeTier)" },
|
||||||
"404": { "description": "Wallet/mnemonic not found" },
|
"404": { "description": "Wallet/mnemonic not found" },
|
||||||
"502": { "description": "Broadcast failed (insufficient balance / RPC error / unsupported)" },
|
"502": { "description": "Broadcast failed (insufficient balance / RPC error / unsupported)" },
|
||||||
"503": { "description": "Crypto service not ready" }
|
"503": { "description": "Crypto service not ready" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/wallets/{chain}/gas-suggestions": {
|
||||||
|
"get": {
|
||||||
|
"summary": "EVM gas oracle (slow/normal/fast)",
|
||||||
|
"description": "Парсит fees через `eth_feeHistory` (последние 5 блоков, percentile p25/p50/p75 priority tips). Возвращает 3 тира с maxFeePerGas/maxPriorityFeePerGas в wei + gwei для display. Floor: ETH=0.5 gwei, BSC=0.05 gwei (защита от dust). Cap: 500 gwei. Только ETH и BSC.",
|
||||||
|
"tags": ["Wallet Ops"],
|
||||||
|
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH", "BSC"] } }],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Fee tiers",
|
||||||
|
"content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "$ref": "#/components/schemas/FeeTiers" } } } } }
|
||||||
|
},
|
||||||
|
"400": { "description": "Non-EVM chain" },
|
||||||
|
"502": { "description": "Upstream RPC error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/wallets/{chain}/sign-raw-evm-tx": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Custodial sign + broadcast arbitrary EVM tx (Relay/Swap unsigned tx)",
|
||||||
|
"description": "Подписывает произвольную EVM tx (например `steps[0].items[0].data` из `/relay/execute/swap`). Сервер расшифровывает mnemonic, деривит privkey, ставит nonce, подписывает type-2 EIP-1559 tx, broadcast'ит. Если задан `feeTier` → переопределяет maxFeePerGas/maxPriority из тела актуальным из eth_feeHistory. ⚠️ Security: подписывает arbitrary `to`+`data` — в production надо whitelist'ить `to` (Relay routers) или требовать Relay attestation. Только ETH(1)/BSC(56).",
|
||||||
|
"tags": ["Wallet Ops"],
|
||||||
|
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH", "BSC"] } }],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SignRawEvmTxRequest" } } }
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Broadcast successful", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TxBroadcastResponse" } } } },
|
||||||
|
"400": { "description": "Invalid input (bad to/data/value, chainId mismatch, invalid feeTier)" },
|
||||||
|
"404": { "description": "Wallet/mnemonic not found" },
|
||||||
|
"502": { "description": "Broadcast failed" },
|
||||||
|
"503": { "description": "Crypto service not ready" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"/btc/utxos/{address}": {
|
"/btc/utxos/{address}": {
|
||||||
"get": {
|
"get": {
|
||||||
@@ -378,19 +465,57 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"/relay/quote/v2": {
|
"/relay/quote": {
|
||||||
"get": { "summary": "Relay bridge quote", "tags": ["Relay"], "responses": { "200": { "description": "Quote" } } }
|
"post": {
|
||||||
|
"summary": "Relay bridge quote (POST с JSON body)",
|
||||||
|
"description": "Прокси к https://api.relay.link/quote. Параметры в body: user, recipient, originChainId, destinationChainId, originCurrency, destinationCurrency, amount (smallest units), tradeType (EXACT_INPUT|EXACT_OUTPUT).",
|
||||||
|
"tags": ["Relay"],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["user", "originChainId", "destinationChainId", "originCurrency", "destinationCurrency", "amount", "tradeType"],
|
||||||
|
"properties": {
|
||||||
|
"user": { "type": "string", "description": "Sender address (0x.. / T.. / SOL pubkey)" },
|
||||||
|
"recipient": { "type": "string", "description": "Обычно тот же что user" },
|
||||||
|
"originChainId": { "type": "integer", "description": "1=ETH, 56=BSC, 728126428=TRON, 792703809=SOL" },
|
||||||
|
"destinationChainId": { "type": "integer" },
|
||||||
|
"originCurrency": { "type": "string", "description": "Token address (EVM: 0x.., SOL: mint, TRX: contract или 'TRX')" },
|
||||||
|
"destinationCurrency": { "type": "string" },
|
||||||
|
"amount": { "type": "string", "description": "smallest units" },
|
||||||
|
"tradeType": { "type": "string", "enum": ["EXACT_INPUT", "EXACT_OUTPUT"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Quote с steps[], fees, details, breakdown" },
|
||||||
|
"502": { "description": "Relay upstream error (приложен upstream JSON для деталей)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"/relay/intents/status/v3": {
|
"/relay/intents/status/v3": {
|
||||||
"get": { "summary": "Relay intent status", "tags": ["Relay"], "responses": { "200": { "description": "Status" } } }
|
"get": {
|
||||||
|
"summary": "Relay intent status",
|
||||||
|
"tags": ["Relay"],
|
||||||
|
"parameters": [{ "name": "requestId", "in": "query", "required": true, "schema": { "type": "string", "description": "Из quote/execute response" } }],
|
||||||
|
"responses": { "200": { "description": "Status" }, "502": { "description": "Relay upstream error" } }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"/relay/execute/{action}": {
|
"/relay/execute/{action}": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Relay execute",
|
"summary": "Relay execute (swap | bridge)",
|
||||||
|
"description": "Принимает ТОТ ЖЕ payload что и /quote и возвращает unsigned tx в steps[].items[].data. Эту tx надо потом подписать (для ETH/BSC — через /wallets/{chain}/sign-raw-evm-tx) и broadcast'нуть. Action whitelist: swap, bridge.",
|
||||||
"tags": ["Relay"],
|
"tags": ["Relay"],
|
||||||
"parameters": [{ "name": "action", "in": "path", "required": true, "schema": { "type": "string" } }],
|
"parameters": [{ "name": "action", "in": "path", "required": true, "schema": { "type": "string", "enum": ["swap", "bridge"] } }],
|
||||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
|
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "description": "Same as /relay/quote body" } } } },
|
||||||
"responses": { "200": { "description": "Result" } }
|
"responses": {
|
||||||
|
"200": { "description": "steps[] with unsigned tx + fees + details" },
|
||||||
|
"502": { "description": "Relay upstream error" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,155 @@
|
|||||||
-- CryptoWallet API — DB schema (idempotent, custodial v5.0)
|
-- ╔══════════════════════════════════════════════════════════════════╗
|
||||||
|
-- ║ CryptoWallet API — Production DB schema (idempotent, custodial) ║
|
||||||
|
-- ║ Применять: psql -h <host> -U postgres_user -d postgres -f ... ║
|
||||||
|
-- ║ Безопасно прогонять повторно на existing БД. ║
|
||||||
|
-- ╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id VARCHAR(26) PRIMARY KEY,
|
id VARCHAR(26) NOT NULL PRIMARY KEY,
|
||||||
email VARCHAR(255) NOT NULL UNIQUE,
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
last_name VARCHAR(255),
|
last_name VARCHAR(128),
|
||||||
first_name VARCHAR(255),
|
first_name VARCHAR(128),
|
||||||
middle_name VARCHAR(255),
|
middle_name VARCHAR(128),
|
||||||
birth_date DATE,
|
birth_date DATE,
|
||||||
|
-- DEPRECATED: исторически generic crypto address; для ETH используем erc20 ниже.
|
||||||
crypto_wallet VARCHAR(255),
|
crypto_wallet VARCHAR(255),
|
||||||
phone VARCHAR(64),
|
phone VARCHAR(16),
|
||||||
bik VARCHAR(64),
|
inn VARCHAR(12),
|
||||||
account_number VARCHAR(64),
|
|
||||||
card_number VARCHAR(64),
|
|
||||||
inn VARCHAR(64),
|
|
||||||
kyc_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
kyc_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
kyc_verified_at TIMESTAMPTZ,
|
kyc_verified_at TIMESTAMP WITH TIME ZONE,
|
||||||
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
encrypted_vault TEXT, -- legacy
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
vault_salt VARCHAR(128), -- legacy
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
encrypted_mnemonic TEXT, -- AES-256-GCM blob (custodial)
|
passport_data VARCHAR(255),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
erc20 VARCHAR(255),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
-- 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
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic') THEN
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE users ADD COLUMN encrypted_mnemonic TEXT;
|
ALTER TABLE users ADD COLUMN encrypted_mnemonic TEXT;
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
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);
|
||||||
-- AES-GCM blob: 12 IV + plaintext + 16 tag.
|
END IF;
|
||||||
-- 12-word mnemonic ~ 116 байт = ~156 base64 chars; 24-word ~ 212 байт = ~284 chars.
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'passport_data') THEN
|
||||||
DO $$
|
ALTER TABLE users ADD COLUMN passport_data VARCHAR(255);
|
||||||
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 140 AND 512));
|
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
|
-- Sanity check на blob size. Floor 100 (worst-case 12-word всё-3char mnemonic):
|
||||||
|
-- plaintext 47 bytes + IV(12) + tag(16) = 75 raw → 100 base64
|
||||||
|
-- typical 12-word: 113 raw → 152 base64; 24-word: 240 raw → 320 base64
|
||||||
|
-- (Раньше floor 140 отвергал ~4% валидных 12-word mnemonics — fixed.)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size') THEN
|
||||||
|
ALTER TABLE users DROP CONSTRAINT users_encrypted_mnemonic_size;
|
||||||
|
END IF;
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_encrypted_mnemonic_size
|
||||||
|
CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 100 AND 512));
|
||||||
|
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 удалении.
|
||||||
|
-- Use is_deleted=true для soft-delete.
|
||||||
CREATE TABLE IF NOT EXISTS wallets (
|
CREATE TABLE IF NOT EXISTS wallets (
|
||||||
id VARCHAR(26) PRIMARY KEY,
|
id VARCHAR(26) NOT NULL PRIMARY KEY,
|
||||||
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
chain VARCHAR(16) NOT NULL,
|
chain VARCHAR(16) NOT NULL,
|
||||||
address VARCHAR(256) NOT NULL,
|
address VARCHAR(128) NOT NULL,
|
||||||
derivation_path VARCHAR(64) NOT NULL,
|
derivation_path VARCHAR(64) NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE (user_id, chain)
|
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_user_id ON wallets(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address);
|
||||||
|
|
||||||
-- sessions table removed — JWT-stateless, не используется в коде.
|
-- Idempotent FK migration: если raised на старой DB с CASCADE — поменять
|
||||||
-- Если существует от старой версии — оператор может drop вручную:
|
DO $$
|
||||||
-- DROP TABLE IF EXISTS sessions CASCADE;
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.referential_constraints
|
||||||
|
WHERE constraint_name LIKE 'wallets_user_id_fkey%' AND delete_rule = 'CASCADE'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE wallets DROP CONSTRAINT IF EXISTS wallets_user_id_fkey;
|
||||||
|
ALTER TABLE wallets ADD CONSTRAINT wallets_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ── AUDIT_LOG (durable sink для критических custodial операций) ─────
|
||||||
|
-- Pre-mutation INSERT 'pending', post-mutation UPDATE 'completed' с txid.
|
||||||
|
-- Если INSERT fails — операция НЕ происходит (fail-secure).
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id VARCHAR(26) NOT NULL PRIMARY KEY,
|
||||||
|
user_id VARCHAR(26) NOT NULL,
|
||||||
|
event VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'success', 'failure')),
|
||||||
|
error_code VARCHAR(64),
|
||||||
|
ip VARCHAR(64),
|
||||||
|
trace_id VARCHAR(64),
|
||||||
|
meta JSONB,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_event_created ON audit_log(event, created_at DESC);
|
||||||
|
|
||||||
|
-- ── IDEMPOTENCY_KEYS (защита от double-spend на retry) ──────────────
|
||||||
|
-- Client шлёт Idempotency-Key header. Pre-mutation INSERT row, post-mutation UPDATE с response.
|
||||||
|
-- На retry — возвращаем cached response без второго broadcast.
|
||||||
|
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||||
|
user_id VARCHAR(26) NOT NULL,
|
||||||
|
key VARCHAR(128) NOT NULL,
|
||||||
|
request_hash VARCHAR(64) NOT NULL,
|
||||||
|
response_status SMALLINT,
|
||||||
|
response_body TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
|
||||||
|
-- Retention cleanup (run via cron): DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours';
|
||||||
|
|||||||
@@ -5,17 +5,16 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: cryptowallet-api
|
container_name: cryptowallet-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Bind to loopback only — TLS termination + WAF на reverse proxy (Caddy/Nginx).
|
# Bind to loopback only — TLS termination + WAF на reverse proxy (Caddy / Nginx).
|
||||||
# Если нужно direct exposure для dev — поменяй на "3001:3001" локально.
|
# Для direct exposure в dev → поменяй на "3001:3001".
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3001:3001"
|
- "127.0.0.1:3001:3001"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
API_PORT: "3001"
|
API_PORT: "3001"
|
||||||
volumes:
|
# Container hardening — post-RCE blast radius minimization.
|
||||||
- ./logs:/app/logs
|
# Audit-логи теперь идут в stdout (не файл), поэтому read_only OK без logs mount.
|
||||||
# Container hardening — post-RCE blast radius minimization
|
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp
|
- /tmp
|
||||||
|
|||||||
13
start.sh
13
start.sh
@@ -26,15 +26,10 @@ if [ "$ENV_MODE" != "600" ]; then
|
|||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Logs dir для audit-log mount — container's app user is uid 1001
|
# NOTE: logs/ директория НЕ нужна — audit-логи теперь в stdout (Docker logs).
|
||||||
mkdir -p logs
|
# Контейнер работает с read_only: true (см. docker-compose.yml).
|
||||||
chmod 750 logs
|
|
||||||
# Если есть права — попытаться выставить нужный owner (требует sudo на host)
|
|
||||||
if [ "$(stat -c %u logs 2>/dev/null)" != "1001" ]; then
|
|
||||||
chown 1001:1001 logs 2>/dev/null || echo "[INFO] chown logs 1001:1001 пропущен (нет прав; audit может не писаться)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[INFO] Building and starting containers..."
|
echo "[INFO] Building and starting container..."
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
echo "[INFO] Waiting for API to become healthy..."
|
echo "[INFO] Waiting for API to become healthy..."
|
||||||
@@ -56,4 +51,4 @@ echo " Перед публичным доступом → настрой revers
|
|||||||
echo "Health: http://127.0.0.1:3001/api/health"
|
echo "Health: http://127.0.0.1:3001/api/health"
|
||||||
echo "Docs: http://127.0.0.1:3001/api/docs"
|
echo "Docs: http://127.0.0.1:3001/api/docs"
|
||||||
echo "Logs: docker compose logs -f api"
|
echo "Logs: docker compose logs -f api"
|
||||||
echo "Audit: tail -f logs/audit.log"
|
echo "Audit events: docker compose logs api | grep '\"level\":\"audit\"'"
|
||||||
|
|||||||
Reference in New Issue
Block a user