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