diff --git a/.env.example b/.env.example index 8618836..33c3b83 100644 --- a/.env.example +++ b/.env.example @@ -7,15 +7,39 @@ VAULT_SECRET_PATH=database VAULT_JWT_KID_PATH=jwt/kid VAULT_JWT_KIDS_PREFIX=jwt/kids -# CSRF загружается если указан путь (оставь пустым чтобы отключить) +# CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF) VAULT_CSRF_PATH= # ── JWT ──────────────────────────────────────────────────────────── +# Allowed: RS256 / RS384 / RS512 / ES256 / ES384 / ES512 / EdDSA / PS256 / PS384 / PS512 JWT_ALGORITHM=RS256 JWT_ISSUER=auth-service JWT_AUDIENCE=elcsa # ── Server ───────────────────────────────────────────────────────── API_PORT=3001 -CORS_ORIGINS=http://localhost:3000 LOG_LEVEL=INFO + +# ── CORS ──────────────────────────────────────────────────────────── +# Comma-separated list of allowed origins. ПУСТО = no cross-origin. +# Никогда не используй wildcard * +CORS_ORIGINS= +CORS_ALLOW_CREDENTIALS=true + +# ── External API keys (optional, fallback if Vault doesn't provide) ─ +RELAY_API_KEY= +TRON_API_KEY= +JUPITER_API_KEY= +JUPITER_REFERRAL_ACCOUNT= +JUPITER_FEE_BPS=70 + +# ── Block explorers (optional, для tx history) ───────────────────── +ETHERSCAN_API_KEY= +BSCSCAN_API_KEY= + +# ── DB fallback (если Vault недоступен при старте) ───────────────── +DB_HOST= +DB_PORT=5432 +DB_USER= +DB_PASSWORD= +DB_NAME= diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index d0994cb..0000000 --- a/Dockerfile +++ /dev/null @@ -1,83 +0,0 @@ -# syntax=docker/dockerfile:1.7 -# ───────────────────────────────────────────────────────────────────────────── -# Production Dockerfile for CryptoWallet API -# Multi-stage: deps → build → prod-deps → runtime -# Runtime: non-root user, tini (signal handling), wget (healthcheck) -# ───────────────────────────────────────────────────────────────────────────── - -ARG NODE_VERSION=20.19-alpine -ARG PNPM_VERSION=10.28.2 - -# ───────────────────────────────────────────────────────────────────────────── -# Base: node + pnpm -# ───────────────────────────────────────────────────────────────────────────── -FROM node:${NODE_VERSION} AS base -ARG PNPM_VERSION -RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate -WORKDIR /app -ENV NODE_ENV=production \ - CI=true \ - HUSKY=0 - -# ───────────────────────────────────────────────────────────────────────────── -# deps: install ALL deps (incl. dev) for TypeScript build -# ───────────────────────────────────────────────────────────────────────────── -FROM base AS deps -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY apps/api/package.json apps/api/ -RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ - pnpm install --frozen-lockfile --prod=false - -# ───────────────────────────────────────────────────────────────────────────── -# build: compile TypeScript → dist/ -# ───────────────────────────────────────────────────────────────────────────── -FROM base AS build -COPY --from=deps /app/node_modules ./node_modules -COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY apps/api ./apps/api -RUN cd apps/api && pnpm build - -# ───────────────────────────────────────────────────────────────────────────── -# prod-deps: only production dependencies (smaller image) -# ───────────────────────────────────────────────────────────────────────────── -FROM base AS prod-deps -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY apps/api/package.json apps/api/ -RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ - pnpm install --frozen-lockfile --prod - -# ───────────────────────────────────────────────────────────────────────────── -# runtime: minimal production image -# ───────────────────────────────────────────────────────────────────────────── -FROM node:${NODE_VERSION} AS runtime - -# tini for proper PID 1 / signal handling -# wget for healthcheck -RUN apk add --no-cache tini wget && \ - addgroup -S -g 1001 app && \ - adduser -S -u 1001 -G app app - -WORKDIR /app/apps/api - -# Copy production dependencies -COPY --from=prod-deps --chown=app:app /app/node_modules /app/node_modules -COPY --from=prod-deps --chown=app:app /app/apps/api/node_modules ./node_modules - -# Copy compiled code + static assets -COPY --from=build --chown=app:app /app/apps/api/dist ./dist -COPY --from=build --chown=app:app /app/apps/api/swagger.json ./swagger.json -COPY --from=build --chown=app:app /app/apps/api/package.json ./package.json - -USER app - -ENV NODE_ENV=production \ - API_PORT=3001 - -EXPOSE 3001 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD wget -qO- http://localhost:3001/api/health || exit 1 - -ENTRYPOINT ["/sbin/tini", "--"] -CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md deleted file mode 100644 index 1b42b1b..0000000 --- a/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# CryptoWallet API — Production Deploy Bundle - -Самодостаточная папка для деплоя на Linux-сервер. Содержит всё нужное для сборки и запуска продакшн-версии API. - -## Состав - -``` -deployserver/ -├── Dockerfile # Multi-stage production build -├── docker-compose.yml # Только API (БД внешняя, из Vault) -├── .env.example # Шаблон переменных окружения -├── .dockerignore -├── start.sh # Автоматический deploy скрипт -├── apps/api/ # Исходник API -│ ├── src/ -│ ├── package.json -│ ├── tsconfig.json -│ ├── swagger.json -│ └── .eslintrc.json -├── package.json # Монорепо root -├── pnpm-workspace.yaml -└── pnpm-lock.yaml -``` - -## Требования - -- Ubuntu 20.04+ / Debian 11+ / любой Linux с Docker 24+ -- Docker Compose plugin (`docker compose` команда) -- Исходящий HTTPS на Vault (`corp.vault.elcsa.ru:443`) -- Сетевой доступ к PostgreSQL (адрес приходит из Vault) -- БД должна быть **инициализирована отдельно** (таблицы `users`, `wallets`, `sessions` — создаются вручную DBA) - -## Порты - -| Порт | Назначение | Открыть наружу? | -|------|-----------|-----------------| -| 3001 | API HTTP | ✅ да (`ufw allow 3001`) | -| 443 (out) | Vault | исходящий, обычно открыт | -| 5432 (out) | PostgreSQL | исходящий к внешнему адресу БД | - -## Управление - -```bash -docker compose logs -f api # смотреть логи -docker compose restart api # рестарт (после смены .env) -docker compose down # остановить -docker compose ps # статус -docker compose up -d --build # пересобрать и запустить -``` - -## Ротация ключей - -JWT public keys и CSRF secret читаются из Vault при старте и **каждый час** обновляются автоматически (см. `key-rotation.service.ts`). При ошибках Vault сервис продолжает работать со старыми ключами — в логах будет `ERROR: Failed to refresh ...`. - -## Безопасность Dockerfile - -- **Non-root user** (uid 1001) — контейнер не работает от root -- **tini** как PID 1 — корректная обработка `SIGTERM` / `SIGKILL` -- **Multi-stage build** — в финальный образ попадают только production deps + компилированный dist -- **Alpine base** — минимальный образ (~150 MB) -- **Healthcheck** — Docker рестартит контейнер если API упал -- **Log rotation** — max 5×20MB логов, не забьёт диск - -## Troubleshooting - -**`Vault AppRole login failed`** -- Проверь VAULT_ROLE_ID / VAULT_SECRET_ID в .env -- Проверь доступ: `curl -v https://corp.vault.elcsa.ru/v1/sys/health` - -**`relation "users" does not exist`** -- БД не инициализирована — попроси DBA создать таблицы (`users`, `wallets`, `sessions`) - -**Port 3001 занят** -- `sudo lsof -i :3001` -- Измени порт: в `docker-compose.yml` `"3002:3001"` и в `ufw allow 3002` - -## Автозапуск при reboot - -Restart policy `unless-stopped` уже настроен. Убедись что Docker стартует: -```bash -sudo systemctl enable docker -``` diff --git a/apps/api/package.json b/apps/api/package.json index dac6031..e66c3f8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,11 +10,14 @@ "lint": "eslint src/ --ext .ts" }, "dependencies": { + "@solana/web3.js": "^1.98.4", + "bs58": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.0", "ethers": "5.7.2", "express": "^4.21.0", + "express-rate-limit": "^8.4.1", "helmet": "^8.0.0", "jose": "^6.2.2", "knex": "^3.1.0", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index b45578a..bf753a7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -8,8 +8,10 @@ import { swaggerSpec } from './config/swagger'; import { traceMiddleware } from './middleware/trace'; import { authMiddleware } from './middleware/auth'; import { csrfMiddleware } from './middleware/csrf'; +import { globalLimiter, mutateLimiter, sensitiveLimiter } from './middleware/rate-limit'; import { errorHandler } from './middleware/error-handler'; import walletRoutes from './routes/wallet.routes'; +import vaultRoutes from './routes/vault.routes'; import relayProxyRoutes from './routes/relay-proxy.routes'; import tronProxyRoutes from './routes/tron-proxy.routes'; import solSwapProxyRoutes from './routes/sol-swap-proxy.routes'; @@ -19,9 +21,17 @@ import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes'; const app = express(); +// Trust proxy для корректного req.ip за reverse proxy / load balancer +app.set('trust proxy', 1); + app.use(helmet()); -app.use(cors({ origin: env.cors.origins, credentials: env.cors.allowCredentials })); -app.use(express.json()); +app.use( + cors({ + origin: env.cors.origins.length > 0 ? env.cors.origins : false, + credentials: env.cors.allowCredentials, + }), +); +app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS app.use(cookieParser()); app.use(traceMiddleware); @@ -35,16 +45,24 @@ app.get('/api/docs/swagger.json', (_req, res) => { res.json(swaggerSpec); }); -// ── PROTECTED endpoints (JWT + CSRF for mutating methods) ──────────────────── +// ── Глобальный rate limit на весь API после public endpoints ──────────────── +app.use('/api', globalLimiter); + +// ── PROTECTED endpoints (JWT + CSRF) ───────────────────────────────────────── const protect = [authMiddleware, csrfMiddleware]; -app.use('/api/wallets', ...protect, walletRoutes); -app.use('/api/relay', ...protect, relayProxyRoutes); -app.use('/api/tron', ...protect, tronProxyRoutes); -app.use('/api/sol/swap', ...protect, solSwapProxyRoutes); -app.use('/api/tron/swap', ...protect, tronSwapProxyRoutes); -app.use('/api/btc', ...protect, btcProxyRoutes); -app.use('/api/bsc/swap', ...protect, bscSwapProxyRoutes); +// Sensitive (send / vault) — самый строгий лимит +app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter); +app.use('/api/vault', ...protect, sensitiveLimiter, vaultRoutes); + +// Mutating (создание кошельков / broadcast / build) — повышенный лимит +app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes); +app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes); +app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes); +app.use('/api/sol/swap', ...protect, mutateLimiter, solSwapProxyRoutes); +app.use('/api/tron/swap', ...protect, mutateLimiter, tronSwapProxyRoutes); +app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes); +app.use('/api/bsc/swap', ...protect, mutateLimiter, bscSwapProxyRoutes); app.use(errorHandler); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 33dd782..141b481 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -31,7 +31,8 @@ export let env = { csrfPath: p.VAULT_CSRF_PATH || '', }, cors: { - origins: (p.CORS_ORIGINS || 'http://localhost:3000').split(','), + // No default — каждый деплой явно указывает CORS_ORIGINS, иначе пустой массив = нет cross-origin доступа. + origins: (p.CORS_ORIGINS || '').split(',').map((o) => o.trim()).filter(Boolean), allowCredentials: p.CORS_ALLOW_CREDENTIALS !== 'false', }, port: parseInt(p.API_PORT || '3001'), @@ -40,6 +41,8 @@ export let env = { jupiterApiKey: p.JUPITER_API_KEY || null, jupiterReferralAccount: p.JUPITER_REFERRAL_ACCOUNT || null, jupiterFeeBps: parseInt(p.JUPITER_FEE_BPS || '70'), + etherscanApiKey: p.ETHERSCAN_API_KEY || null, + bscscanApiKey: p.BSCSCAN_API_KEY || null, }; let vaultToken: string | null = null; diff --git a/apps/api/src/controllers/vault.controller.ts b/apps/api/src/controllers/vault.controller.ts new file mode 100644 index 0000000..d020e51 --- /dev/null +++ b/apps/api/src/controllers/vault.controller.ts @@ -0,0 +1,67 @@ +import { Request, Response } from 'express'; +import { UserModel } from '../models/user.model'; + +const MAX_VAULT_SIZE = 8192; // base64 encrypted blob upper limit +const MAX_SALT_LEN = 128; + +/** + * Encrypted vault — opaque blob (зашифрованный mnemonic, AES-GCM на клиенте). + * Сервис хранит как есть; никогда не расшифровывает. Ключ только у клиента + * (PBKDF2(password+pin) или аналог). + */ +export const VaultController = { + /** + * GET /api/vault — вернуть encrypted_vault + vault_salt пользователя. + */ + async getVault(req: Request, res: Response) { + const userId = req.auth!.userId; + try { + const row = await UserModel.getVault(userId); + if (!row || !row.encrypted_vault || !row.vault_salt) { + res.status(404).json({ success: false, error: 'Vault not found' }); + return; + } + res.json({ + success: true, + data: { + encryptedVault: row.encrypted_vault, + vaultSalt: row.vault_salt, + }, + }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } + }, + + /** + * PUT /api/vault — сохранить новый encrypted_vault + vault_salt. + * Создаёт user-row если её ещё нет. + */ + async putVault(req: Request, res: Response) { + const userId = req.auth!.userId; + const { encryptedVault, vaultSalt } = req.body ?? {}; + + if (typeof encryptedVault !== 'string' || encryptedVault.length === 0 || encryptedVault.length > MAX_VAULT_SIZE) { + res.status(400).json({ + success: false, + error: `encryptedVault must be a non-empty string (max ${MAX_VAULT_SIZE} chars)`, + }); + return; + } + if (typeof vaultSalt !== 'string' || vaultSalt.length === 0 || vaultSalt.length > MAX_SALT_LEN) { + res.status(400).json({ + success: false, + error: `vaultSalt must be a non-empty string (max ${MAX_SALT_LEN} chars)`, + }); + return; + } + + try { + await UserModel.ensureExists(userId); + await UserModel.setVault(userId, encryptedVault, vaultSalt); + res.json({ success: true }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } + }, +}; diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index 97ef59e..69ec310 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -1,7 +1,22 @@ import { Request, Response } from 'express'; import { WalletModel } from '../models/wallet.model'; +import { UserModel } from '../models/user.model'; +import { getBalance, getTransactions, buildSend } from '../services/wallet-ops.service'; +import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators'; + +const ALLOWED_CHAINS = new Set(['ETH', 'BTC', 'SOL', 'TRX', 'BSC']); +const MAX_WALLETS_PER_REQUEST = 20; +const MAX_DERIVATION_PATH = 64; +const MAX_TX_LIMIT = 100; + +function isChain(value: unknown): value is ChainCode { + return typeof value === 'string' && ALLOWED_CHAINS.has(value as ChainCode); +} export const WalletController = { + /** + * GET /api/wallets — все кошельки юзера + */ async getWallets(req: Request, res: Response) { try { const wallets = await WalletModel.findByUserId(req.auth!.userId); @@ -13,8 +28,182 @@ export const WalletController = { derivationPath: w.derivation_path, })), }); + } catch { + res.status(500).json({ success: false, error: 'Internal error' }); + } + }, + + /** + * POST /api/wallets — upsert массива кошельков для юзера из JWT. + */ + async createWallets(req: Request, res: Response) { + const userId = req.auth!.userId; + const { wallets } = req.body ?? {}; + + if (!Array.isArray(wallets) || wallets.length === 0 || wallets.length > MAX_WALLETS_PER_REQUEST) { + res.status(400).json({ + success: false, + error: `wallets must be a non-empty array (max ${MAX_WALLETS_PER_REQUEST})`, + }); + return; + } + + for (const w of wallets) { + if (!w || typeof w !== 'object') { + res.status(400).json({ success: false, error: 'Invalid wallet entry' }); + return; + } + if (!isChain(w.chain)) { + res.status(400).json({ success: false, error: 'Invalid chain' }); + return; + } + if (!isValidAddress(w.chain, w.address)) { + res.status(400).json({ success: false, error: 'Invalid address format for chain' }); + return; + } + if ( + typeof w.derivationPath !== 'string' || + w.derivationPath.length === 0 || + w.derivationPath.length > MAX_DERIVATION_PATH || + !/^m(\/[0-9]+'?)*$/.test(w.derivationPath) + ) { + res.status(400).json({ success: false, error: 'Invalid derivationPath (BIP32 m/.. format expected)' }); + return; + } + } + + try { + await UserModel.ensureExists(userId); + + const rows = await WalletModel.upsertMany( + wallets.map((w: { chain: ChainCode; address: string; derivationPath: string }) => ({ + user_id: userId, + chain: w.chain, + address: w.address, + derivation_path: w.derivationPath, + })) + ); + res.status(201).json({ + success: true, + data: rows.map((w) => ({ + chain: w.chain, + address: w.address, + derivationPath: w.derivation_path, + })), + }); + } catch { + res.status(500).json({ success: false, error: 'Internal error' }); + } + }, + + /** + * GET /api/wallets/:chain/balance — баланс для адреса юзера в данной chain. + */ + async getChainBalance(req: Request, res: Response) { + const userId = req.auth!.userId; + const chain = String(req.params.chain).toUpperCase(); + + if (!isChain(chain)) { + res.status(400).json({ success: false, error: 'Invalid chain parameter' }); + return; + } + + try { + const wallet = await WalletModel.findByUserAndChain(userId, chain); + if (!wallet) { + res.status(404).json({ success: false, error: 'Wallet not found for this chain' }); + return; + } + + const balance = await getBalance(chain, wallet.address); + res.json({ success: true, data: balance }); + } catch { + res.status(502).json({ success: false, error: 'Upstream RPC error' }); + } + }, + + /** + * GET /api/wallets/:chain/transactions + */ + async getChainTransactions(req: Request, res: Response) { + const userId = req.auth!.userId; + const chain = String(req.params.chain).toUpperCase(); + + if (!isChain(chain)) { + res.status(400).json({ success: false, error: 'Invalid chain parameter' }); + return; + } + + const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20')) || 20, 1), MAX_TX_LIMIT); + + try { + const wallet = await WalletModel.findByUserAndChain(userId, chain); + if (!wallet) { + res.status(404).json({ success: false, error: 'Wallet not found for this chain' }); + return; + } + + const txs = await getTransactions(chain, wallet.address, limit); + res.json({ success: true, data: txs }); + } catch { + res.status(502).json({ success: false, error: 'Upstream RPC error' }); + } + }, + + /** + * POST /api/wallets/:chain/send — build unsigned транзакцию. + */ + async sendFromChain(req: Request, res: Response) { + const userId = req.auth!.userId; + const chain = String(req.params.chain).toUpperCase(); + + if (!isChain(chain)) { + res.status(400).json({ success: false, error: 'Invalid chain parameter' }); + return; + } + + const { to, amount, token } = req.body ?? {}; + + if (!isValidAddress(chain, String(to))) { + res.status(400).json({ success: false, error: 'Invalid recipient address for chain' }); + return; + } + if (!isValidAmount(String(amount))) { + res.status(400).json({ success: false, error: 'Invalid amount (must be positive integer string)' }); + return; + } + + let normalizedToken: string | undefined; + if (token !== undefined && token !== null) { + if (typeof token !== 'string' || !/^[A-Z0-9]{2,10}$/.test(token)) { + res.status(400).json({ success: false, error: 'Invalid token symbol' }); + return; + } + normalizedToken = token.toUpperCase(); + } + + try { + const wallet = await WalletModel.findByUserAndChain(userId, chain); + if (!wallet) { + res.status(404).json({ success: false, error: 'Wallet not found for this chain' }); + return; + } + + const tx = await buildSend({ + chain, + from: wallet.address, + to, + amount, + token: normalizedToken, + }); + + res.json({ success: true, data: tx }); } catch (err: any) { - res.status(500).json({ success: false, error: err.message }); + // Не возвращаем raw upstream message — может содержать sensitive info + const safeMsg = err?.message?.toLowerCase().includes('not implemented') + ? 'Send not supported for this chain/token combination' + : 'Failed to build transaction'; + res.status(502).json({ success: false, error: safeMsg }); } }, }; diff --git a/apps/api/src/lib/address-validators.ts b/apps/api/src/lib/address-validators.ts new file mode 100644 index 0000000..3e0476a --- /dev/null +++ b/apps/api/src/lib/address-validators.ts @@ -0,0 +1,39 @@ +/** + * Chain-specific address format validators. + * НЕ заменяет реальную чеканку checksum — это первый barrier. + */ +import { ethers } from 'ethers'; + +const BTC_RE = /^(bc1[a-zA-HJ-NP-Z0-9]{25,62}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})$/; +const TRX_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/; +const SOL_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; + +export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC'; + +export function isValidAddress(chain: ChainCode, address: string): boolean { + if (typeof address !== 'string' || address.length === 0 || address.length > 256) return false; + + switch (chain) { + case 'BTC': + return BTC_RE.test(address); + case 'TRX': + return TRX_RE.test(address); + case 'ETH': + case 'BSC': + return ethers.utils.isAddress(address); + case 'SOL': + return SOL_RE.test(address); + default: + return false; + } +} + +export function isValidAmount(amount: string): boolean { + if (typeof amount !== 'string' || amount.length === 0 || amount.length > 78) return false; + if (!/^\d+$/.test(amount)) return false; + try { + return BigInt(amount) > 0n; + } catch { + return false; + } +} diff --git a/apps/api/src/lib/logger.ts b/apps/api/src/lib/logger.ts index 8d46698..ee12f2e 100644 --- a/apps/api/src/lib/logger.ts +++ b/apps/api/src/lib/logger.ts @@ -30,6 +30,25 @@ function getCallerInfo(): { file: string; line: number } { return { file: 'unknown', line: 0 }; } +// Удаляем потенциально чувствительные значения из лог-сообщений. +// Защита от случайной утечки Vault tokens / passwords / secrets через сообщения ошибок. +const SENSITIVE_PATTERNS: { regex: RegExp; replace: string }[] = [ + { regex: /(password|passwd|pwd)\s*[:=]\s*\S+/gi, replace: '$1=***' }, + { regex: /(token|secret|api[_-]?key|auth)\s*[:=]\s*\S+/gi, replace: '$1=***' }, + { regex: /\bhvs\.[A-Za-z0-9]+/g, replace: 'hvs.***' }, + { regex: /\bs\.[A-Za-z0-9]{20,}/g, replace: 's.***' }, + { regex: /Bearer\s+[A-Za-z0-9._-]+/gi, replace: 'Bearer ***' }, + { regex: /Authorization:\s*\S+/gi, replace: 'Authorization: ***' }, +]; + +function sanitize(msg: string): string { + let out = msg; + for (const { regex, replace } of SENSITIVE_PATTERNS) { + out = out.replace(regex, replace); + } + return out; +} + function log(level: string, message: string): void { if ((LEVELS[level] ?? 0) < MIN_LEVEL) return; @@ -41,7 +60,7 @@ function log(level: string, message: string): void { file: caller.file, line: caller.line, trace_id: getTraceId(), - message, + message: sanitize(message), }; process.stdout.write(JSON.stringify(entry) + '\n'); } diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index 6a46ddb..8425be3 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -11,16 +11,18 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction): return; } - // CSRF отключён если VAULT_CSRF_PATH не задан + // Если CSRF полностью отключён через ENV (csrfPath пустой) — пропускаем. + // Это явная конфигурация, не fail-open. if (!env.vault.csrfPath) { next(); return; } - // Секрет не загрузился (Vault недоступен) — пропускаем чтобы не блокировать сервис + // CSRF включён, но секрет не загружен (Vault недоступен) → fail-secure: 503. + // НИКОГДА не пропускаем mutating запросы при не-валидном состоянии. if (!isCsrfConfigured()) { - logger.warn('CSRF check skipped: secret not loaded'); - next(); + logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request'); + res.status(503).json({ success: false, error: 'CSRF protection unavailable, retry later' }); return; } diff --git a/apps/api/src/middleware/error-handler.ts b/apps/api/src/middleware/error-handler.ts index 245a246..9d061b5 100644 --- a/apps/api/src/middleware/error-handler.ts +++ b/apps/api/src/middleware/error-handler.ts @@ -1,7 +1,39 @@ import { Request, Response, NextFunction } from 'express'; import { logger } from '../lib/logger'; -export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void { - logger.error(err.message); +interface HttpError extends Error { + status?: number; + statusCode?: number; + type?: string; +} + +/** + * Generic error handler. Sanitization of err.message происходит в logger. + * Клиенту НИКОГДА не отдаём raw err.message (может содержать sensitive data). + */ +export function errorHandler(err: HttpError, _req: Request, res: Response, _next: NextFunction): void { + const status = err.status || err.statusCode || 500; + + // Standard Express body-parser errors + if (err.type === 'entity.too.large') { + logger.warn(`Payload too large: ${err.message}`); + res.status(413).json({ success: false, error: 'Payload too large' }); + return; + } + if (err.type === 'entity.parse.failed') { + logger.warn(`Invalid JSON: ${err.message}`); + res.status(400).json({ success: false, error: 'Invalid JSON' }); + return; + } + + // Известные клиентские ошибки (4xx) — отдаём safe сообщение + if (status >= 400 && status < 500) { + logger.warn(`Client error ${status}: ${err.message}`); + res.status(status).json({ success: false, error: 'Bad request' }); + return; + } + + // Серверные ошибки (5xx) — generic message, детали только в логи + logger.error(`Server error: ${err.message}`); res.status(500).json({ success: false, error: 'Internal server error' }); } diff --git a/apps/api/src/middleware/rate-limit.ts b/apps/api/src/middleware/rate-limit.ts new file mode 100644 index 0000000..846badd --- /dev/null +++ b/apps/api/src/middleware/rate-limit.ts @@ -0,0 +1,42 @@ +import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; +import { Request } from 'express'; + +/** + * Per-user rate limiting (если auth есть, то по userId; иначе по IP). + * Защищает от brute force / DoS / API quota exhaustion. + */ +function keyByUserOrIp(req: Request, res: any): string { + if (req.auth?.userId) return `user:${req.auth.userId}`; + // ipKeyGenerator корректно нормализует IPv6 (по /64 префиксу) + return ipKeyGenerator(req.ip || ''); +} + +// Глобальный лимит на любые API запросы — защита от мусорного трафика +export const globalLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 120, + standardHeaders: 'draft-7', + legacyHeaders: false, + keyGenerator: keyByUserOrIp, + message: { success: false, error: 'Too many requests' }, +}); + +// Жёсткий лимит на mutating операции с балансами/wallet +export const mutateLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 30, + standardHeaders: 'draft-7', + legacyHeaders: false, + keyGenerator: keyByUserOrIp, + message: { success: false, error: 'Too many mutating requests' }, +}); + +// Самый строгий — для send / vault PUT (anti-abuse / spam tx prevention) +export const sensitiveLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 10, + standardHeaders: 'draft-7', + legacyHeaders: false, + keyGenerator: keyByUserOrIp, + message: { success: false, error: 'Too many sensitive requests' }, +}); diff --git a/apps/api/src/models/user.model.ts b/apps/api/src/models/user.model.ts index db0863f..a7bfc33 100644 --- a/apps/api/src/models/user.model.ts +++ b/apps/api/src/models/user.model.ts @@ -1,5 +1,4 @@ import { db } from '../config/database'; -import { generateUlid } from '../utils/ulid'; export interface UserRow { id: string; @@ -18,32 +17,62 @@ export interface UserRow { kyc_verified: boolean; kyc_verified_at: Date | null; is_deleted: boolean; + encrypted_vault: string | null; + vault_salt: string | null; created_at: Date; updated_at: Date; } export const UserModel = { - async findByEmail(email: string): Promise { - return db('users').where({ email, is_deleted: false }).first(); - }, - async findById(id: string): Promise { return db('users').where({ id, is_deleted: false }).first(); }, - async create(data: { - email: string; - password_hash: string; - }): Promise { - const [user] = await db('users').insert({ id: generateUlid(), ...data }).returning('*'); - return user; + async findByEmail(email: string): Promise { + return db('users').where({ email, is_deleted: false }).first(); }, - async update(id: string, data: Partial>): Promise { + /** + * Создать запись пользователя если её нет. + * id берётся из JWT (sub). Email/password_hash — заглушки, потому что реальный + * учёт авторизации в BITOK; мы только проксируем wallet-специфичные данные. + */ + async ensureExists(id: string): Promise { + await db('users') + .insert({ + id, + email: `${id}@elcsa.local`, + password_hash: 'EXTERNAL_AUTH', + }) + .onConflict('id') + .ignore(); + }, + + async update( + id: string, + data: Partial>, + ): Promise { const [user] = await db('users') .where({ id }) .update({ ...data, updated_at: db.fn.now() }) .returning('*'); return user; }, + + async setVault(id: string, encryptedVault: string, vaultSalt: string): Promise { + await db('users') + .where({ id }) + .update({ + encrypted_vault: encryptedVault, + vault_salt: vaultSalt, + updated_at: db.fn.now(), + }); + }, + + async getVault(id: string): Promise<{ encrypted_vault: string | null; vault_salt: string | null } | undefined> { + return db('users') + .where({ id, is_deleted: false }) + .select('encrypted_vault', 'vault_salt') + .first(); + }, }; diff --git a/apps/api/src/models/wallet.model.ts b/apps/api/src/models/wallet.model.ts index 98c63d4..98de385 100644 --- a/apps/api/src/models/wallet.model.ts +++ b/apps/api/src/models/wallet.model.ts @@ -15,10 +15,29 @@ export const WalletModel = { return db('wallets').where({ user_id: userId }); }, + async findByUserAndChain(userId: string, chain: string): Promise { + return db('wallets').where({ user_id: userId, chain }).first(); + }, + async createMany( wallets: { user_id: string; chain: string; address: string; derivation_path: string }[] ): Promise { const withIds = wallets.map((w) => ({ id: generateUlid(), ...w })); return db('wallets').insert(withIds).returning('*'); }, + + /** + * Insert wallets, on conflict (user_id, chain) update address + derivation_path. + * Used by POST /api/wallets — клиент шлёт массив адресов после регистрации в BITOK. + */ + async upsertMany( + wallets: { user_id: string; chain: string; address: string; derivation_path: string }[] + ): Promise { + const withIds = wallets.map((w) => ({ id: generateUlid(), ...w })); + return db('wallets') + .insert(withIds) + .onConflict(['user_id', 'chain']) + .merge(['address', 'derivation_path']) + .returning('*'); + }, }; diff --git a/apps/api/src/routes/sol-swap-proxy.routes.ts b/apps/api/src/routes/sol-swap-proxy.routes.ts index 1c3d5c3..d4e5305 100644 --- a/apps/api/src/routes/sol-swap-proxy.routes.ts +++ b/apps/api/src/routes/sol-swap-proxy.routes.ts @@ -56,6 +56,12 @@ async function getQuote(req: Request, res: Response) { return; } + const parsedSlippage = parseInt(String(slippageBps), 10); + if (!Number.isFinite(parsedSlippage) || parsedSlippage < 1 || parsedSlippage > 500) { + res.status(400).json({ success: false, error: 'slippageBps must be 1-500 (max 5%)' }); + return; + } + const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS); diff --git a/apps/api/src/routes/tron-proxy.routes.ts b/apps/api/src/routes/tron-proxy.routes.ts index 562409a..3360b59 100644 --- a/apps/api/src/routes/tron-proxy.routes.ts +++ b/apps/api/src/routes/tron-proxy.routes.ts @@ -193,15 +193,55 @@ async function createTransaction(req: Request, res: Response) { } } +// Whitelist contracts and functions accepted via /triggersmartcontract. +// Defence in depth: иначе клиент мог бы вызвать любой контракт (e.g. drain pool). +const ALLOWED_TRC_CONTRACTS = new Set([ + USDT_CONTRACT, // USDT TRC20 + 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax', // SunSwap router + 'TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E', // FeeSwapRouter_TRX + 'TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR', // WTRX +]); + +const ALLOWED_TRC_FUNCTIONS = new Set([ + 'transfer(address,uint256)', + 'approve(address,uint256)', + 'balanceOf(address)', + 'allowance(address,address)', + 'swapExactETHForTokens(uint256,address[],address,uint256)', + 'swapExactTokensForETH(uint256,uint256,address[],address,uint256)', + 'swapNativeWithFee(bytes)', + 'swapTokenWithFee(address,uint256,bytes)', + 'getAmountsOut(uint256,address[])', +]); + /** * POST /api/tron/triggersmartcontract - * Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction + * Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction. + * Whitelisted contracts + function selectors only. */ async function triggerSmartContract(req: Request, res: Response) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS); try { + const body = req.body ?? {}; + const contractAddress = String(body.contract_address || ''); + const functionSelector = String(body.function_selector || ''); + const ownerAddress = String(body.owner_address || ''); + + if (!ALLOWED_TRC_CONTRACTS.has(contractAddress)) { + res.status(403).json({ success: false, error: 'Contract address not allowed' }); + return; + } + if (!ALLOWED_TRC_FUNCTIONS.has(functionSelector)) { + res.status(403).json({ success: false, error: 'Function selector not allowed' }); + return; + } + if (!TRON_ADDRESS_RE.test(ownerAddress)) { + res.status(400).json({ success: false, error: 'Invalid owner_address' }); + return; + } + const headers: Record = { 'Content-Type': 'application/json', Accept: 'application/json', diff --git a/apps/api/src/routes/vault.routes.ts b/apps/api/src/routes/vault.routes.ts new file mode 100644 index 0000000..df3481a --- /dev/null +++ b/apps/api/src/routes/vault.routes.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { VaultController } from '../controllers/vault.controller'; + +const router = Router(); + +router.get('/', VaultController.getVault); +router.put('/', VaultController.putVault); + +export default router; diff --git a/apps/api/src/routes/wallet.routes.ts b/apps/api/src/routes/wallet.routes.ts index 2482258..a924766 100644 --- a/apps/api/src/routes/wallet.routes.ts +++ b/apps/api/src/routes/wallet.routes.ts @@ -4,5 +4,10 @@ import { WalletController } from '../controllers/wallet.controller'; const router = Router(); router.get('/', WalletController.getWallets); +router.post('/', WalletController.createWallets); + +router.get('/:chain/balance', WalletController.getChainBalance); +router.get('/:chain/transactions', WalletController.getChainTransactions); +router.post('/:chain/send', WalletController.sendFromChain); export default router; diff --git a/apps/api/src/services/csrf.service.ts b/apps/api/src/services/csrf.service.ts index 3f6e878..c663a29 100644 --- a/apps/api/src/services/csrf.service.ts +++ b/apps/api/src/services/csrf.service.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import { logger } from '../lib/logger'; +import { fetchVaultKV2 } from '../config/vault'; /** * CSRF token validation compatible with Python's `itsdangerous` @@ -18,57 +18,70 @@ import { logger } from '../lib/logger'; const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time -let csrfSecret: string | null = null; -let csrfSalt = 'itsdangerous.Signer'; -let csrfDigest: 'sha1' | 'sha256' | 'sha512' = 'sha512'; -let csrfMaxAgeSec = 60 * 60 * 24 * 7; // 7 days +export interface CsrfConfig { + secret: string; + salt: string; + digest: 'sha1' | 'sha256' | 'sha512'; + maxAgeSec: number; +} -export async function loadCsrfSecret( +// Live config — атомарно подменяется через swapCsrfConfig() +let current: CsrfConfig | null = null; + +export function swapCsrfConfig(cfg: CsrfConfig | null): void { + current = cfg; +} + +export function isCsrfConfigured(): boolean { + return current !== null; +} + +/** + * Pre-fetch CSRF config из Vault — НЕ мутирует глобал, возвращает новый объект. + */ +export async function fetchCsrfConfig( addr: string, token: string, mount: string, path: string, -): Promise { - const { fetchVaultKV2 } = await import('../config/vault'); - +): Promise { const secrets = await fetchVaultKV2(addr, token, mount, path); if (!secrets) { - logger.warn('Failed to load CSRF secret from Vault'); - return; + throw new Error('Failed to load CSRF secret from Vault'); } - const secret = secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret; - if (!secret) { - logger.warn('CSRF secret not found in Vault payload (expected key: secret_key)'); - return; + const secret = + secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret; + if (!secret || typeof secret !== 'string' || secret.length < 32) { + throw new Error('CSRF secret invalid: must be string >= 32 chars'); } - csrfSecret = secret; - if (secrets.salt) csrfSalt = secrets.salt; + const salt = secrets.salt || 'itsdangerous.Signer'; + if (typeof salt !== 'string' || salt.length < 8) { + throw new Error('CSRF salt invalid: must be string >= 8 chars'); + } + + let digest: 'sha1' | 'sha256' | 'sha512' = 'sha512'; if (secrets.digest === 'sha1' || secrets.digest === 'sha256' || secrets.digest === 'sha512') { - csrfDigest = secrets.digest as 'sha1' | 'sha256' | 'sha512'; + digest = secrets.digest; } + + let maxAgeSec = 60 * 60 * 24 * 7; // 7 days if (secrets.max_age_sec) { const n = parseInt(secrets.max_age_sec); - if (!Number.isNaN(n) && n > 0) csrfMaxAgeSec = n; + if (!Number.isNaN(n) && n > 0) maxAgeSec = n; } - logger.info(`CSRF secret loaded (digest=${csrfDigest}, salt="${csrfSalt}", max_age=${csrfMaxAgeSec}s)`); -} - -export function isCsrfConfigured(): boolean { - return csrfSecret !== null; + return { secret, salt, digest, maxAgeSec }; } function b64urlDecode(s: string): Buffer { - // itsdangerous strips padding const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4); const padded = s + '='.repeat(pad); return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); } function deriveKey(secret: string, salt: string, digest: string): Buffer { - // itsdangerous `Signer.derive_key`: HMAC(secret, salt + "signer") return crypto.createHmac(digest, secret).update(salt + 'signer').digest(); } @@ -85,13 +98,13 @@ export interface CsrfVerifyResult { } export function verifyCsrfToken(token: string): CsrfVerifyResult { - if (!csrfSecret) return { valid: false, reason: 'CSRF secret not loaded' }; + if (!current) return { valid: false, reason: 'CSRF secret not loaded' }; if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' }; const lastDot = token.lastIndexOf('.'); if (lastDot < 0) return { valid: false, reason: 'Malformed token (no signature)' }; - const payloadTs = token.slice(0, lastDot); // "." + const payloadTs = token.slice(0, lastDot); const sigStr = token.slice(lastDot + 1); const prevDot = payloadTs.lastIndexOf('.'); @@ -99,8 +112,8 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult { const tsStr = payloadTs.slice(prevDot + 1); - const derived = deriveKey(csrfSecret, csrfSalt, csrfDigest); - const expectedSig = crypto.createHmac(csrfDigest, derived).update(payloadTs).digest(); + const derived = deriveKey(current.secret, current.salt, current.digest); + const expectedSig = crypto.createHmac(current.digest, derived).update(payloadTs).digest(); let actualSig: Buffer; try { @@ -116,12 +129,11 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult { return { valid: false, reason: 'Signature mismatch' }; } - // Timestamp check try { const issuedAt = decodeTimestamp(tsStr); const now = Math.floor(Date.now() / 1000); if (issuedAt > now + 60) return { valid: false, reason: 'Token from the future' }; - if (now - issuedAt > csrfMaxAgeSec) return { valid: false, reason: 'Token expired' }; + if (now - issuedAt > current.maxAgeSec) return { valid: false, reason: 'Token expired' }; } catch { return { valid: false, reason: 'Invalid timestamp' }; } diff --git a/apps/api/src/services/jwt.service.ts b/apps/api/src/services/jwt.service.ts index c440134..c84baa9 100644 --- a/apps/api/src/services/jwt.service.ts +++ b/apps/api/src/services/jwt.service.ts @@ -1,5 +1,6 @@ import * as jose from 'jose'; import { env } from '../config/env'; +import { fetchVaultKV2 } from '../config/vault'; import { logger } from '../lib/logger'; export interface AccessTokenPayload { @@ -19,21 +20,41 @@ export interface AuthContext { token: AccessTokenPayload; } -const keyMap = new Map>>(); +type KeyType = Awaited>; -export async function loadJwtKeysFromVault( +// Whitelist надёжных асимметричных алгоритмов. Никогда не разрешаем 'none'/HS* +// (HS — симметричные, могли бы быть подставлены через algorithm confusion). +const ALLOWED_ALGORITHMS = new Set(['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'EdDSA', 'PS256', 'PS384', 'PS512']); + +if (!ALLOWED_ALGORITHMS.has(env.jwt.algorithm)) { + throw new Error(`JWT_ALGORITHM "${env.jwt.algorithm}" not allowed. Use one of: ${[...ALLOWED_ALGORITHMS].join(', ')}`); +} + +// Live key store — атомарно подменяется через swapKeyMap() +let keyMap: Map = new Map(); + +export function swapKeyMap(newMap: Map): void { + keyMap = newMap; +} + +export function getKeyMapSize(): number { + return keyMap.size; +} + +/** + * Pre-fetch JWT public keys from Vault, не мутируя глобальный keyMap. + * Возвращает новую Map для атомарного swap'а. + */ +export async function fetchJwtKeysFromVault( vaultAddr: string, vaultToken: string, mount: string, kidPath: string, kidsPrefix: string, -): Promise { - const { fetchVaultKV2 } = await import('../config/vault'); - +): Promise> { const kidData = await fetchVaultKV2(vaultAddr, vaultToken, mount, kidPath); if (!kidData) { - logger.warn('Failed to read JWT kid config from Vault'); - return; + throw new Error('Failed to read JWT kid config from Vault'); } const kids: string[] = []; @@ -41,10 +62,11 @@ export async function loadJwtKeysFromVault( if (kidData.previous && kidData.previous !== kidData.active) kids.push(kidData.previous); if (kids.length === 0) { - logger.warn('No active/previous kids found in Vault'); - return; + throw new Error('No active/previous kids found in Vault'); } + const next = new Map(); + for (const kid of kids) { const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`); if (!kidSecret?.public_key) { @@ -52,16 +74,15 @@ export async function loadJwtKeysFromVault( continue; } - try { - const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm); - keyMap.set(kid, key); - logger.info(`Loaded JWT public key for kid=${kid}`); - } catch (err: any) { - logger.error(`Failed to import public key for kid=${kid}: ${err.message}`); - } + const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm); + next.set(kid, key); } - logger.info(`JWT key store loaded: ${keyMap.size} key(s)`); + if (next.size === 0) { + throw new Error('No public keys could be loaded from Vault'); + } + + return next; } export async function verifyAccessToken(token: string): Promise { @@ -71,17 +92,17 @@ export async function verifyAccessToken(token: string): Promise { const header = jose.decodeProtectedHeader(token); const kid = header.kid; - if (!kid) { - throw Object.assign(new Error('Missing kid in token header'), { status: 401 }); + if (!kid || typeof kid !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(kid)) { + throw Object.assign(new Error('Missing or invalid kid in token header'), { status: 401 }); } const key = keyMap.get(kid); if (!key) { - logger.warn(`Unknown kid=${kid}`); throw Object.assign(new Error('Unknown token kid'), { status: 401 }); } - if (header.alg !== env.jwt.algorithm) { + // Двойная защита от algorithm confusion: проверяем точное совпадение + if (header.alg !== env.jwt.algorithm || !ALLOWED_ALGORITHMS.has(String(header.alg))) { throw Object.assign(new Error('Invalid token algorithm'), { status: 401 }); } diff --git a/apps/api/src/services/key-rotation.service.ts b/apps/api/src/services/key-rotation.service.ts index 361db9e..305e652 100644 --- a/apps/api/src/services/key-rotation.service.ts +++ b/apps/api/src/services/key-rotation.service.ts @@ -1,7 +1,7 @@ import { env, getVaultToken } from '../config/env'; import { vaultAppRoleLogin } from '../config/vault'; -import { loadJwtKeysFromVault } from './jwt.service'; -import { loadCsrfSecret } from './csrf.service'; +import { fetchJwtKeysFromVault, swapKeyMap, getKeyMapSize } from './jwt.service'; +import { fetchCsrfConfig, swapCsrfConfig } from './csrf.service'; import { logger } from '../lib/logger'; const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour @@ -10,9 +10,8 @@ let timer: NodeJS.Timeout | null = null; let currentVaultToken: string | null = null; /** - * Refresh JWT public keys (active + previous) and CSRF secret from Vault. - * Errors are logged but do NOT throw — старые значения остаются в памяти, - * сервис продолжает работать до следующего успешного refresh. + * Atomic refresh: pre-fetch JWT keys + CSRF config, swap globals only if BOTH succeed. + * При любой ошибке оставляем старые значения в памяти, сервис продолжает работать. */ export async function refreshAllKeys(): Promise { const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath } = env.vault; @@ -22,7 +21,7 @@ export async function refreshAllKeys(): Promise { return; } - // Use token from initEnv first call; re-login only if we don't have one yet. + // Vault token: используем закэшированный из initEnv, либо логинимся заново let token = currentVaultToken || getVaultToken(); if (!token) { const fresh = await vaultAppRoleLogin(addr, roleId, secretId); @@ -34,19 +33,32 @@ export async function refreshAllKeys(): Promise { currentVaultToken = fresh; } - try { - await loadJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix); - } catch (err: any) { - logger.error(`Failed to refresh JWT keys: ${err.message}`); + // ── Pre-fetch обоих секретов параллельно (НЕ мутируя глобал) ─────────── + const jwtPromise = fetchJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix); + const csrfPromise = csrfPath ? fetchCsrfConfig(addr, token, mount, csrfPath) : Promise.resolve(null); + + const [jwtResult, csrfResult] = await Promise.allSettled([jwtPromise, csrfPromise]); + + // ── Атомарность: если хоть один обязательный fetch упал — НИЧЕГО не меняем ── + if (jwtResult.status === 'rejected') { + logger.error(`Key refresh ABORTED — JWT keys fetch failed: ${jwtResult.reason?.message || jwtResult.reason}`); + return; + } + if (csrfPath && csrfResult.status === 'rejected') { + logger.error(`Key refresh ABORTED — CSRF fetch failed: ${csrfResult.reason?.message || csrfResult.reason}`); + return; } - if (csrfPath) { - try { - await loadCsrfSecret(addr, token, mount, csrfPath); - } catch (err: any) { - logger.error(`Failed to refresh CSRF secret: ${err.message}`); - } + // ── Atomic swap (синхронные операции, нельзя прервать) ────────────────── + swapKeyMap(jwtResult.value); + if (csrfResult.status === 'fulfilled' && csrfResult.value) { + swapCsrfConfig(csrfResult.value); } + + logger.info( + `Keys refreshed atomically: JWT keys=${getKeyMapSize()}` + + (csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '') + ); } export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void { @@ -56,8 +68,7 @@ export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void void refreshAllKeys().catch((err) => logger.error(`Key rotation tick failed: ${err?.message || err}`) ); - // On token expiry Vault will return 403 — we need to re-login. - // Reset cached token so refreshAllKeys re-logs in on next call. + // На каждый тик — invalidate Vault token (он мог истечь), будет re-login currentVaultToken = null; }, intervalMs); logger.info(`Key rotation scheduled (every ${intervalMs}ms)`); diff --git a/apps/api/src/services/wallet-ops.service.ts b/apps/api/src/services/wallet-ops.service.ts new file mode 100644 index 0000000..fcc2f07 --- /dev/null +++ b/apps/api/src/services/wallet-ops.service.ts @@ -0,0 +1,490 @@ +/** + * Wallet operations across chains: balance, transactions, build unsigned send tx. + * Non-custodial: server NEVER signs — клиент подписывает приватом. + */ +import { ethers } from 'ethers'; +import { env } from '../config/env'; + +export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC'; + +const TIMEOUT_MS = 15_000; + +// ── External APIs ── +const BLOCKSTREAM = 'https://blockstream.info/api'; +const TRONGRID = 'https://api.trongrid.io'; +const BSC_RPC = 'https://bsc-dataseed.binance.org'; +const ETH_RPC = 'https://ethereum-rpc.publicnode.com'; +const SOL_RPC = 'https://api.mainnet-beta.solana.com'; + +const 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)', + 'function decimals() view returns (uint8)', +]; + +// ─────────────────────── BALANCE ─────────────────────── + +export interface BalanceResult { + chain: ChainCode; + address: string; + native: string; // в smallest units (satoshi/wei/lamports/sun) + tokens?: Record; // например { USDT: "12345678" } +} + +export async function getBalance(chain: ChainCode, address: string): Promise { + switch (chain) { + case 'BTC': + return { chain, address, native: await btcBalance(address) }; + 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 }; + } + case 'ETH': { + const { native, tokens } = await evmBalance(ETH_RPC, address, [{ symbol: 'USDT', addr: USDT_ERC20 }]); + return { chain, address, native, tokens }; + } + case 'SOL': + return { chain, address, native: await solBalance(address) }; + } +} + +async function btcBalance(address: string): Promise { + const res = await fetchJson(`${BLOCKSTREAM}/address/${address}`); + const stats = res.chain_stats; + const sat = BigInt(stats.funded_txo_sum) - BigInt(stats.spent_txo_sum); + return sat.toString(); +} + +async function trxBalance(address: string): Promise<{ trx: string; usdt: string }> { + 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'; + + return { trx, usdt }; +} + +async function evmBalance( + rpc: string, + address: string, + tokens: { symbol: string; addr: string }[], +): Promise<{ native: string; tokens: Record }> { + const provider = new ethers.providers.StaticJsonRpcProvider(rpc); + const native = await withTimeout(provider.getBalance(address), TIMEOUT_MS, 'EVM balance timeout'); + + const tokenBalances: Record = {}; + await Promise.all( + tokens.map(async ({ symbol, addr }) => { + try { + const c = new ethers.Contract(addr, ERC20_ABI, provider); + const bal: ethers.BigNumber = await withTimeout(c.balanceOf(address), TIMEOUT_MS, `${symbol} balance timeout`); + tokenBalances[symbol] = bal.toString(); + } catch { + tokenBalances[symbol] = '0'; + } + }), + ); + + return { native: native.toString(), tokens: tokenBalances }; +} + +async function solBalance(address: string): Promise { + const res = await fetchJson(SOL_RPC, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getBalance', + params: [address], + }), + }); + return String(res.result?.value ?? 0); +} + +// ─────────────────────── TRANSACTIONS ─────────────────────── + +export interface TxItem { + txid: string; + timestamp: number | null; // unix seconds + direction: 'in' | 'out' | 'self'; + amount?: string; + token?: string; + to?: string; + from?: string; +} + +export async function getTransactions(chain: ChainCode, address: string, limit: number): Promise { + switch (chain) { + case 'BTC': + return btcTransactions(address, limit); + case 'TRX': + return trxTransactions(address, limit); + case 'BSC': + return scanTransactions('https://api.bscscan.com/api', env.bscscanApiKey, address, limit); + case 'ETH': + return scanTransactions('https://api.etherscan.io/api', env.etherscanApiKey, address, limit); + case 'SOL': + return solTransactions(address, limit); + } +} + +async function scanTransactions( + apiBase: string, + apiKey: string | null, + address: string, + limit: number, +): Promise { + if (!apiKey) return []; + + const url = new URL(apiBase); + url.searchParams.set('module', 'account'); + url.searchParams.set('action', 'txlist'); + url.searchParams.set('address', address); + url.searchParams.set('startblock', '0'); + url.searchParams.set('endblock', '99999999'); + url.searchParams.set('page', '1'); + url.searchParams.set('offset', String(Math.min(limit, 100))); + url.searchParams.set('sort', 'desc'); + url.searchParams.set('apikey', apiKey); + + const res = await fetchJson(url.toString()); + if (res.status !== '1' || !Array.isArray(res.result)) return []; + + return (res.result as any[]).slice(0, limit).map((tx) => { + const isOut = String(tx.from).toLowerCase() === address.toLowerCase(); + return { + txid: tx.hash, + timestamp: tx.timeStamp ? parseInt(tx.timeStamp, 10) : null, + direction: (isOut ? 'out' : 'in') as TxItem['direction'], + amount: tx.value || undefined, + from: tx.from, + to: tx.to, + }; + }); +} + +async function btcTransactions(address: string, limit: number): Promise { + const txs = await fetchJson(`${BLOCKSTREAM}/address/${address}/txs`); + return (txs as any[]).slice(0, limit).map((tx) => { + const inSelf = tx.vin.some((v: any) => v.prevout?.scriptpubkey_address === address); + const outSelf = tx.vout.some((v: any) => v.scriptpubkey_address === address); + const direction: TxItem['direction'] = inSelf && outSelf ? 'self' : inSelf ? 'out' : 'in'; + return { + txid: tx.txid, + timestamp: tx.status?.block_time ?? null, + direction, + amount: String( + tx.vout + .filter((v: any) => (direction === 'in' ? v.scriptpubkey_address === address : v.scriptpubkey_address !== address)) + .reduce((s: bigint, v: any) => s + BigInt(v.value), 0n), + ), + }; + }); +} + +async function trxTransactions(address: string, limit: number): Promise { + const headers: Record = { Accept: 'application/json' }; + if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey; + + const res = await fetchJson( + `${TRONGRID}/v1/accounts/${address}/transactions?limit=${limit}`, + { headers }, + ); + return ((res.data as any[]) || []).slice(0, limit).map((tx) => { + const contract = tx.raw_data?.contract?.[0]; + const value = contract?.parameter?.value; + const fromAddr = value?.owner_address ? hexToTron(value.owner_address) : ''; + const toAddr = value?.to_address ? hexToTron(value.to_address) : ''; + const isOut = fromAddr === address; + return { + txid: tx.txID, + timestamp: tx.block_timestamp ? Math.floor(tx.block_timestamp / 1000) : null, + direction: (isOut ? 'out' : 'in') as TxItem['direction'], + amount: value?.amount ? String(value.amount) : undefined, + from: fromAddr || undefined, + to: toAddr || undefined, + }; + }); +} + +async function solTransactions(address: string, limit: number): Promise { + const res = await fetchJson(SOL_RPC, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getSignaturesForAddress', + params: [address, { limit }], + }), + }); + return ((res.result as any[]) || []).map((sig) => ({ + txid: sig.signature, + timestamp: sig.blockTime ?? null, + direction: 'self' as const, // без deep parsing — направление неизвестно + })); +} + +// ─────────────────────── BUILD SEND (UNSIGNED TX) ─────────────────────── + +export interface BuildSendParams { + chain: ChainCode; + from: string; + to: string; + amount: string; + token?: string; // 'USDT' и т.д.; для native перевода — undefined +} + +export type UnsignedTx = + | { kind: 'btc'; from: string; to: string; amountSat: string; utxos: any[]; feeRateSatPerVb: number } + | { kind: 'tron'; transaction: any } + | { kind: 'evm'; to: string; data: string; value: string; chainId: number; gasLimit?: string } + | { kind: 'solana'; instructions: any; recentBlockhash: string }; + +export async function buildSend(p: BuildSendParams): Promise { + switch (p.chain) { + case 'BTC': + return buildBtcSend(p); + case 'TRX': + return buildTrxSend(p); + case 'BSC': + return buildEvmSend(p, BSC_RPC, 56, USDT_BEP20); + case 'ETH': + return buildEvmSend(p, ETH_RPC, 1, USDT_ERC20); + case 'SOL': + return buildSolSend(p); + } +} + +async function buildBtcSend(p: BuildSendParams): Promise { + if (p.token) throw new Error('BTC tokens not supported'); + const utxos = await fetchJson(`${BLOCKSTREAM}/address/${p.from}/utxo`); + const fees = await fetchJson(`${BLOCKSTREAM}/fee-estimates`); + const confirmed = ((utxos as any[]) || []).filter((u) => u.status?.confirmed); + + return { + kind: 'btc', + from: p.from, + to: p.to, + amountSat: p.amount, + utxos: confirmed.map((u) => ({ txid: u.txid, vout: u.vout, value: u.value })), + feeRateSatPerVb: Math.ceil((fees as any)['3'] ?? (fees as any)['6'] ?? 5), + }; +} + +async function buildTrxSend(p: BuildSendParams): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey; + + if (!p.token) { + // Native TRX + const res = await fetchJson(`${TRONGRID}/wallet/createtransaction`, { + method: 'POST', + headers, + body: JSON.stringify({ owner_address: p.from, to_address: p.to, amount: Number(p.amount), visible: true }), + }); + return { kind: 'tron', transaction: res }; + } + + if (p.token.toUpperCase() === 'USDT') { + // TRC20 USDT + const param = tronAddressToHex(p.to).padStart(64, '0') + BigInt(p.amount).toString(16).padStart(64, '0'); + const res = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, { + method: 'POST', + headers, + body: JSON.stringify({ + owner_address: p.from, + contract_address: USDT_TRC20, + function_selector: 'transfer(address,uint256)', + parameter: param, + fee_limit: 100_000_000, + call_value: 0, + visible: true, + }), + }); + return { kind: 'tron', transaction: res }; + } + + throw new Error(`Token ${p.token} not supported on TRX`); +} + +async function buildEvmSend(p: BuildSendParams, rpc: string, chainId: number, usdtAddr: string): Promise { + if (!ethers.utils.isAddress(p.to)) throw new Error('Invalid recipient address'); + + if (!p.token) { + return { kind: 'evm', to: p.to, data: '0x', value: ethers.BigNumber.from(p.amount).toHexString(), chainId }; + } + + if (p.token.toUpperCase() === 'USDT') { + const iface = new ethers.utils.Interface(ERC20_ABI); + const data = iface.encodeFunctionData('transfer', [p.to, p.amount]); + return { kind: 'evm', to: usdtAddr, data, value: '0x0', chainId }; + } + + throw new Error(`Token ${p.token} not supported on ${chainId === 56 ? 'BSC' : 'ETH'}`); +} + +async function buildSolSend(p: BuildSendParams): Promise { + const { + Connection, + PublicKey, + SystemProgram, + Transaction, + } = await import('@solana/web3.js'); + + const conn = new Connection(SOL_RPC, 'confirmed'); + + let fromPk: InstanceType; + let toPk: InstanceType; + try { + fromPk = new PublicKey(p.from); + toPk = new PublicKey(p.to); + } catch { + throw new Error('Invalid Solana address'); + } + + const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash(); + const tx = new Transaction({ + feePayer: fromPk, + blockhash, + lastValidBlockHeight, + }); + + if (!p.token) { + // Native SOL transfer + tx.add( + SystemProgram.transfer({ + fromPubkey: fromPk, + toPubkey: toPk, + lamports: BigInt(p.amount), + }), + ); + } else { + // SPL token transfer (manual instruction — не тянем @solana/spl-token) + const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); + const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'); + + const mint = solMintFor(p.token); + if (!mint) throw new Error(`Unsupported SOL token: ${p.token}`); + + const fromAta = await deriveAta(new PublicKey(mint), fromPk, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID); + const toAta = await deriveAta(new PublicKey(mint), toPk, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID); + + // Transfer instruction (instruction tag = 3 для SPL Token Transfer) + const data = Buffer.alloc(9); + data.writeUInt8(3, 0); + data.writeBigUInt64LE(BigInt(p.amount), 1); + + tx.add({ + programId: TOKEN_PROGRAM_ID, + keys: [ + { pubkey: fromAta, isSigner: false, isWritable: true }, + { pubkey: toAta, isSigner: false, isWritable: true }, + { pubkey: fromPk, isSigner: true, isWritable: false }, + ], + data, + }); + } + + // Сериализуем сообщение (без подписей) для клиента + const serialized = tx.serialize({ requireAllSignatures: false, verifySignatures: false }); + + return { + kind: 'solana', + instructions: serialized.toString('base64'), + recentBlockhash: blockhash, + }; +} + +function solMintFor(symbol: string): string | null { + const map: Record = { + USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + }; + return map[symbol] ?? null; +} + +async function deriveAta( + mint: any, + owner: any, + tokenProgramId: any, + associatedTokenProgramId: any, +): Promise { + const { PublicKey } = await import('@solana/web3.js'); + const [pda] = await PublicKey.findProgramAddress( + [owner.toBuffer(), tokenProgramId.toBuffer(), mint.toBuffer()], + associatedTokenProgramId, + ); + return pda; +} + +// ─────────────────────── HELPERS ─────────────────────── + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +function tronAddressToHex(address: string): string { + let num = 0n; + for (const ch of address) { + const i = BASE58_ALPHABET.indexOf(ch); + if (i === -1) throw new Error('Invalid base58 character in TRON address'); + num = num * 58n + BigInt(i); + } + const hex = num.toString(16).padStart(50, '0'); + return hex.slice(2, 42); // 20 bytes без префикса 0x41 +} + +function hexToTron(hex: string): string { + // Формат TronGrid: 41xxxx (20 bytes after prefix). Это base58check. + // Для простоты возвращаем как есть hex (можно улучшить на full base58check если нужно). + return hex; +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const res = await fetch(url, { ...init, signal: controller.signal }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Upstream ${res.status}: ${body.slice(0, 200)}`); + } + return await res.json(); + } finally { + clearTimeout(t); + } +} + +function withTimeout(promise: Promise, ms: number, msg: string): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(msg)), ms); + promise.then( + (v) => { clearTimeout(t); resolve(v); }, + (e) => { clearTimeout(t); reject(e); }, + ); + }); +} diff --git a/apps/api/swagger.json b/apps/api/swagger.json index 601415e..dbfbae9 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -2,19 +2,28 @@ "openapi": "3.0.0", "info": { "title": "CryptoWallet API", - "version": "2.0.0", - "description": "Multi-chain cryptocurrency wallet API with blockchain proxy services" + "version": "2.1.0", + "description": "Multi-chain crypto wallet API. Auth via JWT (cookie/Bearer), issued by external auth-service (BITOK). Non-custodial: server NEVER signs transactions, только строит unsigned tx + хранит зашифрованный vault." }, "servers": [ - { "url": "/api", "description": "API" } + { "url": "/api", "description": "API root" } + ], + "tags": [ + { "name": "System", "description": "Health & service info" }, + { "name": "Wallets", "description": "User wallet records" }, + { "name": "Wallet Ops", "description": "Per-chain balance / transactions / send" }, + { "name": "Vault", "description": "Encrypted mnemonic blob storage (opaque)" }, + { "name": "BTC", "description": "Bitcoin RPC proxy (Blockstream)" }, + { "name": "TRON", "description": "TRON RPC proxy (TronGrid)" }, + { "name": "Solana", "description": "Solana swap proxy (Jupiter)" }, + { "name": "TRON Swap", "description": "TRON swap proxy (SunSwap + FeeSwapRouter)" }, + { "name": "BSC", "description": "BSC swap proxy (PancakeSwap V2)" }, + { "name": "Relay", "description": "Cross-chain bridges (Relay Protocol)" } ], "components": { "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" - } + "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }, + "cookieAuth": { "type": "apiKey", "in": "cookie", "name": "access_token" } }, "schemas": { "Error": { @@ -24,78 +33,388 @@ "error": { "type": "string" } } }, + "SuccessEmpty": { + "type": "object", + "properties": { "success": { "type": "boolean", "example": true } } + }, + "HealthResponse": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "data": { "type": "object", "properties": { "status": { "type": "string", "example": "ok" } } } + } + }, + "Chain": { + "type": "string", + "enum": ["ETH", "BTC", "SOL", "TRX", "BSC"] + }, "Wallet": { "type": "object", "properties": { - "chain": { "type": "string", "enum": ["ETH", "BTC", "SOL", "TRX", "BSC"] }, + "chain": { "$ref": "#/components/schemas/Chain" }, "address": { "type": "string" }, "derivationPath": { "type": "string" } } }, - "HealthResponse": { + "WalletInput": { + "type": "object", + "required": ["chain", "address", "derivationPath"], + "properties": { + "chain": { "$ref": "#/components/schemas/Chain" }, + "address": { "type": "string", "maxLength": 256 }, + "derivationPath": { "type": "string", "maxLength": 64 } + } + }, + "CreateWalletsRequest": { + "type": "object", + "required": ["wallets"], + "properties": { + "wallets": { + "type": "array", "minItems": 1, "maxItems": 20, + "items": { "$ref": "#/components/schemas/WalletInput" } + } + } + }, + "WalletsResponse": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "data": { "type": "array", "items": { "$ref": "#/components/schemas/Wallet" } } + } + }, + "BalanceResponse": { "type": "object", "properties": { "success": { "type": "boolean", "example": true }, "data": { "type": "object", "properties": { - "status": { "type": "string", "example": "ok" } + "chain": { "$ref": "#/components/schemas/Chain" }, + "address": { "type": "string" }, + "native": { "type": "string", "description": "Balance в smallest units (sat/wei/lamports/sun)" }, + "tokens": { + "type": "object", + "additionalProperties": { "type": "string" }, + "example": { "USDT": "12345678" } + } } } } + }, + "Transaction": { + "type": "object", + "properties": { + "txid": { "type": "string" }, + "timestamp": { "type": "integer", "nullable": true, "description": "Unix seconds" }, + "direction": { "type": "string", "enum": ["in", "out", "self"] }, + "amount": { "type": "string", "nullable": true }, + "token": { "type": "string", "nullable": true }, + "from": { "type": "string", "nullable": true }, + "to": { "type": "string", "nullable": true } + } + }, + "TransactionsResponse": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": { "type": "array", "items": { "$ref": "#/components/schemas/Transaction" } } + } + }, + "SendRequest": { + "type": "object", + "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 (TRX/ETH/BNB/BTC)" } + } + }, + "UnsignedTxResponse": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": { + "type": "object", + "description": "Unsigned tx — формат зависит от chain (kind: btc | tron | evm | solana). Клиент подписывает приватом и broadcast'ит через соответствующий /api/{btc,tron}/broadcast endpoint" + } + } + }, + "VaultResponse": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "data": { + "type": "object", + "properties": { + "encryptedVault": { "type": "string", "description": "AES-GCM encrypted mnemonic, base64" }, + "vaultSalt": { "type": "string", "description": "PBKDF2 salt, hex" } + } + } + } + }, + "VaultPutRequest": { + "type": "object", + "required": ["encryptedVault", "vaultSalt"], + "properties": { + "encryptedVault": { "type": "string", "maxLength": 8192 }, + "vaultSalt": { "type": "string", "maxLength": 128 } + } } } }, + "security": [ + { "cookieAuth": [] }, + { "bearerAuth": [] } + ], "paths": { "/health": { "get": { - "summary": "Health check", + "summary": "Liveness check", "tags": ["System"], + "security": [], + "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HealthResponse" } } } } } + } + }, + + "/wallets": { + "get": { + "summary": "Get all wallets of authenticated user", + "tags": ["Wallets"], "responses": { - "200": { - "description": "Service is healthy", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/HealthResponse" } - } - } - } + "200": { "description": "List of wallets", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WalletsResponse" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "post": { + "summary": "Upsert wallets for authenticated user", + "description": "user_id берётся из JWT (sub). При первом обращении создаёт user-row автоматически. На конфликт (user_id, chain) — обновляет address + derivationPath.", + "tags": ["Wallets"], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateWalletsRequest" } } } + }, + "responses": { + "201": { "description": "Created/updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WalletsResponse" } } } }, + "400": { "description": "Invalid input", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, - "/wallets": { + + "/wallets/{chain}/balance": { "get": { - "summary": "Get user wallets", - "tags": ["Wallets"], - "security": [{ "bearerAuth": [] }], + "summary": "Balance for user wallet in chain", + "tags": ["Wallet Ops"], + "parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }], "responses": { - "200": { - "description": "List of wallets", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { "type": "boolean", "example": true }, - "data": { - "type": "array", - "items": { "$ref": "#/components/schemas/Wallet" } - } - } - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Error" } - } - } - } + "200": { "description": "Balance", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BalanceResponse" } } } }, + "404": { "description": "Wallet for this chain not found" }, + "502": { "description": "Upstream RPC error" } } } + }, + + "/wallets/{chain}/transactions": { + "get": { + "summary": "Transaction history for user wallet in chain", + "tags": ["Wallet Ops"], + "parameters": [ + { "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 } } + ], + "responses": { + "200": { "description": "List of transactions", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TransactionsResponse" } } } }, + "404": { "description": "Wallet for this chain not found" } + } + } + }, + + "/wallets/{chain}/send": { + "post": { + "summary": "Build unsigned send transaction (non-custodial)", + "description": "Возвращает unsigned tx. Клиент подписывает приватным ключом и broadcast'ит через /api/{btc,tron}/broadcast или RPC своей цепи.", + "tags": ["Wallet Ops"], + "parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendRequest" } } } + }, + "responses": { + "200": { "description": "Unsigned tx", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnsignedTxResponse" } } } }, + "400": { "description": "Invalid input" }, + "404": { "description": "Wallet not found" }, + "502": { "description": "Upstream RPC error" } + } + } + }, + + "/vault": { + "get": { + "summary": "Get user's encrypted mnemonic vault", + "tags": ["Vault"], + "responses": { + "200": { "description": "Encrypted vault blob", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VaultResponse" } } } }, + "404": { "description": "Vault not yet stored" } + } + }, + "put": { + "summary": "Save / replace encrypted mnemonic vault", + "description": "Vault — opaque blob (AES-GCM на стороне клиента). Сервер хранит как есть, не расшифровывает.", + "tags": ["Vault"], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VaultPutRequest" } } } + }, + "responses": { + "200": { "description": "Saved", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessEmpty" } } } }, + "400": { "description": "Invalid input" } + } + } + }, + + "/btc/utxos/{address}": { + "get": { + "summary": "Confirmed UTXOs for Bitcoin address", + "tags": ["BTC"], + "parameters": [{ "name": "address", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { "200": { "description": "UTXOs" }, "401": { "description": "Not authenticated" } } + } + }, + "/btc/fee-estimates": { + "get": { + "summary": "Bitcoin fee estimates (sat/vB)", + "tags": ["BTC"], + "responses": { "200": { "description": "fast/normal/slow" }, "401": { "description": "Not authenticated" } } + } + }, + "/btc/broadcast": { + "post": { + "summary": "Broadcast raw signed Bitcoin tx", + "tags": ["BTC"], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["hex"], "properties": { "hex": { "type": "string" } } } } } }, + "responses": { "200": { "description": "txid" }, "400": { "description": "Invalid hex" } } + } + }, + + "/tron/account/{address}": { + "get": { + "summary": "TRON account info + USDT (TRC20) balance", + "tags": ["TRON"], + "parameters": [{ "name": "address", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { "200": { "description": "Account data" } } + } + }, + "/tron/createtransaction": { + "post": { + "summary": "Build unsigned TRX transfer", + "tags": ["TRON"], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["owner_address", "to_address", "amount"], "properties": { "owner_address": { "type": "string" }, "to_address": { "type": "string" }, "amount": { "type": "integer" } } } } } }, + "responses": { "200": { "description": "Unsigned tx" } } + } + }, + "/tron/triggersmartcontract": { + "post": { + "summary": "Build unsigned TRC20 contract call", + "tags": ["TRON"], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } }, + "responses": { "200": { "description": "Unsigned tx" } } + } + }, + "/tron/broadcasttransaction": { + "post": { + "summary": "Broadcast signed TRON tx", + "tags": ["TRON"], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } }, + "responses": { "200": { "description": "Result" } } + } + }, + + "/sol/swap/quote": { + "get": { + "summary": "Jupiter swap quote (Solana)", + "tags": ["Solana"], + "parameters": [ + { "name": "inputMint", "in": "query", "required": true, "schema": { "type": "string" } }, + { "name": "outputMint", "in": "query", "required": true, "schema": { "type": "string" } }, + { "name": "amount", "in": "query", "required": true, "schema": { "type": "string" } }, + { "name": "slippageBps", "in": "query", "required": true, "schema": { "type": "integer" } } + ], + "responses": { "200": { "description": "Quote" } } + } + }, + "/sol/swap/build": { + "post": { + "summary": "Jupiter swap build", + "tags": ["Solana"], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["quoteResponse", "userPublicKey"], "properties": { "quoteResponse": { "type": "object" }, "userPublicKey": { "type": "string" } } } } } }, + "responses": { "200": { "description": "Swap tx" } } + } + }, + + "/tron/swap/quote": { + "get": { + "summary": "TRON swap quote (TRX <-> USDT)", + "tags": ["TRON Swap"], + "parameters": [ + { "name": "from", "in": "query", "required": true, "schema": { "type": "string", "enum": ["TRX", "USDT"] } }, + { "name": "to", "in": "query", "required": true, "schema": { "type": "string", "enum": ["TRX", "USDT"] } }, + { "name": "amount", "in": "query", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Quote" } } + } + }, + "/tron/swap/build": { + "post": { + "summary": "Build TRON swap transactions", + "tags": ["TRON Swap"], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["from", "to", "amount", "amountOutMin", "userAddress"], "properties": { "from": { "type": "string" }, "to": { "type": "string" }, "amount": { "type": "string" }, "amountOutMin": { "type": "string" }, "userAddress": { "type": "string" } } } } } }, + "responses": { "200": { "description": "Unsigned txs" } } + } + }, + "/tron/swap/broadcast": { + "post": { + "summary": "Broadcast signed TRON swap", + "tags": ["TRON Swap"], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["signedTransaction"], "properties": { "signedTransaction": { "type": "object" } } } } } }, + "responses": { "200": { "description": "Result" } } + } + }, + + "/bsc/swap/quote": { + "get": { + "summary": "BSC swap quote (PancakeSwap V2)", + "tags": ["BSC"], + "parameters": [ + { "name": "from", "in": "query", "required": true, "schema": { "type": "string", "enum": ["BNB", "USDT", "DOGE"] } }, + { "name": "to", "in": "query", "required": true, "schema": { "type": "string", "enum": ["BNB", "USDT", "DOGE"] } }, + { "name": "amount", "in": "query", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "Quote" } } + } + }, + "/bsc/swap/build": { + "post": { + "summary": "Build BSC swap transactions", + "tags": ["BSC"], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["from", "to", "amount", "amountOutMin", "userAddress"], "properties": { "from": { "type": "string" }, "to": { "type": "string" }, "amount": { "type": "string" }, "amountOutMin": { "type": "string" }, "userAddress": { "type": "string" } } } } } }, + "responses": { "200": { "description": "Unsigned txs" } } + } + }, + + "/relay/quote/v2": { + "get": { "summary": "Relay bridge quote", "tags": ["Relay"], "responses": { "200": { "description": "Quote" } } } + }, + "/relay/intents/status/v3": { + "get": { "summary": "Relay intent status", "tags": ["Relay"], "responses": { "200": { "description": "Status" } } } + }, + "/relay/execute/{action}": { + "post": { + "summary": "Relay execute", + "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" } } + } } } } diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index ada3206..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,51 +0,0 @@ -services: - postgres: - image: postgres:16-alpine - container_name: cryptowallet-db - restart: unless-stopped - environment: - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB: ${POSTGRES_DB:-cryptowallet_v2} - volumes: - - pgdata:/var/lib/postgresql/data - # Наружу НЕ экспозим — только docker network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-cryptowallet_v2}"] - interval: 5s - timeout: 5s - retries: 10 - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" - - api: - build: - context: . - dockerfile: Dockerfile - image: cryptowallet-api:latest - container_name: cryptowallet-api - restart: unless-stopped - ports: - - "3001:3001" - env_file: - - .env - environment: - # Override внутри docker network - DB_HOST: postgres - API_PORT: "3001" - NODE_ENV: production - depends_on: - postgres: - condition: service_healthy - logging: - driver: json-file - options: - max-size: "20m" - max-file: "5" - -volumes: - pgdata: - driver: local diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26419c8..d00527a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: apps/api: dependencies: + '@solana/web3.js': + specifier: ^1.98.4 + version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + bs58: + specifier: ^6.0.0 + version: 6.0.0 cookie-parser: specifier: ^1.4.7 version: 1.4.7 @@ -28,10 +34,13 @@ importers: version: 16.6.1 ethers: specifier: 5.7.2 - version: 5.7.2(bufferutil@4.1.0) + version: 5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) express: specifier: ^4.21.0 version: 4.22.1 + express-rate-limit: + specifier: ^8.4.1 + version: 8.4.1(express@4.22.1) helmet: specifier: ^8.0.0 version: 8.1.0 @@ -90,6 +99,10 @@ importers: packages: + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -300,6 +313,14 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -315,6 +336,35 @@ packages: '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@solana/buffer-layout@4.0.1': + resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} + engines: {node: '>=5.10'} + + '@solana/codecs-core@2.3.0': + resolution: {integrity: sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.3.3' + + '@solana/codecs-numbers@2.3.0': + resolution: {integrity: sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.3.3' + + '@solana/errors@2.3.0': + resolution: {integrity: sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==} + engines: {node: '>=20.18.0'} + hasBin: true + peerDependencies: + typescript: '>=5.3.3' + + '@solana/web3.js@1.98.4': + resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} + + '@swc/helpers@0.5.21': + resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -350,6 +400,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@20.19.37': resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} @@ -374,6 +427,15 @@ packages: '@types/swagger-ui-express@4.1.8': resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@types/ws@7.4.7': + resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@7.18.0': resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} engines: {node: ^18.18.0 || >=20.0.0} @@ -456,6 +518,10 @@ packages: aes-js@3.0.0: resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==} + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -487,6 +553,15 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base-x@3.0.11: + resolution: {integrity: sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==} + + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bech32@1.1.4: resolution: {integrity: sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==} @@ -504,6 +579,9 @@ packages: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + borsh@0.7.0: + resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -517,9 +595,18 @@ packages: brorand@1.1.0: resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + bs58@4.0.1: + resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} + + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bufferutil@4.1.0: resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} engines: {node: '>=6.14.2'} @@ -544,6 +631,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -562,6 +653,13 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -618,6 +716,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delay@5.0.0: + resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} + engines: {node: '>=10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -674,6 +776,12 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + + es6-promisify@5.0.0: + resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -730,10 +838,23 @@ packages: ethers@5.7.2: resolution: {integrity: sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + express-rate-limit@8.4.1: + resolution: {integrity: sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} + eyes@0.1.8: + resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} + engines: {node: '> 0.1.90'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -747,6 +868,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-stable-stringify@1.0.0: + resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -860,10 +984,16 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -887,6 +1017,10 @@ packages: resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} engines: {node: '>= 0.10'} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -918,6 +1052,16 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic-ws@4.0.1: + resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} + peerDependencies: + ws: '*' + + jayson@4.3.0: + resolution: {integrity: sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==} + engines: {node: '>=8'} + hasBin: true + jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} @@ -937,6 +1081,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1061,6 +1208,15 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -1245,6 +1401,9 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rpc-websockets@9.3.8: + resolution: {integrity: sha512-7r+fm4tSJmLf9GvZfL1DJ1SJwpagpp6AazqM0FUaeV7CA+7+NYINSk1syWa4tU/6OF2CyBicLtzENGmXRJH6wQ==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1316,6 +1475,12 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1332,6 +1497,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + superstruct@2.0.2: + resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} + engines: {node: '>=14.0.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1353,6 +1522,9 @@ packages: resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} engines: {node: '>=8.0.0'} + text-encoding-utf-8@1.0.2: + resolution: {integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -1368,6 +1540,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1406,6 +1581,9 @@ packages: tsconfig@7.0.0: resolution: {integrity: sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + turbo-darwin-64@2.8.15: resolution: {integrity: sha512-EElCh+Ltxex9lXYrouV3hHjKP3HFP31G91KMghpNHR/V99CkFudRcHcnWaorPbzAZizH1m8o2JkLL8rptgb8WQ==} cpu: [x64] @@ -1471,10 +1649,23 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + utf-8-validate@6.0.6: + resolution: {integrity: sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==} + engines: {node: '>=6.14.2'} + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -1482,6 +1673,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1506,6 +1703,30 @@ packages: utf-8-validate: optional: true + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -1520,6 +1741,8 @@ packages: snapshots: + '@babel/runtime@7.29.2': {} + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -1808,7 +2031,7 @@ snapshots: dependencies: '@ethersproject/logger': 5.8.0 - '@ethersproject/providers@5.7.2(bufferutil@4.1.0)': + '@ethersproject/providers@5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@ethersproject/abstract-provider': 5.8.0 '@ethersproject/abstract-signer': 5.8.0 @@ -1829,7 +2052,7 @@ snapshots: '@ethersproject/transactions': 5.8.0 '@ethersproject/web': 5.8.0 bech32: 1.1.4 - ws: 7.4.6(bufferutil@4.1.0) + ws: 7.4.6(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -2006,6 +2229,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2020,6 +2249,54 @@ snapshots: '@scarf/scarf@1.4.0': {} + '@solana/buffer-layout@4.0.1': + dependencies: + buffer: 6.0.3 + + '@solana/codecs-core@2.3.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 2.3.0(typescript@5.9.3) + typescript: 5.9.3 + + '@solana/codecs-numbers@2.3.0(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.3.0(typescript@5.9.3) + '@solana/errors': 2.3.0(typescript@5.9.3) + typescript: 5.9.3 + + '@solana/errors@2.3.0(typescript@5.9.3)': + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + typescript: 5.9.3 + + '@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)': + dependencies: + '@babel/runtime': 7.29.2 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@solana/buffer-layout': 4.0.1 + '@solana/codecs-numbers': 2.3.0(typescript@5.9.3) + agentkeepalive: 4.6.0 + bn.js: 5.2.3 + borsh: 0.7.0 + bs58: 4.0.1 + buffer: 6.0.3 + fast-stable-stringify: 1.0.0 + jayson: 4.3.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + node-fetch: 2.7.0 + rpc-websockets: 9.3.8 + superstruct: 2.0.2 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + + '@swc/helpers@0.5.21': + dependencies: + tslib: 2.8.1 + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -2060,6 +2337,8 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/node@12.20.55': {} + '@types/node@20.19.37': dependencies: undici-types: 6.21.0 @@ -2086,6 +2365,16 @@ snapshots: '@types/express': 5.0.6 '@types/serve-static': 2.2.0 + '@types/uuid@10.0.0': {} + + '@types/ws@7.4.7': + dependencies: + '@types/node': 20.19.37 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.37 + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2186,6 +2475,10 @@ snapshots: aes-js@3.0.0: {} + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -2214,6 +2507,14 @@ snapshots: balanced-match@1.0.2: {} + base-x@3.0.11: + dependencies: + safe-buffer: 5.2.1 + + base-x@5.0.1: {} + + base64-js@1.5.1: {} + bech32@1.1.4: {} binary-extensions@2.3.0: {} @@ -2239,6 +2540,12 @@ snapshots: transitivePeerDependencies: - supports-color + borsh@0.7.0: + dependencies: + bn.js: 5.2.3 + bs58: 4.0.1 + text-encoding-utf-8: 1.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -2254,8 +2561,21 @@ snapshots: brorand@1.1.0: {} + bs58@4.0.1: + dependencies: + base-x: 3.0.11 + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + buffer-from@1.1.2: {} + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bufferutil@4.1.0: dependencies: node-gyp-build: 4.8.4 @@ -2280,6 +2600,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -2302,6 +2624,10 @@ snapshots: commander@10.0.1: {} + commander@14.0.3: {} + + commander@2.20.3: {} + concat-map@0.0.1: {} content-disposition@0.5.4: @@ -2344,6 +2670,8 @@ snapshots: deep-is@0.1.4: {} + delay@5.0.0: {} + depd@2.0.0: {} destroy@1.2.0: {} @@ -2402,6 +2730,12 @@ snapshots: dependencies: es-errors: 1.3.0 + es6-promise@4.2.8: {} + + es6-promisify@5.0.0: + dependencies: + es6-promise: 4.2.8 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -2480,7 +2814,7 @@ snapshots: etag@1.8.1: {} - ethers@5.7.2(bufferutil@4.1.0): + ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: '@ethersproject/abi': 5.7.0 '@ethersproject/abstract-provider': 5.7.0 @@ -2500,7 +2834,7 @@ snapshots: '@ethersproject/networks': 5.7.1 '@ethersproject/pbkdf2': 5.7.0 '@ethersproject/properties': 5.7.0 - '@ethersproject/providers': 5.7.2(bufferutil@4.1.0) + '@ethersproject/providers': 5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@ethersproject/random': 5.7.0 '@ethersproject/rlp': 5.7.0 '@ethersproject/sha2': 5.7.0 @@ -2516,6 +2850,13 @@ snapshots: - bufferutil - utf-8-validate + eventemitter3@5.0.4: {} + + express-rate-limit@8.4.1(express@4.22.1): + dependencies: + express: 4.22.1 + ip-address: 10.1.0 + express@4.22.1: dependencies: accepts: 1.3.8 @@ -2552,6 +2893,8 @@ snapshots: transitivePeerDependencies: - supports-color + eyes@0.1.8: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -2566,6 +2909,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-stable-stringify@1.0.0: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -2699,10 +3044,16 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} import-fresh@3.3.1: @@ -2721,6 +3072,8 @@ snapshots: interpret@2.2.0: {} + ip-address@10.1.0: {} + ipaddr.js@1.9.1: {} is-binary-path@2.1.0: @@ -2743,6 +3096,28 @@ snapshots: isexe@2.0.0: {} + isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)): + dependencies: + ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6) + + jayson@4.3.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@types/connect': 3.4.38 + '@types/node': 12.20.55 + '@types/ws': 7.4.7 + commander: 2.20.3 + delay: 5.0.0 + es6-promisify: 5.0.0 + eyes: 0.1.8 + isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + json-stringify-safe: 5.0.1 + stream-json: 1.9.1 + uuid: 8.3.2 + ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + jose@6.2.2: {} js-sha3@0.8.0: {} @@ -2757,6 +3132,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -2848,6 +3225,10 @@ snapshots: negotiator@0.6.3: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-gyp-build@4.8.4: optional: true @@ -3001,6 +3382,19 @@ snapshots: dependencies: glob: 7.2.3 + rpc-websockets@9.3.8: + dependencies: + '@swc/helpers': 0.5.21 + '@types/uuid': 10.0.0 + '@types/ws': 8.18.1 + buffer: 6.0.3 + eventemitter3: 5.0.4 + uuid: 11.1.1 + ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3089,6 +3483,12 @@ snapshots: statuses@2.0.2: {} + stream-chain@2.2.5: {} + + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -3099,6 +3499,8 @@ snapshots: strip-json-comments@3.1.1: {} + superstruct@2.0.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3116,6 +3518,8 @@ snapshots: tarn@3.0.2: {} + text-encoding-utf-8@1.0.2: {} + text-table@0.2.0: {} tildify@2.0.0: {} @@ -3126,6 +3530,8 @@ snapshots: toidentifier@1.0.1: {} + tr46@0.0.3: {} + tree-kill@1.2.2: {} ts-api-utils@1.4.3(typescript@5.9.3): @@ -3175,6 +3581,8 @@ snapshots: strip-bom: 3.0.0 strip-json-comments: 2.0.1 + tslib@2.8.1: {} + turbo-darwin-64@2.8.15: optional: true @@ -3227,12 +3635,28 @@ snapshots: dependencies: punycode: 2.3.1 + utf-8-validate@6.0.6: + dependencies: + node-gyp-build: 4.8.4 + optional: true + utils-merge@1.0.1: {} + uuid@11.1.1: {} + + uuid@8.3.2: {} + v8-compile-cache-lib@3.0.1: {} vary@1.1.2: {} + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3241,9 +3665,20 @@ snapshots: wrappy@1.0.2: {} - ws@7.4.6(bufferutil@4.1.0): + ws@7.4.6(bufferutil@4.1.0)(utf-8-validate@6.0.6): optionalDependencies: bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + + ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + + ws@8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 xtend@4.0.2: {} diff --git a/start.sh b/start.sh deleted file mode 100644 index 2df610c..0000000 --- a/start.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash -set -euo pipefail - -cd "$(dirname "$0")" - -echo "==========================================" -echo " CryptoWallet API - Production Deploy" -echo "==========================================" - -# 1. Docker check -command -v docker >/dev/null || { echo "[ERROR] Docker not installed"; exit 1; } -docker compose version >/dev/null 2>&1 || { echo "[ERROR] docker compose plugin missing"; exit 1; } - -# 2. .env check -if [ ! -f .env ]; then - cp .env.example .env - echo "[INFO] Created .env from .env.example" - echo "[INFO] Fill in VAULT_ROLE_ID, VAULT_SECRET_ID and re-run." - exit 1 -fi - -# 3. Build & start -echo "[INFO] Building image..." -docker compose build --pull api - -echo "[INFO] Starting services..." -docker compose up -d - -# 4. Wait for health -echo "[INFO] Waiting for API to be healthy..." -for i in $(seq 1 60); do - if curl -sf http://localhost:3001/api/health >/dev/null 2>&1; then - echo "[OK] API healthy" - break - fi - if [ "$i" = "60" ]; then - echo "[ERROR] API did not become healthy in 120s" - docker compose logs --tail=50 api - exit 1 - fi - sleep 2 -done - -echo "" -echo "==========================================" -echo " Deploy complete" -echo "" -echo " API: http://localhost:3001" -echo " Health: http://localhost:3001/api/health" -echo " Docs: http://localhost:3001/api/docs" -echo "" -echo " Logs: docker compose logs -f api" -echo " Stop: docker compose down" -echo "=========================================="