diff --git a/.dockerignore b/.dockerignore index afd71e7..b35c44c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,5 @@ **/.DS_Store **/.vscode **/.idea +pastdeploy/ +.seed-backup/ diff --git a/.env.example b/.env.example index 63aace5..d74de58 100644 --- a/.env.example +++ b/.env.example @@ -8,17 +8,19 @@ VAULT_JWT_KID_PATH=jwt/kid VAULT_JWT_KIDS_PREFIX=jwt/kids # CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF) -VAULT_CSRF_PATH= +VAULT_CSRF_PATH=csrf # Crypto master-key для шифрования мнемоник юзеров (AES-256-GCM). # В Vault лежит hex-строка длиной 64 (32 байта). # Положить: vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32) VAULT_CRYPTO_KEY_PATH=crypto/master -# ── JWT ──────────────────────────────────────────────────────────── -# Allowed: RS256 / RS384 / RS512 / ES256 / ES384 / ES512 / EdDSA / PS256 / PS384 / PS512 +# ── JWT (внешний bitok issuer) ───────────────────────────────────── +# bitok-сервис подписывает JWT своим приватником, public key регистрируется +# в Vault под kid'ом (см. VAULT_JWT_KIDS_PREFIX). +# Allowed alg: RS256 / RS384 / RS512 / ES256 / ES384 / ES512 / EdDSA / PS256 / PS384 / PS512 JWT_ALGORITHM=RS256 -JWT_ISSUER=auth-service +JWT_ISSUER=bitok JWT_AUDIENCE=elcsa # ── Server ───────────────────────────────────────────────────────── @@ -31,7 +33,7 @@ LOG_LEVEL=INFO CORS_ORIGINS= CORS_ALLOW_CREDENTIALS=true -# ── External API keys (optional, fallback if Vault doesn't provide) ─ +# ── External API keys (optional, fallback если Vault их не выдаёт) ─ RELAY_API_KEY= TRON_API_KEY= JUPITER_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d3e999 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.env +.env.local +node_modules/ +**/node_modules/ +dist/ +**/dist/ +*.log +logs/ +.DS_Store +.vscode/ +.idea/ diff --git a/Dockerfile b/Dockerfile index 692edc3..d6b12cd 100644 --- a/Dockerfile +++ b/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 RUN corepack enable && corepack prepare pnpm@10.28.2 --activate \ && apk add --no-cache python3 make g++ WORKDIR /app +# ── Stage 1: install ALL deps (incl. devDeps) для build ── FROM base AS deps COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY apps/api/package.json apps/api/ RUN pnpm install --frozen-lockfile --prod=false +# ── Stage 2: TypeScript compile ── FROM base AS build COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules COPY . . RUN cd apps/api && pnpm build +# ── Stage 3: prod-only deps (без devDeps, меньше image) ── FROM base AS prod-deps COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY apps/api/package.json apps/api/ RUN pnpm install --frozen-lockfile --prod +# ── Stage 4: runtime image — minimal surface ── FROM node:20-alpine AS runtime RUN apk add --no-cache tini wget \ && addgroup -S app -g 1001 \ @@ -26,13 +35,11 @@ RUN apk add --no-cache tini wget \ WORKDIR /app/apps/api -COPY --from=prod-deps --chown=app:app /app/node_modules /app/node_modules -COPY --from=prod-deps --chown=app:app /app/apps/api/node_modules ./node_modules -COPY --from=build --chown=app:app /app/apps/api/dist ./dist -COPY --from=build --chown=app:app /app/apps/api/swagger.json ./swagger.json -COPY --from=build --chown=app:app /app/apps/api/package.json ./package.json - -RUN mkdir -p /app/logs && chown -R app:app /app/logs +COPY --from=prod-deps --chown=app:app /app/node_modules /app/node_modules +COPY --from=prod-deps --chown=app:app /app/apps/api/node_modules ./node_modules +COPY --from=build --chown=app:app /app/apps/api/dist ./dist +COPY --from=build --chown=app:app /app/apps/api/swagger.json ./swagger.json +COPY --from=build --chown=app:app /app/apps/api/package.json ./package.json USER app EXPOSE 3001 diff --git a/README.md b/README.md index e20d23f..7f84356 100644 --- a/README.md +++ b/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). - Сервер генерит mnemonic, хранит зашифрованной (AES-256-GCM, master-key из HashiCorp Vault) - Сервер сам подписывает tx по запросу юзера (юзер на клиенте жмёт "подтвердить") -Auth — JWT (BITOK), секреты — HashiCorp Vault (AppRole). +Auth — JWT, выданный сервисом **bitok** (внешний). Секреты — HashiCorp Vault (AppRole). -## Pre-deploy setup (один раз навсегда) +## Pre-deploy setup (один раз) ```bash # 1. Master-key в Vault vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32) -# 2. DB schema -psql -h 72.56.9.76 -U postgres_user -d postgres -f cryptowallet-schema.sql +# 2. CSRF secret в Vault +vault kv put dev-secrets/csrf secret_key=$(openssl rand -hex 32) salt=csrf-salt digest=sha256 + +# 3. DB schema (миграция идемпотентна — безопасно прогонять на existing БД) +psql -h -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= +vault kv put dev-secrets/jwt/kids/ \ + 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 ```bash -scp -P 2222 -r deployserver/ server@176.124.213.102:~/cryptowallet/ -ssh server@176.124.213.102 -p 2222 -cd ~/cryptowallet && cp .env.example .env && nano .env && ./start.sh +# Залить bundle на сервер +scp -P 2222 -r deployserver/ server@:~/cryptowallet/ + +# На сервере: заполнить .env, поднять +ssh server@ -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`. - -## 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 +В `.env` **обязательны**: `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`, `JWT_ISSUER=bitok`, `JWT_AUDIENCE`, `CORS_ORIGINS`. ## Update / Rebuild ```bash -scp -P 2222 -r deployserver/apps server@176.124.213.102:~/cryptowallet/ -ssh server@176.124.213.102 -p 2222 'cd cryptowallet && docker compose up -d --build' +scp -P 2222 -r deployserver/apps server@:~/cryptowallet/ +ssh server@ -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 под реальный трафик diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9f80744..738afcc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -35,17 +35,50 @@ app.use(cookieParser()); app.use(traceMiddleware); // ── PUBLIC endpoints ───────────────────────────────────────────────────────── -app.get('/api/health', (_req, res) => { - res.json({ success: true, data: { status: 'ok' } }); +// H11 — /api/health with DB probe (не возвращает OK если DB down) +import { db } from './config/database'; +app.get('/api/health', async (_req, res) => { + try { + await Promise.race([ + db.raw('select 1'), + new Promise((_, reject) => setTimeout(() => reject(new Error('db-timeout')), 1000)), + ]); + res.json({ success: true, data: { status: 'ok' } }); + } catch (err: any) { + res.status(503).json({ success: false, error: 'db_unavailable' }); + } }); -app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); -app.get('/api/docs/swagger.json', (_req, res) => { +// ── Глобальный rate limit на /api/* — ДО docs чтобы не было unauthenticated DoS на swagger.json +app.use('/api', globalLimiter); + +// H1 — Swagger gated. В production требуется basic-auth ИЛИ NODE_ENV != production. +// JSON endpoint ОБЯЗАТЕЛЬНО до app.use('/api/docs', ...) — иначе swagger-ui-express +// перехватывает все /api/docs/* и возвращает HTML вместо JSON. +const docsGate = (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (process.env.NODE_ENV !== 'production' || process.env.SWAGGER_PUBLIC === 'true') { + return next(); + } + // Production без SWAGGER_PUBLIC=true → require basic auth (operator credentials) + const auth = req.headers.authorization || ''; + const expected = process.env.SWAGGER_BASIC_AUTH; // "user:pass" + if (!expected || !auth.startsWith('Basic ')) { + res.set('WWW-Authenticate', 'Basic realm="docs"'); + res.status(401).json({ success: false, error: 'Docs auth required' }); + return; + } + const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); + if (decoded !== expected) { + res.set('WWW-Authenticate', 'Basic realm="docs"'); + res.status(401).json({ success: false, error: 'Invalid docs credentials' }); + return; + } + return next(); +}; +app.get('/api/docs/swagger.json', docsGate, (_req, res) => { res.json(swaggerSpec); }); - -// ── Глобальный rate limit на весь API после public endpoints ──────────────── -app.use('/api', globalLimiter); +app.use('/api/docs', docsGate, swaggerUi.serve, swaggerUi.setup(swaggerSpec)); // ── PROTECTED endpoints (JWT + CSRF) ───────────────────────────────────────── const protect = [authMiddleware, csrfMiddleware]; diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 193fb51..59aa255 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -81,6 +81,20 @@ export async function initEnv(): Promise { return; } + // H7 — HTTPS-only Vault enforce. Plaintext HTTP means master-key + AppRole secret_id + // travel through WAN unencrypted. Override via VAULT_ALLOW_INSECURE=true (only для local dev). + try { + const parsed = new URL(addr); + if (parsed.protocol !== 'https:' && p.VAULT_ALLOW_INSECURE !== 'true') { + throw new Error(`VAULT_ADDR must use https:// (got ${parsed.protocol}). Set VAULT_ALLOW_INSECURE=true only for local dev.`); + } + } catch (err: any) { + if (err.message?.includes('Invalid URL')) { + throw new Error(`VAULT_ADDR is malformed: ${addr}`); + } + throw err; + } + const token = await vaultAppRoleLogin(addr, roleId, secretId); if (!token) { logger.warn('Vault AppRole login failed, using .env fallback'); @@ -117,12 +131,15 @@ export async function initEnv(): Promise { }, jwt: { ...env.jwt, - issuer: s('JWT_ISSUER') || env.jwt.issuer, - audience: s('JWT_AUDIENCE') || env.jwt.audience, + // H17 — trim whitespace; пустая строка после trim → fallback на env + issuer: (s('JWT_ISSUER')?.trim() || env.jwt.issuer), + audience: (s('JWT_AUDIENCE')?.trim() || env.jwt.audience), }, cors: { 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, tronApiKey: s('TRON_API_KEY') || env.tronApiKey, diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index c9ac11c..9538a2f 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -6,8 +6,12 @@ import { getBalance, getTransactions } from '../services/wallet-ops.service'; import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators'; import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service'; import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service'; -import { signAndBroadcast } from '../services/wallet-signer.service'; -import { auditLog, auditLogStrict } from '../lib/audit-log'; +import { signAndBroadcast, signAndBroadcastRawEvm } from '../services/wallet-signer.service'; +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'; const ALLOWED_CHAINS = new Set(ALL_CHAINS); @@ -83,6 +87,12 @@ export const WalletController = { })), 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; }); @@ -163,22 +173,35 @@ export const WalletController = { return; } - const mnemonic = decryptMnemonic(blob); - - // CRITICAL operation — fail-secure audit (если запись fail'ит — не отдаём mnemonic). + // CRITICAL operation — durable audit BEFORE decrypt (fail-secure). + // Если INSERT fails — отказываем decrypt'у. + let auditId: string; try { - await auditLogStrict({ + auditId = await auditLogStrict({ event: 'mnemonic.reveal', userId, ip: req.ip || null, - result: 'success', }); } 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' }); 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 } }); } catch (err: any) { logger.error(`revealMnemonic failed for user ${userId}: ${err.stack || err.message}`); @@ -268,7 +291,7 @@ export const WalletController = { return; } - const { to, amount, token } = req.body ?? {}; + const { to, amount, token, feeTier } = req.body ?? {}; if (!isValidAddress(chain, String(to))) { res.status(400).json({ success: false, error: 'Invalid recipient address for chain' }); @@ -288,6 +311,33 @@ export const WalletController = { 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; try { const wallet = await WalletModel.findByUserAndChain(userId, chain); @@ -302,37 +352,40 @@ export const WalletController = { return; } - mnemonic = decryptMnemonic(blob); - - const result = await signAndBroadcast({ - chain, - mnemonic, - to: String(to), - amount: String(amount), - token: normalizedToken, - expectedFromAddress: wallet.address, - }); - - // CRITICAL operation — fail-secure audit + // CRITICAL — audit row BEFORE broadcast (fail-secure: если DB не примет — не подписываем). + let auditId: string; try { - await auditLogStrict({ + auditId = await auditLogStrict({ event: 'wallet.send', userId, ip: req.ip || null, - result: 'success', - meta: { chain, hasToken: !!normalizedToken, txid: result.txid }, + meta: { chain, hasToken: !!normalizedToken, to: String(to) }, }); } 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', - }); + 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); + + let result: { txid: string }; + try { + result = await signAndBroadcast({ + chain, + mnemonic, + to: String(to), + amount: String(amount), + token: normalizedToken, + expectedFromAddress: wallet.address, + feeTier: normalizedFeeTier, + }); + } catch (sendErr: any) { + await completeAudit(auditId, 'failure', undefined, 'BROADCAST_FAILED'); + throw sendErr; + } + + await completeAudit(auditId, 'success', { txid: result.txid }); res.json({ success: true, data: { txid: result.txid, chain } }); } catch (err: any) { logger.error(`send failed for user ${userId} chain ${chain}: ${err.stack || err.message}`); @@ -344,14 +397,210 @@ export const WalletController = { meta: { chain }, errorCode: 'BROADCAST_FAILED', }); - const msg = err?.message?.toLowerCase?.().includes('insufficient') - ? 'Insufficient balance' - : err?.message?.toLowerCase?.().includes('not supported') - ? 'Token/chain combination not supported' - : 'Failed to broadcast transaction'; + // Разделяем причины — иначе юзер видит generic "Insufficient balance" и думает что + // дело в самом токене, хотя на самом деле не хватает native для газа. + const lower = err?.message?.toLowerCase?.() ?? ''; + let msg: string; + 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 }); } finally { 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}`)); + } } }, }; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 95458a1..42c4b39 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,7 +1,8 @@ import app from './app'; import { env, initEnv } from './config/env'; 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'; // 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}`); 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. if (!isCryptoReady()) { @@ -26,6 +31,11 @@ async function main() { process.exit(1); } + // Integrity self-test: если в БД уже есть encrypted_mnemonic, master-key должен их декриптить. + // Иначе мы стартовали с НЕПРАВИЛЬНЫМ ключом (например после потери Vault dev-mode state) — + // и любой /send или /sign будет тихо валиться с GCM auth error. Лучше упасть сразу. + await runCryptoIntegritySelfTest(); + startKeyRotation(); const server = app.listen(env.port, () => { @@ -44,6 +54,59 @@ async function main() { process.on('SIGINT', () => shutdown('SIGINT')); } +async function runCryptoIntegritySelfTest(): Promise { + // 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) => { logger.error(`Failed to start: ${err.message}`); process.exit(1); diff --git a/apps/api/src/lib/audit-log.ts b/apps/api/src/lib/audit-log.ts index 9a73ecd..8819aa3 100644 --- a/apps/api/src/lib/audit-log.ts +++ b/apps/api/src/lib/audit-log.ts @@ -1,35 +1,20 @@ /** - * Audit log — append-only JSON lines в `logs/audit.log`. - * Используется для критических custodial операций. + * Audit log — durable durable durable. + * + * 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. */ -import { promises as fs } from 'fs'; -import path from 'path'; -import { logger } from './logger'; +import { ulid } from 'ulidx'; +import { db } from '../config/database'; import { getTraceId } from './trace-store'; - -const AUDIT_DIR = path.resolve(__dirname, '../../../../logs'); -const AUDIT_FILE = path.join(AUDIT_DIR, 'audit.log'); - -let initialized = false; - -async function ensureFile(): Promise { - 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}`); - } -} +import { logger } from './logger'; export interface AuditEntry { event: string; @@ -40,36 +25,90 @@ export interface AuditEntry { errorCode?: string; } -/** - * Best-effort write. Если запись провалилась — только log, не throws. - * Используется для не-критических событий (wallet.create success, etc). - */ -export async function auditLog(entry: AuditEntry): Promise { - await ensureFile(); - const line = JSON.stringify({ +function buildStdoutLine(entry: AuditEntry, status: 'pending' | 'success' | 'failure'): string { + return JSON.stringify({ + level: 'audit', + status, timestamp: new Date().toISOString(), trace_id: getTraceId(), ...entry, - }); + }) + '\n'; +} + +function writeStdoutBestEffort(line: string): void { try { - await fs.appendFile(AUDIT_FILE, line + '\n', { mode: 0o600 }); - } catch (err: any) { - logger.error(`Audit log write failed: ${err.message}`); + process.stdout.write(line); + } catch { + // swallow } } /** - * Fail-secure write. Если запись провалилась — throws. - * Используется для critical security событий (mnemonic.reveal, wallet.send), - * где compliance требует чтобы операция НЕ происходила без audit-trail. + * Best-effort: stdout only. Используется для info-level событий + * (wallet.create success, lookup, etc). Не блокирует request на DB. */ -export async function auditLogStrict(entry: AuditEntry): Promise { - await ensureFile(); - const line = JSON.stringify({ - timestamp: new Date().toISOString(), - trace_id: getTraceId(), - ...entry, - }); - // Без try/catch — caller обрабатывает failure - await fs.appendFile(AUDIT_FILE, line + '\n', { mode: 0o600 }); +export async function auditLog(entry: AuditEntry): Promise { + const status: 'success' | 'failure' = entry.result === 'failure' ? 'failure' : 'success'; + writeStdoutBestEffort(buildStdoutLine(entry, status)); +} + +/** + * Fail-secure audit для critical custodial операций (mnemonic.reveal, wallet.send, + * wallet.sign_raw_evm). + * + * Семантика: 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 { + 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, + errorCode?: string, +): Promise { + 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: '', meta, errorCode, result }, + result, + )); } diff --git a/apps/api/src/lib/evm-tx-policy.ts b/apps/api/src/lib/evm-tx-policy.ts new file mode 100644 index 0000000..3619e81 --- /dev/null +++ b/apps/api/src/lib/evm-tx-policy.ts @@ -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> = { + // 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 = { + '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, + }; +} diff --git a/apps/api/src/lib/idempotency.ts b/apps/api/src/lib/idempotency.ts new file mode 100644 index 0000000..4a30e06 --- /dev/null +++ b/apps/api/src/lib/idempotency.ts @@ -0,0 +1,92 @@ +/** + * Idempotency-Key handling — C3 защита от double-spend при retry. + * + * Контракт: + * Client передаёт header `Idempotency-Key: `. + * 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 { + 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 { + 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; +} diff --git a/apps/api/src/lib/send-lock.ts b/apps/api/src/lib/send-lock.ts new file mode 100644 index 0000000..cdecbd0 --- /dev/null +++ b/apps/api/src/lib/send-lock.ts @@ -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>(); + +/** + * 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 { + const key = `${userId}:${chain}`; + // Wait для предыдущего lock + while (locks.has(key)) { + await locks.get(key); + } + let release: ReleaseFn = () => {}; + const promise = new Promise((resolve) => { + release = () => { + locks.delete(key); + resolve(); + }; + }); + locks.set(key, promise); + return release; +} diff --git a/apps/api/src/lib/token-registry.ts b/apps/api/src/lib/token-registry.ts new file mode 100644 index 0000000..141a7b3 --- /dev/null +++ b/apps/api/src/lib/token-registry.ts @@ -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; +} diff --git a/apps/api/src/lib/wallet-binding.ts b/apps/api/src/lib/wallet-binding.ts new file mode 100644 index 0000000..792ee4b --- /dev/null +++ b/apps/api/src/lib/wallet-binding.ts @@ -0,0 +1,43 @@ +/** + * JWT ↔ wallet-address binding. + * + * Защита от drain-вектора: проксируемые endpoint'ы (swap-build, relay-quote) принимают + * `userAddress`/`recipient` как body param. Если не привязать к JWT-юзеру — + * authenticated user A может set `userAddress=` и: + * - 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 { + 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; +} diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index 25838e0..0d42327 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -28,6 +28,15 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction): 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. if (!isCsrfConfigured()) { logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request'); diff --git a/apps/api/src/models/user.model.ts b/apps/api/src/models/user.model.ts index f19394d..84035d5 100644 --- a/apps/api/src/models/user.model.ts +++ b/apps/api/src/models/user.model.ts @@ -9,18 +9,15 @@ export interface UserRow { first_name: string | null; middle_name: string | null; birth_date: string | null; - crypto_wallet: string | null; + crypto_wallet: string | null; // DEPRECATED — оставлено для backward-compat. ETH = users.erc20. phone: string | null; - bik: string | null; - account_number: string | null; - card_number: string | null; inn: string | null; kyc_verified: boolean; kyc_verified_at: Date | null; is_deleted: boolean; - encrypted_vault: string | null; // legacy, unused - vault_salt: string | null; // legacy, unused - encrypted_mnemonic: string | null; // AES-GCM blob (custodial) + passport_data: string | null; + erc20: string | null; // ETH-адрес кастодиального кошелька (заполняется при /wallets/create) + encrypted_mnemonic: string | null; // AES-GCM blob (custodial). Extension над user-supplied schema. created_at: Date; updated_at: Date; } @@ -100,4 +97,19 @@ export const UserModel = { .first(); 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 { + const k = trx || db; + await k('users') + .where({ id, is_deleted: false }) + .update({ + erc20: address, + updated_at: k.fn.now(), + }); + }, }; diff --git a/apps/api/src/routes/bsc-swap-proxy.routes.ts b/apps/api/src/routes/bsc-swap-proxy.routes.ts index a9f7d4f..e01f0d4 100644 --- a/apps/api/src/routes/bsc-swap-proxy.routes.ts +++ b/apps/api/src/routes/bsc-swap-proxy.routes.ts @@ -1,5 +1,7 @@ import { Request, Response, Router } from 'express'; import { ethers } from 'ethers'; +import { logger } from '../lib/logger'; +import { assertUserOwnsAddress } from '../lib/wallet-binding'; const router = Router(); @@ -85,7 +87,7 @@ async function getSwapQuote(req: Request, res: Response) { toDecimals: TOKEN_DECIMALS[to], }); } 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' }); } } @@ -113,6 +115,16 @@ async function buildSwapTx(req: Request, res: Response) { 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). // "0" truthy bypass — без этого attacker может выставить amountOutMin=0 = 100% slippage // → sandwich attack осушает swap. @@ -189,7 +201,7 @@ async function buildSwapTx(req: Request, res: Response) { res.json({ success: true, transactions }); } 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' }); } } diff --git a/apps/api/src/routes/relay-proxy.routes.ts b/apps/api/src/routes/relay-proxy.routes.ts index 33f675b..5bd9a88 100644 --- a/apps/api/src/routes/relay-proxy.routes.ts +++ b/apps/api/src/routes/relay-proxy.routes.ts @@ -1,14 +1,25 @@ import { NextFunction, Request, Response, Router } from 'express'; import { env } from '../config/env'; import { logger } from '../lib/logger'; +import { WalletModel } from '../models/wallet.model'; +import type { ChainCode } from '../lib/address-validators'; const router = Router(); const RELAY_API_URL = 'https://api.relay.link'; const RELAY_TIMEOUT_MS = 20_000; -// Whitelist: GET-paths + allowed `/execute/` actions. +// chainId → ChainCode. Relay использует EVM chainIds + custom большие для не-EVM. +const RELAY_CHAINID_TO_CHAIN: Record = { + 1: 'ETH', + 56: 'BSC', + 792703809: 'SOL', +}; + +// Whitelist: GET-paths + POST-paths + allowed `/execute/` actions. // Без него `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([ 'swap', 'bridge', @@ -24,10 +35,13 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction const relayPath = req.path; // Whitelist matching — никакого freeform после `/execute/`. + // Каждый path matches только своему method'у, чтобы клиент не мог GET-ом дёрнуть POST endpoint. let allowed = false; - if (ALLOWED_GET_PATHS.has(relayPath)) { + if (req.method === 'GET' && ALLOWED_GET_PATHS.has(relayPath)) { 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); // action: только alphanumeric, никаких слешей/дотов 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; } + // 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}`); 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(); if (!upstream.ok) { logger.warn(`Relay upstream ${upstream.status}: ${text.slice(0, 200)}`); - res.json({ success: false, error: 'Relay upstream error' }); + // Пробрасываем Relay error JSON клиенту — он сам пишет structured payload + // {message, errorCode, requestId, ...}. Content-Type уже forced на JSON выше, + // так что HTML-injection невозможен. Parsable наружу — клиент видит реальную причину. + let parsed: unknown = null; + try { parsed = JSON.parse(text); } catch { /* not JSON — wrap in safe envelope */ } + if (parsed && typeof parsed === 'object') { + res.json({ success: false, error: 'Relay upstream error', upstream: parsed }); + } else { + res.json({ success: false, error: 'Relay upstream error' }); + } return; } diff --git a/apps/api/src/routes/sol-swap-proxy.routes.ts b/apps/api/src/routes/sol-swap-proxy.routes.ts index 027a636..609607e 100644 --- a/apps/api/src/routes/sol-swap-proxy.routes.ts +++ b/apps/api/src/routes/sol-swap-proxy.routes.ts @@ -1,5 +1,8 @@ import { Request, Response, Router } from 'express'; 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 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('outputMint', String(outputMint)); 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 if (env.jupiterFeeBps > 0) { @@ -87,7 +91,7 @@ async function getQuote(req: Request, res: Response) { if (!response.ok) { const text = await response.text().catch(() => ''); // НЕ 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' }); return; } @@ -122,6 +126,37 @@ async function buildSwap(req: Request, res: Response) { 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 timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS); @@ -156,7 +191,7 @@ async function buildSwap(req: Request, res: Response) { if (!response.ok) { 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' }); return; } diff --git a/apps/api/src/routes/tron-swap-proxy.routes.ts b/apps/api/src/routes/tron-swap-proxy.routes.ts index 62b17bb..e383d1b 100644 --- a/apps/api/src/routes/tron-swap-proxy.routes.ts +++ b/apps/api/src/routes/tron-swap-proxy.routes.ts @@ -1,5 +1,7 @@ import { Request, Response, Router } from 'express'; import { env } from '../config/env'; +import { logger } from '../lib/logger'; +import { assertUserOwnsAddress } from '../lib/wallet-binding'; const router = Router(); const TRONGRID_BASE = 'https://api.trongrid.io'; @@ -199,6 +201,15 @@ async function buildSwapTx(req: Request, res: Response) { 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 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' }); 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' }); } finally { clearTimeout(timeout); @@ -320,8 +331,25 @@ async function buildSwapTx(req: Request, res: Response) { async function broadcastTx(req: Request, res: Response) { const { signedTransaction } = req.body; - if (!signedTransaction) { - res.status(400).json({ success: false, error: 'Missing signedTransaction' }); + if (!signedTransaction || typeof signedTransaction !== 'object') { + 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; } diff --git a/apps/api/src/routes/wallet.routes.ts b/apps/api/src/routes/wallet.routes.ts index b87970b..6344d47 100644 --- a/apps/api/src/routes/wallet.routes.ts +++ b/apps/api/src/routes/wallet.routes.ts @@ -9,6 +9,8 @@ router.post('/mnemonic/reveal', WalletController.revealMnemonic); router.get('/:chain/balance', WalletController.getChainBalance); router.get('/:chain/transactions', WalletController.getChainTransactions); +router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions); router.post('/:chain/send', WalletController.sendFromChain); +router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx); export default router; diff --git a/apps/api/src/services/crypto.service.ts b/apps/api/src/services/crypto.service.ts index b70440f..7994428 100644 --- a/apps/api/src/services/crypto.service.ts +++ b/apps/api/src/services/crypto.service.ts @@ -56,8 +56,13 @@ export async function fetchMasterKey( 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 (secrets.master_key || secrets.MASTER_KEY) { + throw new Error('Crypto master key misconfigured: Vault has alternate field but missing canonical "key"'); + } throw new Error('Crypto master key invalid: expected hex string in Vault field "key"'); } if (!/^[0-9a-fA-F]{64}$/.test(raw)) { diff --git a/apps/api/src/services/gas-oracle.service.ts b/apps/api/src/services/gas-oracle.service.ts new file mode 100644 index 0000000..c9fc707 --- /dev/null +++ b/apps/api/src/services/gas-oracle.service.ts @@ -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 { + 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 { + const tiers = await getEvmFeeTiers(chain); + return tiers[tier]; +} diff --git a/apps/api/src/services/key-rotation.service.ts b/apps/api/src/services/key-rotation.service.ts index b545d41..ea0517a 100644 --- a/apps/api/src/services/key-rotation.service.ts +++ b/apps/api/src/services/key-rotation.service.ts @@ -11,13 +11,31 @@ let timer: NodeJS.Timeout | null = null; // Inflight guard — reentrant calls share the same promise (audit#4 C2/C3). // Без этого две параллельные refreshAllKeys могут torn-state'ить keyMap/csrf/crypto. -let inflight: Promise | null = null; +let inflight: Promise | null = null; + +// H15/H18 — track refresh outcomes для health endpoint + alarm. +export interface RefreshResult { + ok: boolean; + reason?: string; + timestamp: number; +} + +let lastRefresh: RefreshResult = { ok: false, reason: 'not_yet_run', timestamp: 0 }; +let consecutiveFailures = 0; + +export function getLastRefreshResult(): RefreshResult { + return lastRefresh; +} +export function getConsecutiveFailures(): number { + return consecutiveFailures; +} /** * Atomic refresh: pre-fetch JWT/CSRF/crypto secrets, swap globals только если необходимые получены. * Reentrant-safe. + * H18 — returns RefreshResult так что caller знает реально ли refresh succeeded. */ -export async function refreshAllKeys(): Promise { +export async function refreshAllKeys(): Promise { if (inflight) return inflight; inflight = doRefresh().finally(() => { inflight = null; @@ -25,22 +43,23 @@ export async function refreshAllKeys(): Promise { return inflight; } -async function doRefresh(): Promise { +async function doRefresh(): Promise { 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) { - logger.warn('Vault not configured, skipping key refresh'); - return; + return fail('vault_not_configured'); } - // КАЖДЫЙ refresh — свежий AppRole login. Vault token TTL обычно ≤1 час, а refresh-интервал - // тоже ~1 час → кэшировать токен между tick'ами = expired token на 2-м tick → silent fail. - // Стоимость fresh login: один HTTP-запрос в час — пренебрежимо. Безопасность: гарантированно - // валидный токен для всех последующих fetches. + // КАЖДЫЙ refresh — свежий AppRole login. Vault token TTL обычно ≤1 час. const token = await vaultAppRoleLogin(addr, roleId, secretId); if (!token) { - logger.error('Key refresh: Vault AppRole login failed'); - return; + return fail('approle_login_failed'); } const jwtPromise = fetchJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix); @@ -50,21 +69,17 @@ async function doRefresh(): Promise { const [jwtResult, csrfResult, cryptoResult] = await Promise.allSettled([jwtPromise, csrfPromise, cryptoPromise]); if (jwtResult.status === 'rejected') { - logger.error(`Key refresh ABORTED — JWT keys fetch failed: ${jwtResult.reason?.message || jwtResult.reason}`); - return; + return fail(`jwt_fetch_failed: ${jwtResult.reason?.message || jwtResult.reason}`); } if (csrfPath && csrfResult.status === 'rejected') { - logger.error(`Key refresh ABORTED — CSRF fetch failed: ${csrfResult.reason?.message || csrfResult.reason}`); - return; + return fail(`csrf_fetch_failed: ${csrfResult.reason?.message || csrfResult.reason}`); } // Master-key: первый load обязателен, дальнейшие failures толерантны. if (cryptoKeyPath && !isCryptoReady() && cryptoResult.status === 'rejected') { - logger.error(`Key refresh ABORTED — Crypto master key fetch failed: ${cryptoResult.reason?.message || cryptoResult.reason}`); - return; + return fail(`crypto_fetch_failed: ${cryptoResult.reason?.message || cryptoResult.reason}`); } - // Atomic synchronous swap. JS single-threaded — между swap'ами нет await, - // т.е. observers видят либо все старые, либо все новые значения. + // Atomic swap. JS single-threaded → observers видят либо все старые, либо все новые. swapKeyMap(jwtResult.value); if (csrfResult.status === 'fulfilled' && csrfResult.value) { swapCsrfConfig(csrfResult.value); @@ -74,18 +89,29 @@ async function doRefresh(): Promise { swapMasterKey(cryptoResult.value); logger.info('Crypto master key loaded'); } else if (!masterKeyMatches(cryptoResult.value)) { - logger.warn( - 'Vault crypto/master key DIFFERS from in-memory key. Service continues with old key. ' + - 'Rotating master-key bricks all existing encrypted_mnemonic — revert Vault or plan re-encryption migration.' - ); + // H16 — master-key drift detected. По умолчанию FATAL: если operator не выставил + // ALLOW_MASTER_KEY_ROTATION=true explicitly, мы НЕ продолжаем silently на старом key. + const allowRotation = process.env.ALLOW_MASTER_KEY_ROTATION === 'true'; + const msg = 'Vault crypto/master key DIFFERS from in-memory key. ALL existing encrypted_mnemonic will become undecryptable.'; + if (allowRotation) { + logger.warn(msg + ' (continuing because ALLOW_MASTER_KEY_ROTATION=true)'); + } else { + logger.error(msg + ' Set ALLOW_MASTER_KEY_ROTATION=true to acknowledge migration intent. FATAL — service will exit.'); + // Defer exit so rest of refresh logs flush + setImmediate(() => process.exit(1)); + return fail('master_key_drift'); + } } } + consecutiveFailures = 0; + lastRefresh = { ok: true, timestamp: Date.now() }; logger.info( `Keys refreshed atomically: JWT keys=${getKeyMapSize()}` + (csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '') + `, Crypto=${isCryptoReady() ? 'ready' : 'NOT-READY'}` ); + return lastRefresh; } export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void { diff --git a/apps/api/src/services/wallet-ops.service.ts b/apps/api/src/services/wallet-ops.service.ts index f34117d..03a77e9 100644 --- a/apps/api/src/services/wallet-ops.service.ts +++ b/apps/api/src/services/wallet-ops.service.ts @@ -4,6 +4,7 @@ */ import { ethers } from 'ethers'; import { env } from '../config/env'; +import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry'; 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 SOL_RPC = 'https://api.mainnet-beta.solana.com'; -const USDT_TRC20 = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; -const USDT_BEP20 = '0x55d398326f99059fF775485246999027B3197955'; -const USDT_ERC20 = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; - const ERC20_ABI = [ 'function balanceOf(address owner) view returns (uint256)', 'function transfer(address to, uint256 amount) returns (bool)', @@ -28,31 +25,106 @@ const ERC20_ABI = [ // ─────────────────────── 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 { chain: ChainCode; address: string; - native: string; // в smallest units (satoshi/wei/lamports/sun) - tokens?: Record; // например { USDT: "12345678" } + native: FormattedAmount; + tokens?: Record; +} + +// Native decimals per chain +const NATIVE_DECIMALS: Record = { + 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, + decimalsLookup: Record, +): Record { + const out: Record = {}; + 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 { + const nativeDecimals = NATIVE_DECIMALS[chain]; switch (chain) { case 'BTC': - return { chain, address, native: await btcBalance(address) }; + return { + chain, address, + native: fmt(await btcBalance(address), nativeDecimals), + }; case 'TRX': { - const { trx, usdt } = await trxBalance(address); - return { chain, address, native: trx, tokens: { USDT: usdt } }; - } - case 'BSC': { - const { native, tokens } = await evmBalance(BSC_RPC, address, [{ symbol: 'USDT', addr: USDT_BEP20 }]); - return { chain, address, native, tokens }; + const { trx, tokens } = await trxBalance(address); + const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals])); + return { + chain, address, + native: fmt(trx, nativeDecimals), + tokens: fmtTokens(tokens, decimalsMap), + }; } + case 'BSC': case 'ETH': { - const { native, tokens } = await evmBalance(ETH_RPC, address, [{ symbol: 'USDT', addr: USDT_ERC20 }]); - return { chain, address, native, tokens }; + const tokenList = getEvmTokens(chain); + const rpc = chain === 'BSC' ? BSC_RPC : ETH_RPC; + const { native, tokens } = await evmBalance( + rpc, + address, + tokenList.map((t) => ({ symbol: t.symbol, addr: t.contractAddress })), + ); + const decimalsMap = Object.fromEntries(tokenList.map((t) => [t.symbol, t.decimals])); + 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 { return sat.toString(); } -async function trxBalance(address: string): Promise<{ trx: string; usdt: string }> { +async function trxBalance(address: string): Promise<{ trx: string; tokens: Record }> { const headers: Record = { Accept: 'application/json' }; if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey; const accRes = await fetchJson(`${TRONGRID}/v1/accounts/${address}`, { headers }); const trx = accRes.data?.[0]?.balance ? String(accRes.data[0].balance) : '0'; - // USDT TRC20 balance - const usdtRes = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - owner_address: address, - contract_address: USDT_TRC20, - function_selector: 'balanceOf(address)', - parameter: tronAddressToHex(address).padStart(64, '0'), - visible: true, - }), - }); - const usdtHex = usdtRes.constant_result?.[0]; - const usdt = usdtHex && !/^0+$/.test(usdtHex) ? BigInt('0x' + usdtHex).toString() : '0'; + // Parallel TRC20 balanceOf для всех зарегистрированных токенов + const tokens: Record = {}; + const addrHex = tronAddressToHex(address).padStart(64, '0'); - return { trx, usdt }; + await Promise.all( + getTrxTokens().map(async ({ symbol, contractAddress }) => { + try { + const res = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner_address: address, + contract_address: contractAddress, + function_selector: 'balanceOf(address)', + parameter: addrHex, + visible: true, + }), + }); + const hex = res.constant_result?.[0]; + tokens[symbol] = hex && !/^0+$/.test(hex) ? BigInt('0x' + hex).toString() : '0'; + } catch { + tokens[symbol] = '0'; + } + }), + ); + + return { trx, tokens }; } async function evmBalance( @@ -112,8 +195,9 @@ async function evmBalance( return { native: native.toString(), tokens: tokenBalances }; } -async function solBalance(address: string): Promise { - const res = await fetchJson(SOL_RPC, { +async function solBalance(address: string): Promise<{ native: string; tokens: Record }> { + // 1) Native SOL balance + const nativeRes = await fetchJson(SOL_RPC, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -123,7 +207,48 @@ async function solBalance(address: string): Promise { params: [address], }), }); - return String(res.result?.value ?? 0); + const native = String(nativeRes.result?.value ?? 0); + + // 2) Все SPL token accounts юзера одним запросом + const tokens: Record = {}; + 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 ─────────────────────── diff --git a/apps/api/src/services/wallet-signer.service.ts b/apps/api/src/services/wallet-signer.service.ts index 043529d..c654eb7 100644 --- a/apps/api/src/services/wallet-signer.service.ts +++ b/apps/api/src/services/wallet-signer.service.ts @@ -14,16 +14,52 @@ import * as bip39 from 'bip39'; import { BIP32Factory } from 'bip32'; import * as ecc from 'tiny-secp256k1'; 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 { env } from '../config/env'; import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service'; +import { getEvmFeeForTier, type FeeTier } from './gas-oracle.service'; import type { ChainCode } from '../lib/address-validators'; const bip32 = BIP32Factory(ecc); -const ETH_RPC = 'https://ethereum-rpc.publicnode.com'; -const BSC_RPC = 'https://bsc-dataseed.binance.org'; +// H29 — multiple RPC endpoints для failover. Если основной 5xx — переключаемся на следующий. +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 { + 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 TRONGRID = 'https://api.trongrid.io'; const BLOCKSTREAM = 'https://blockstream.info/api'; @@ -46,6 +82,29 @@ export interface SendParams { amount: string; token?: 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 }> { @@ -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(p: Promise, ms: number, msg: string): Promise { + return Promise.race([ + p, + new Promise((_, reject) => setTimeout(() => reject(new Error(msg)), ms)), + ]); +} + function assertAddressMatch(derived: string, expected: string, chain: ChainCode): void { const norm = (s: string) => 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 }> { const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH); 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 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 effectiveGasPrice = feeData.maxFeePerGas ?? feeData.gasPrice; - if (!effectiveGasPrice || effectiveGasPrice.gt(capWei)) { - throw new Error(`Gas price exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`); + const maxFeePerGas = ethers.BigNumber.from(fee.maxFeePerGas); + const maxPriorityFeePerGas = ethers.BigNumber.from(fee.maxPriorityFeePerGas); + // 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'); - // Принудительно EIP-1559 (type 2). ETH и BSC оба поддерживают с 2021. - // Если 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 effectiveGasPrice = maxFeePerGas; // for balance estimation const feeFields: Partial = { type: 2, maxFeePerGas, @@ -117,17 +275,31 @@ async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: st throw new Error('Insufficient token balance'); } 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))) { 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 }; } else { 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 }; } @@ -146,8 +318,41 @@ async function sendSol(p: SendParams): Promise<{ txid: string }> { const keypair = Keypair.fromSeed(key); 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); + + // 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 tx = new Transaction({ @@ -155,26 +360,50 @@ async function sendSol(p: SendParams): Promise<{ txid: string }> { blockhash, 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( SystemProgram.transfer({ fromPubkey: keypair.publicKey, toPubkey: toPk, - lamports: BigInt(p.amount), + lamports, }), ); tx.sign(keypair); const sig = await conn.sendRawTransaction(tx.serialize()); + // H37 — distinguished error categories try { await conn.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, 'confirmed'); } 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 }; } +// 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 ─── 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 feeMap = feesRes as Record; - // Floor 15 sat/vB — иначе tx может реджектиться по min-relay-fee при congestion. - const feeRate = Math.max(Math.ceil(feeMap['1'] ?? feeMap['3'] ?? feeMap['6'] ?? 15), 15); + // Tier-based BTC fee target: + // 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 = { 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); 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); 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; const feeFor = (ins: number, outs: number) => @@ -230,16 +484,32 @@ async function sendBtc(p: SendParams): Promise<{ txid: string }> { psbt.addInput({ hash: u.txid, 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 }, }); } 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 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) }); + } 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++) { @@ -283,6 +553,17 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> { const headers: Record = { 'Content-Type': 'application/json' }; 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; if (!p.token) { const built = await fetchJson(`${TRONGRID}/wallet/createtransaction`, { @@ -291,7 +572,7 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> { body: JSON.stringify({ owner_address: fromTronAddr, to_address: p.to, - amount: Number(p.amount), + amount: Number(amountBig), // safe — checked ≤ MAX_SAFE_INTEGER выше visible: true, }), }); @@ -308,7 +589,9 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> { contract_address: USDT_TRC20, function_selector: 'transfer(address,uint256)', 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, visible: true, }), @@ -400,22 +683,38 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> { // Sign verified txID const sk = new ethers.utils.SigningKey(wallet.privateKey); 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 = sig.r.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`, { method: 'POST', headers, - body: JSON.stringify(txBody), + body: JSON.stringify(cleanTxBody), }); 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'; - 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 }; @@ -425,15 +724,65 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> { 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 { + if (typeof address !== 'string' || address.length === 0) { + throw new Error('Invalid TRON address: empty'); + } + + // Step 1: base58 decode let num = 0n; for (const ch of address) { const i = BASE58_ALPHABET.indexOf(ch); if (i === -1) throw new Error('Invalid base58 character in TRON address'); num = num * 58n + BigInt(i); } - const hex = num.toString(16).padStart(50, '0'); - return hex.slice(2, 42); + + // 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 { diff --git a/apps/api/swagger.json b/apps/api/swagger.json index c4bf61a..7035c7b 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -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": { "type": "object", "properties": { @@ -92,11 +100,11 @@ "properties": { "chain": { "$ref": "#/components/schemas/Chain" }, "address": { "type": "string" }, - "native": { "type": "string", "description": "Balance в smallest units (sat/wei/lamports/sun)" }, + "native": { "$ref": "#/components/schemas/FormattedAmount" }, "tokens": { "type": "object", - "additionalProperties": { "type": "string" }, - "example": { "USDT": "12345678" } + "description": "Map symbol → FormattedAmount. Содержит все известные токены chain'а (ETH: USDT/USDC/DAI/WBTC/LINK/UNI, BSC: USDT/USDC/DOGE/WBNB/BUSD, TRX: USDT/USDC, SOL: 14 токенов)", + "additionalProperties": { "$ref": "#/components/schemas/FormattedAmount" } } } } @@ -126,8 +134,52 @@ "required": ["to", "amount"], "properties": { "to": { "type": "string", "description": "Recipient address" }, - "amount": { "type": "string", "description": "Amount в smallest units" }, - "token": { "type": "string", "nullable": true, "description": "USDT для TRC20/ERC20/BEP20. Без token = native." } + "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." }, + "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": { "post": { "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"], "parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }], "requestBody": { @@ -241,13 +293,48 @@ }, "responses": { "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" }, "502": { "description": "Broadcast failed (insufficient balance / RPC error / unsupported)" }, "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}": { "get": { @@ -378,19 +465,57 @@ } }, - "/relay/quote/v2": { - "get": { "summary": "Relay bridge quote", "tags": ["Relay"], "responses": { "200": { "description": "Quote" } } } + "/relay/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": { - "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}": { "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"], - "parameters": [{ "name": "action", "in": "path", "required": true, "schema": { "type": "string" } }], - "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } }, - "responses": { "200": { "description": "Result" } } + "parameters": [{ "name": "action", "in": "path", "required": true, "schema": { "type": "string", "enum": ["swap", "bridge"] } }], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "description": "Same as /relay/quote body" } } } }, + "responses": { + "200": { "description": "steps[] with unsigned tx + fees + details" }, + "502": { "description": "Relay upstream error" } + } } } } diff --git a/cryptowallet-schema.sql b/cryptowallet-schema.sql index 30cadbc..fde1e46 100644 --- a/cryptowallet-schema.sql +++ b/cryptowallet-schema.sql @@ -1,64 +1,155 @@ --- CryptoWallet API — DB schema (idempotent, custodial v5.0) +-- ╔══════════════════════════════════════════════════════════════════╗ +-- ║ CryptoWallet API — Production DB schema (idempotent, custodial) ║ +-- ║ Применять: psql -h -U postgres_user -d postgres -f ... ║ +-- ║ Безопасно прогонять повторно на existing БД. ║ +-- ╚══════════════════════════════════════════════════════════════════╝ CREATE TABLE IF NOT EXISTS users ( - id VARCHAR(26) PRIMARY KEY, - email VARCHAR(255) NOT NULL UNIQUE, - password_hash VARCHAR(255) NOT NULL, - last_name VARCHAR(255), - first_name VARCHAR(255), - middle_name VARCHAR(255), + id VARCHAR(26) NOT NULL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + last_name VARCHAR(128), + first_name VARCHAR(128), + middle_name VARCHAR(128), birth_date DATE, + -- DEPRECATED: исторически generic crypto address; для ETH используем erc20 ниже. crypto_wallet VARCHAR(255), - phone VARCHAR(64), - bik VARCHAR(64), - account_number VARCHAR(64), - card_number VARCHAR(64), - inn VARCHAR(64), - kyc_verified BOOLEAN NOT NULL DEFAULT FALSE, - kyc_verified_at TIMESTAMPTZ, - is_deleted BOOLEAN NOT NULL DEFAULT FALSE, - encrypted_vault TEXT, -- legacy - vault_salt VARCHAR(128), -- legacy - encrypted_mnemonic TEXT, -- AES-256-GCM blob (custodial) - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + phone VARCHAR(16), + inn VARCHAR(12), + kyc_verified BOOLEAN NOT NULL DEFAULT FALSE, + kyc_verified_at TIMESTAMP WITH TIME ZONE, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + passport_data VARCHAR(255), + erc20 VARCHAR(255), + -- EXTENSION (custodial wallet support): + -- AES-256-GCM blob: IV(12) || ciphertext || authTag(16), base64. Master-key в Vault. + encrypted_mnemonic TEXT ); +-- Idempotent ALTERs для existing БД без extension-columns DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic' - ) THEN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic') THEN ALTER TABLE users ADD COLUMN encrypted_mnemonic TEXT; END IF; -END $$; - --- AES-GCM blob: 12 IV + plaintext + 16 tag. --- 12-word mnemonic ~ 116 байт = ~156 base64 chars; 24-word ~ 212 байт = ~284 chars. -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size' - ) THEN - ALTER TABLE users - ADD CONSTRAINT users_encrypted_mnemonic_size - CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 140 AND 512)); + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'erc20') THEN + ALTER TABLE users ADD COLUMN erc20 VARCHAR(255); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'passport_data') THEN + ALTER TABLE users ADD COLUMN passport_data VARCHAR(255); END IF; END $$; +-- 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 ( - id VARCHAR(26) PRIMARY KEY, - user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - chain VARCHAR(16) NOT NULL, - address VARCHAR(256) NOT NULL, - derivation_path VARCHAR(64) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (user_id, chain) + id VARCHAR(26) NOT NULL PRIMARY KEY, + user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + chain VARCHAR(16) NOT NULL, + address VARCHAR(128) NOT NULL, + derivation_path VARCHAR(64) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, chain), + CHECK (chain IN ('ETH', 'BSC', 'BTC', 'TRX', 'SOL')) ); CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id); +CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address); --- sessions table removed — JWT-stateless, не используется в коде. --- Если существует от старой версии — оператор может drop вручную: --- DROP TABLE IF EXISTS sessions CASCADE; +-- Idempotent FK migration: если raised на старой DB с CASCADE — поменять +DO $$ +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'; diff --git a/docker-compose.yml b/docker-compose.yml index e2ce3e8..db8c2ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,17 +5,16 @@ services: dockerfile: Dockerfile container_name: cryptowallet-api restart: unless-stopped - # Bind to loopback only — TLS termination + WAF на reverse proxy (Caddy/Nginx). - # Если нужно direct exposure для dev — поменяй на "3001:3001" локально. + # Bind to loopback only — TLS termination + WAF на reverse proxy (Caddy / Nginx). + # Для direct exposure в dev → поменяй на "3001:3001". ports: - "127.0.0.1:3001:3001" env_file: - .env environment: API_PORT: "3001" - volumes: - - ./logs:/app/logs - # Container hardening — post-RCE blast radius minimization + # Container hardening — post-RCE blast radius minimization. + # Audit-логи теперь идут в stdout (не файл), поэтому read_only OK без logs mount. read_only: true tmpfs: - /tmp diff --git a/start.sh b/start.sh index d7c84b7..a2eb2f0 100644 --- a/start.sh +++ b/start.sh @@ -26,15 +26,10 @@ if [ "$ENV_MODE" != "600" ]; then chmod 600 .env fi -# Logs dir для audit-log mount — container's app user is uid 1001 -mkdir -p logs -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 +# NOTE: logs/ директория НЕ нужна — audit-логи теперь в stdout (Docker logs). +# Контейнер работает с read_only: true (см. docker-compose.yml). -echo "[INFO] Building and starting containers..." +echo "[INFO] Building and starting container..." docker compose up -d --build 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 "Docs: http://127.0.0.1:3001/api/docs" echo "Logs: docker compose logs -f api" -echo "Audit: tail -f logs/audit.log" +echo "Audit events: docker compose logs api | grep '\"level\":\"audit\"'"