Compare commits

..

13 Commits

Author SHA1 Message Date
ZOMBIIIIIII
399322973e initikghuiu 2026-05-29 13:34:29 +03:00
ZOMBIIIIIII
1b3fc444fc initodwehjowecfuihe 2026-05-29 01:09:06 +03:00
ZOMBIIIIIII
860a22eb4a initluyhgulednj 2026-05-29 01:03:03 +03:00
ZOMBIIIIIII
77a0f3d107 initluyhguliohw3eufuer 2026-05-29 00:45:33 +03:00
ZOMBIIIIIII
b3f61353b3 init 2026-05-29 00:30:27 +03:00
ZOMBIIIIIII
336f0577ab initluyhgul 2026-05-29 00:07:43 +03:00
ZOMBIIIIIII
b2ab5f0421 inithilyhb 2026-05-28 23:48:09 +03:00
ZOMBIIIIIII
31aba0b681 initjirefr 2026-05-28 23:29:18 +03:00
ZOMBIIIIIII
4c00c6ca1b initrftsebfvgyhloutersvbhustdr 2026-05-28 22:40:36 +03:00
ZOMBIIIIIII
444030e424 init449494 2026-05-28 22:02:37 +03:00
ZOMBIIIIIII
15af7174c6 init339398989 2026-05-28 15:40:41 +03:00
ZOMBIIIIIII
1f209a8fec init33939 2026-05-28 14:38:57 +03:00
ZOMBIIIIIII
c2a71395fd init 2026-05-28 14:38:01 +03:00
22 changed files with 1033 additions and 370 deletions

View File

@@ -1,74 +1,66 @@
# ─────────────────────────────────────────────────────────────────────
# Production .env template для CryptoWallet API.
# Скопируй: cp .env.example .env && chmod 600 .env && nano .env
# ─────────────────────────────────────────────────────────────────────
# ─── HashiCorp Vault (для master-key, JWT public keys, CSRF secret) ──
VAULT_ADDR=https://vault.your-domain.com
VAULT_ROLE_ID=00000000-0000-0000-0000-000000000000
VAULT_SECRET_ID=00000000-0000-0000-0000-000000000000
# ── Vault (AppRole) ────────────────────────────────────────────────
VAULT_ADDR=
VAULT_ROLE_ID=
VAULT_SECRET_ID=
VAULT_MOUNT_POINT=dev-secrets
VAULT_SECRET_PATH=crypto/master
VAULT_SECRET_PATH=database
VAULT_JWT_KID_PATH=jwt/kid
VAULT_JWT_KIDS_PREFIX=jwt/kids/
VAULT_JWT_KIDS_PREFIX=jwt/kids
# CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF)
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 (приём от bitok external issuer) ────────────────────────────
# ── 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=bitok
JWT_AUDIENCE=cryptowallet-api
JWT_AUDIENCE=elcsa
# ─── API runtime ─────────────────────────────────────────────────────
# ── Server ─────────────────────────────────────────────────────────
API_PORT=3001
LOG_LEVEL=info
LOG_LEVEL=INFO
# CORS — comma-separated list разрешённых origins (фронтенд hosts)
CORS_ORIGINS=https://app.your-domain.com,https://admin.your-domain.com
CORS_ALLOW_CREDENTIALS=true
# ─── Postgres (для docker-compose) ───────────────────────────────────
# Эти переменные используются compose'ом для создания/connect к Postgres контейнеру.
# Если оператор использует external managed Postgres — игнорь POSTGRES_* и впиши
# connection string в DATABASE_URL ниже.
POSTGRES_USER=cryptowallet
POSTGRES_PASSWORD=__GENERATE_STRONG_PASSWORD__
POSTGRES_DB=cryptowallet
# Если используешь managed/external Postgres — раскомментируй и заполни:
# DATABASE_URL=postgres://user:pass@host:5432/dbname
# ─── KeyDB / Redis (idempotency cache + NearIntents asset map cache) ─
# REDIS_HOST=keydb имя service в compose — НЕ меняй если работаешь через compose
# ── KeyDB / Redis (idempotency cache) ──────────────────────────────
# REDIS_PASSWORD also used by docker-compose to seed KeyDB --requirepass.
REDIS_HOST=keydb
REDIS_PORT=6379
REDIS_PASSWORD=__GENERATE_STRONG_PASSWORD__
REDIS_PASSWORD=
REDIS_DB=0
# ─── Внешние API ─────────────────────────────────────────────────────
# CoinGecko — для prices/dynamics (без ключа работает с rate limits)
# ── CORS ────────────────────────────────────────────────────────────
# Comma-separated list of allowed origins. ПУСТО = no cross-origin.
# Никогда не используй wildcard *
CORS_ORIGINS=
CORS_ALLOW_CREDENTIALS=true
# ── External API keys (optional, fallback если Vault их не выдаёт) ─
RELAY_API_KEY=
TRON_API_KEY=
JUPITER_API_KEY=
JUPITER_REFERRAL_ACCOUNT=
JUPITER_FEE_BPS=70
# ── Block explorers (optional, для tx history) ─────────────────────
ETHERSCAN_API_KEY=
BSCSCAN_API_KEY=
# ── Price oracle (optional) ─────────────────────────────────────────
# CoinGecko Demo API key. Без ключа работает free tier (~10-30 req/min).
# Если задан → передаётся через header `x-cg-demo-api-key`.
# Используется в /api/wallets/{chain}/balance (для usdPrice/usdValue)
# и /api/prices?symbols=... KeyDB cache: 5 минут.
COINGECKO_API_KEY=
# Jupiter — для SOL custodial swap (без ключа = lower rate limits)
JUPITER_API_KEY=
# Jupiter referral — если хочешь чтобы SOL swap fees шли через Jupiter feeAccount.
# Сейчас у нас атомарный 0.7% fee atomic в swap-orchestrator (на APP_FEE_WALLET_SOL),
# referral отдельный механизм. Можно оставить пустым.
JUPITER_REFERRAL_ACCOUNT=
# TronGrid — для TRX queries (без ключа 3 req/sec, с ключом 100/sec)
# Получить: https://www.trongrid.io/dashboard
TRON_API_KEY=
# Outbound HTTP proxy (опц.) — если хотите чтобы Jupiter/Relay/NearIntents calls
# шли через proxy (rotation IP для rate limits). Формат: http://user:pass@host:port
OUTBOUND_PROXY_URL=
# ─── App fee wallets ─────────────────────────────────────────────────
# Эти адреса HARDCODED в backend (apps/api/src/lib/app-fee.ts), НЕ настраиваются
# через env. Если нужно изменить — code review + rebuild.
# EVM (ETH+BSC): 0xeb9fbf0d137ef5ea7b9959044c2ed44ec1206c68
# SOL: DQkQegoX698XkcXZ6VX9P1qUpbV64Sgjz1BCPFgfWpjD
# TRX: TRwpFjnfMBe4aDJbHYEqeUVCG1auF8wFXP
# BTC: — (not collectable)
# ── DB fallback (если Vault недоступен при старте) ─────────────────
DB_HOST=
DB_PORT=5432
DB_USER=
DB_PASSWORD=
DB_NAME=

3
.gitignore vendored
View File

@@ -1,9 +1,12 @@
.env
.env.local
# Никогда не коммитить артефакты установки/сборки — только Docker build на сервере
node_modules/
**/node_modules/
dist/
**/dist/
.turbo/
**/.turbo/
*.log
logs/
.DS_Store

View File

@@ -25,7 +25,9 @@ RUN cd apps/api && pnpm build
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
RUN pnpm install --frozen-lockfile --prod \
&& BIGINT_DIR="$(find /app -path '*/node_modules/bigint-buffer' -type d 2>/dev/null | head -1)" \
&& if [ -n "$BIGINT_DIR" ]; then (cd "$BIGINT_DIR" && npm run rebuild); fi
# ── Stage 4: runtime image — minimal surface ──
FROM node:20-alpine AS runtime

196
README.md
View File

@@ -1,196 +0,0 @@
# 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).
## Pre-deploy setup (один раз)
```bash
# 1. Master-key в Vault
vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32)
# 2. CSRF secret в Vault
vault kv put dev-secrets/csrf secret_key=$(openssl rand -hex 32) salt=csrf-salt digest=sha256
# 3. DB schema — APPEND-ONLY / NON-DESTRUCTIVE
# Безопасно прогонять на existing БД. См. ниже "Schema is non-destructive".
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` станут нерасшифровываемыми. API на старте делает self-test: пытается декриптить любой существующий mnemonic и фейлится если ключ не подошёл.
## Deploy
```bash
# Залить bundle на сервер
scp -P 2222 -r deployserver/ server@<host>:~/cryptowallet/
# На сервере: убедись что .env заполнен (VAULT_*, JWT_*, CORS_ORIGINS, REDIS_PASSWORD, ...)
ssh server@<host> -p 2222
cd ~/cryptowallet/deployserver
docker compose up -d --build
docker compose logs -f api
curl http://localhost:3001/api/health
```
API **не делает migrations / DROP / ALTER** при старте — только INSERT/UPDATE/SELECT. Schema (если нужны новые колонки/таблицы для нового функционала) обновляется только руками: `psql -f cryptowallet-schema.sql` (script append-only — `CREATE TABLE IF NOT EXISTS` / `ALTER TABLE ADD COLUMN IF NOT EXISTS`, никаких DROP).
## Update / Rebuild
```bash
# Залить новый src + rebuild api (keydb данные не теряются)
scp -P 2222 -r deployserver/apps deployserver/Dockerfile deployserver/docker-compose.yml \
server@<host>:~/cryptowallet/deployserver/
ssh server@<host> -p 2222 'cd cryptowallet/deployserver && docker compose up -d --build'
```
## Endpoints
### Core wallet management
| 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` | Список адресов юзера |
| GET | `/api/wallets/portfolio` | Аггрегированный USD portfolio по всем 5 chains (KeyDB cached 1h, stale fallback) |
| 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?}` |
| POST | `/api/wallets/{chain}/send/cost-estimate` | Pre-flight gas/fee estimate (no broadcast) |
| 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 execute steps). Поддерживает opt-in `bridgeAmount`+`bridgeToken` → автоматически взимает 0.7% app fee на EVM fee wallet ПЕРЕД main tx (atomic). |
| POST | `/api/wallets/SOL/sign-and-broadcast-tx` | Sign+broadcast serialized VersionedTransaction (Jupiter/Relay SOL steps) |
### Bridge & Swap (NEW)
| Method | Path | Описание |
|---|---|---|
| POST | **`/api/bridge/execute`** | One-click bridge orchestrator. Auto-routes: TRX source → NearIntents 1Click; EVM/SOL source → Jumper (LiFi) или Relay. Atomic fee tx ДО main bridge. Idempotency-Key support. Returns `{feeTxid?, approveTxid?, bridgeTxid, trackerUrl, provider}`. |
| GET | **`/api/jumper/quote-best`** | Bridge quote с NearIntents priority + smart LiFi fallback. JWT-binds fromAddress. |
| GET | `/api/jumper/{chains,tools,tokens,connections,status}` | LiFi metadata proxies (JWT-protected) |
| POST | `/api/jumper/advanced/{routes,stepTransaction}` | LiFi multi-route preview / step tx fetcher |
| POST | `/api/wallets/{chain}/swap/quote` | Custodial swap quote (BSC PancakeSwap / SOL Jupiter / TRX SunSwap) — 30s locked minOut |
| POST | **`/api/wallets/{chain}/swap`** | Confirm custodial swap (BSC/SOL/TRX). BSC + SOL: 0.7% atomic fee ДО main swap. TRX: existing on-chain FeeSwapRouter. |
| POST | `/api/wallets/{chain}/swap/cost-estimate` | Swap pre-flight cost estimate |
| — | `/api/relay/*` | Relay.link bridge proxy (quote / execute / status / cost-estimate). Unchanged from previous deploys. |
### App fee 0.7% (NEW — hardcoded recipients)
| Method | Path | Описание |
|---|---|---|
| POST | **`/api/wallets/{chain}/app-fee`** | Standalone fee transfer endpoint. Для **Relay frontend hook** — клиент явно вызывает после Relay execute. Body: `{amount, token?}`. Server validates JWT-bind + computes 0.7% + signs + broadcasts. Idempotency-Key support. |
Fee wallets (захардкожены в `lib/app-fee.ts`, нельзя override через env):
| Chain | Recipient address |
|---|---|
| EVM (ETH+BSC) | `0xeb9fbf0d137ef5ea7b9959044c2ed44ec1206c68` |
| SOL | `DQkQegoX698XkcXZ6VX9P1qUpbV64Sgjz1BCPFgfWpjD` |
| TRX | `TRwpFjnfMBe4aDJbHYEqeUVCG1auF8wFXP` |
| BTC | (not collectable — no fee wallet) |
Fee рассчёт: `feeAmount = amount × 70 / 10000` (BigInt-precision, 0 если < 144 smallest units).
### Tokens & Prices (NEW)
| Method | Path | Описание |
|---|---|---|
| GET | **`/api/tokens`** `?chain={ETH,BSC,BTC,TRX,SOL}` `?bridgeable=true` | Token registry. `bridgeable=true` фильтрует только tokens с реальным bridge route (используется Jumper UI dropdowns) |
| GET | `/api/prices` `?symbols=BTC,ETH,USDT` `?chain={...}` | CoinGecko cached USD prices |
| GET | **`/api/prices/dynamics`** `?symbols=...` | 24h price change % для нескольких символов одним запросом |
### Provider integrations (NEW)
- **NearIntents 1Click** (`https://1click.chaindefuser.com`) — direct TRX bridges. Asset map fetched dynamically через `/v0/tokens` (cached 1h in-memory). Quote → user transfers TRX/USDT to depositAddress → solver delivers cross-chain. No JWT для test env (0.2% NearIntents fee included в quote). Implementation: `src/lib/nearintents-client.ts`.
- **Jumper / LiFi** (`https://li.quest/v1`) — multi-chain bridge proxy + quote-best smart routing с NearIntents priority. Implementation: `src/routes/jumper-proxy.routes.ts`.
- **Relay.link** (`https://api.relay.link`) — EVM/SOL/BTC bridge proxy. **Unchanged** (preservation invariant). Implementation: `src/routes/relay-proxy.routes.ts`.
### Audit log events (extended)
- `wallet.create`, `wallet.send`, `wallet.swap` (existing)
- `wallet.sign_raw_evm` (existing, +`bridgeAmount` meta)
- `wallet.bsc_fee` (existing) / `wallet.app_fee` (NEW — standalone fee endpoint)
- `bridge.execute`, `bridge.execute.broadcast` (NEW — bridge orchestrator)
## 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` / `wallet.swap` / `wallet.sign_raw_evm` / `wallet.bsc_fee` / `wallet.app_fee` / `mnemonic.reveal` / `bridge.execute` / `bridge.execute.broadcast`
- **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'ов
- **Jumper proxy** whitelist аналогично — `/quote`, `/quote-best`, `/chains`, `/tools`, `/tokens`, `/connections`, `/status`, `/advanced/{routes,stepTransaction}`. JWT-binds `fromAddress` к user wallet.
- **NearIntents direct integration** для TRX bridges — `signAndBroadcast(TRX)` через battle-tested sendTrx (4-layer MITM defense сохраняется). Asset map fetched через `/v0/tokens` (auth source) и cached 1h в-memory. depositAddress validated через Tron base58 regex `^T[1-9A-HJ-NP-Za-km-z]{33}$`.
- **App fee 0.7%** atomic enforcement в bridge-execute orchestrator + custodial swap orchestrator. Fee tx FIRST → если fail, main tx НЕ broadcast (atomic abort). Recipients hardcoded — нельзя override через body/env.
- **BridgeSimulationError** для EVM bridges — pre-broadcast `eth_call` dry-run; если revert → 400 `SIMULATION_FAILED`, fees не сгорают.
- **InsufficientBalanceError** pre-check на bridge — отвергаем 400 ДО подписи если balance < amount + reserve.
- **Anti-MEV** в bridge-execute — server re-quotes ПЕРЕД sign, валидирует `minAmountOut ≥ acceptedMinOut` (50 bps cushion). Если price moved → 409.
- **Idempotency-Key** на mutating endpoints (send / sign-raw / swap / bridge-execute / app-fee) — KeyDB cache 10 min, retry-safe.
- **TronGrid 429 retry** с exponential backoff (0/1.5/4/8s) для free-tier rate limits.
## Schema is non-destructive
`cryptowallet-schema.sql` **append-only**. Re-run на боксе с уже настроенной БД = **zero DDL changes**. Если оператор добавил кастомные таблицы / индексы / constraints вручную — они **никогда** не будут перезаписаны или удалены.
Что делает script:
- `CREATE TABLE IF NOT EXISTS users` / `wallets`
- `ALTER TABLE users ADD COLUMN <X>` (только если колонки нет — `encrypted_mnemonic`, `erc20`, `passport_data`)
- `CREATE UNIQUE INDEX users_email_lower_unique` (если индекса нет)
- `CREATE INDEX idx_users_active` / `idx_wallets_*` (если индексов нет)
- `ADD CONSTRAINT` × 4 (только если данного constraint name нет)
Что script **НЕ делает**:
- ❌ Никогда не `DROP TABLE`
- ❌ Никогда не `DROP CONSTRAINT`
- ❌ Никогда не `DROP COLUMN`
- ❌ Никогда не перезаписывает существующие constraints / indexes
Legacy cleanup (audit_log, idempotency_keys, sessions от старых версий) — **manual one-time** операторская задача, не часть этого script'а:
```bash
psql ... -c "DROP TABLE IF EXISTS audit_log CASCADE;"
psql ... -c "DROP TABLE IF EXISTS idempotency_keys CASCADE;"
psql ... -c "DROP TABLE IF EXISTS sessions CASCADE;"
```
## Logs
Файловых логов **нет**. Весь код пишет в `process.stdout` (см. `apps/api/src/lib/logger.ts` и `lib/audit-log.ts`). Docker подбирает stdout через json-file driver и показывает через `docker compose logs`:
```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 под реальный трафик

37
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
WORKDIR /app
# ── deps: install all node_modules ───────────────────────────────────────────
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
RUN pnpm install --frozen-lockfile --prod=false
# ── build: compile TypeScript ────────────────────────────────────────────────
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY . .
RUN cd apps/api && pnpm build
# ── prod-deps: production-only dependencies ─────────────────────────────────
FROM base AS prod-deps
RUN apk add --no-cache python3 make g++
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
RUN pnpm install --frozen-lockfile --prod \
&& BIGINT_DIR="$(find /app -path '*/node_modules/bigint-buffer' -type d 2>/dev/null | head -1)" \
&& if [ -n "$BIGINT_DIR" ]; then (cd "$BIGINT_DIR" && npm run rebuild); fi
# ── runtime: minimal image ───────────────────────────────────────────────────
FROM node:20-alpine AS runtime
WORKDIR /app/apps/api
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=prod-deps /app/apps/api/node_modules ./node_modules
COPY --from=build /app/apps/api/dist ./dist
COPY --from=build /app/apps/api/swagger.json ./swagger.json
COPY --from=build /app/apps/api/package.json ./package.json
EXPOSE 3001
CMD ["node", "dist/index.js"]

View File

@@ -41,6 +41,7 @@ app.use(
origin: corsIsWildcard ? '*' : (corsOrigins.length > 0 ? corsOrigins : false),
// Wildcard incompatible с credentials per browser spec — force false при wildcard.
credentials: corsIsWildcard ? false : env.cors.allowCredentials,
exposedHeaders: ['X-CSRF-Token', 'X-Trace-ID'],
}),
);
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS

View File

@@ -1599,9 +1599,9 @@ export const WalletController = {
async appFeeTransfer(req: Request, res: Response) {
const userId = req.auth!.userId;
const chainParam = String(req.params.chain || '').toUpperCase();
const ALLOWED_FEE_CHAINS = new Set(['ETH', 'BSC', 'SOL', 'TRX']);
const ALLOWED_FEE_CHAINS = new Set(['ETH', 'BSC', 'SOL', 'TRX', 'BTC']);
if (!ALLOWED_FEE_CHAINS.has(chainParam)) {
res.status(400).json({ success: false, error: `Chain ${chainParam} doesn't support app fee (allowed: ETH, BSC, SOL, TRX)` });
res.status(400).json({ success: false, error: `Chain ${chainParam} doesn't support app fee (allowed: ETH, BSC, SOL, TRX, BTC)` });
return;
}
const chain = chainParam as ChainCode;
@@ -1694,6 +1694,7 @@ export const WalletController = {
to: feeWallet,
amount: feeAmountBig.toString(),
token: tokenSymbol,
feeTier: chain === 'BTC' ? 'slow' : undefined,
});
txid = sendRes.txid;
} catch (sendErr: any) {

View File

@@ -14,7 +14,7 @@
* EVM (ETH+BSC) → 0xeb9f... (один EVM wallet, оба chain'а)
* SOL → DQkQ... (Solana base58)
* TRX → TRwp... (Tron base58)
* BTC → не collectable (no wallet provided — bridge in BTC не имеет fee tx layer)
* BTC → bc1q... (bech32 P2WPKH, отдельная fee tx перед bridge)
*
* Изменение wallet → требует code review + новый release.
*/
@@ -30,6 +30,9 @@ export const APP_FEE_WALLET_SOL = 'DQkQegoX698XkcXZ6VX9P1qUpbV64Sgjz1BCPFgfWpjD'
/** Tron base58 (с T-prefix). */
export const APP_FEE_WALLET_TRX = 'TRwpFjnfMBe4aDJbHYEqeUVCG1auF8wFXP';
/** Bitcoin bech32 (P2WPKH). */
export const APP_FEE_WALLET_BTC = 'bc1qwzm9e4qun4tptecalq9zwxshdnwmvcxtz27znm';
/** 70 bps = 0.7%. Изменение требует code review. */
export const APP_FEE_BPS = 70n;
@@ -37,22 +40,21 @@ export const APP_FEE_BPS = 70n;
export const APP_FEE_DENOMINATOR = 10000n;
/**
* Resolve fee recipient для chain. Throws для unsupported chain (BTC).
* Resolve fee recipient для chain.
*/
export function getAppFeeWallet(chain: ChainCode): string {
if (chain === 'ETH' || chain === 'BSC') return APP_FEE_WALLET_EVM;
if (chain === 'SOL') return APP_FEE_WALLET_SOL;
if (chain === 'TRX') return APP_FEE_WALLET_TRX;
throw new Error(
`getAppFeeWallet: chain '${chain}' has no fee wallet (BTC bridges не имеют collectable fee layer)`,
);
if (chain === 'BTC') return APP_FEE_WALLET_BTC;
throw new Error(`getAppFeeWallet: unsupported chain '${chain}'`);
}
/**
* Check если для chain есть fee wallet. Used для conditional fee tx (skip BTC).
* Check если для chain есть fee wallet.
*/
export function hasAppFee(chain: ChainCode): boolean {
return chain === 'ETH' || chain === 'BSC' || chain === 'SOL' || chain === 'TRX';
return chain === 'ETH' || chain === 'BSC' || chain === 'SOL' || chain === 'TRX' || chain === 'BTC';
}
/**

View File

@@ -288,10 +288,10 @@ export function nearIntentsTrackerUrl(depositAddress: string): string {
// валидный Tron address, не attacker-controlled garbage)
const TRON_BASE58_REGEX = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
const BTC_BECH32_REGEX = /^bc1[ac-hj-np-z02-9]{6,}$/;
/**
* Throws если depositAddress не соответствует ожидаемому формату для chain.
* На MVP — только TRX validation. Расширить когда добавим SOL/BTC origins.
*/
export function assertValidDepositAddress(chain: ChainCode, depositAddress: string): void {
if (chain === 'TRX') {
@@ -300,5 +300,10 @@ export function assertValidDepositAddress(chain: ChainCode, depositAddress: stri
}
return;
}
// Для других chains — пока no extra validation (TODO when extending)
if (chain === 'BTC') {
if (!BTC_BECH32_REGEX.test(depositAddress)) {
throw new Error(`NearIntents depositAddress ${depositAddress} не валидный Bitcoin bech32 — abort`);
}
return;
}
}

View File

@@ -47,8 +47,15 @@ export interface TokenListEntry {
name: string;
contract: string | null;
decimals: number;
/** LiFi/Jumper fromToken/toToken для native (BTC = "bitcoin"). */
lifiAddress?: string;
}
/** LiFi native sentinel для bridge quote (только BTC отличается от contract:null). */
export const LIFI_NATIVE_ADDRESS: Partial<Record<ChainCode, string>> = {
BTC: 'bitcoin',
};
/**
* Native decimals per chain. Дублирует `NATIVE_DECIMALS` из `lib/amount-units.ts`
* и `services/wallet-ops.service.ts` — небольшое количество constants, проще
@@ -171,7 +178,7 @@ function isBridgeable(chain: ChainCode, symbol: string): boolean {
*
* @param filterChain — если задан, фильтрует только этот chain
* @param bridgeableOnly — если true, возвращает только tokens из `BRIDGEABLE_TOKENS` allowlist
* (used by Jumper bridge UI чтобы не показывать unsupported memecoins)
* (used by bridge/swap UI чтобы не показывать unsupported memecoins)
*/
export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean = false): TokenListEntry[] {
const out: TokenListEntry[] = [];
@@ -180,12 +187,14 @@ export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean =
for (const chain of chains) {
// Native first
if (!bridgeableOnly || isBridgeable(chain, NATIVE_SYMBOLS[chain])) {
const lifiAddress = LIFI_NATIVE_ADDRESS[chain];
out.push({
chain,
symbol: NATIVE_SYMBOLS[chain],
name: NATIVE_NAMES[chain],
contract: null,
decimals: NATIVE_DECIMALS_LOCAL[chain],
...(lifiAddress ? { lifiAddress } : {}),
});
}
// Tokens
@@ -210,6 +219,18 @@ export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean =
return out;
}
/**
* Multi-chain variant для `/api/tokens?chains=ETH,BSC,...`.
* По умолчанию compact/bridgeable список, потому что endpoint используется UI dropdown'ами.
*/
export function getTokensForChains(
filterChains?: ChainCode[],
bridgeableOnly: boolean = true,
): TokenListEntry[] {
const chains = filterChains && filterChains.length > 0 ? filterChains : ALL_CHAINS_ORDERED;
return chains.flatMap((chain) => getAllTokens(chain, bridgeableOnly));
}
export function getEvmTokens(chain: ChainCode): EvmToken[] {
if (chain === 'ETH') return ETH_TOKENS;
if (chain === 'BSC') return BSC_TOKENS;

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from 'express';
import { timingSafeEqual } from 'crypto';
import { verifyCsrfToken, isCsrfConfigured } from '../services/csrf.service';
import { verifyCsrfToken, isCsrfConfigured, getCsrfConfigSummary } from '../services/csrf.service';
import { env } from '../config/env';
import { logger } from '../lib/logger';
@@ -28,15 +28,6 @@ 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');
@@ -66,7 +57,13 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction):
// HMAC verify только после совпадения двух source'ов.
const result = verifyCsrfToken(cookieToken);
if (!result.valid) {
logger.warn(`CSRF validation failed: ${result.reason}`);
const sigDiag =
result.actualSigLen !== undefined && result.expectedSigLen !== undefined
? ` (sigLen=${result.actualSigLen} expectedLen=${result.expectedSigLen})`
: '';
const cfg = getCsrfConfigSummary();
const fp = cfg ? ` secret_fp=${cfg.secretFp} salt="${cfg.salt}" digest=${cfg.digest}` : '';
logger.warn(`CSRF validation failed: ${result.reason}${sigDiag}${fp}`);
res.status(403).json({ success: false, error: 'Invalid CSRF token' });
return;
}

View File

@@ -84,6 +84,15 @@ async function executeHandler(req: Request, res: Response): Promise<void> {
return;
}
const BTC_NATIVE_FROM_TOKENS = new Set(['bitcoin', 'bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8']);
if (CHAINID_TO_CHAIN[fromChain] === 'BTC' && !BTC_NATIVE_FROM_TOKENS.has(fromToken)) {
res.status(400).json({
success: false,
error: 'BTC bridge supports native only: fromToken must be "bitcoin" (LiFi sentinel)',
});
return;
}
// ── 2. JWT-bind: fromAddress = user's source-chain wallet ──
const sourceCode = CHAINID_TO_CHAIN[fromChain];
if (!sourceCode) {

View File

@@ -20,6 +20,7 @@ import { logger } from '../lib/logger';
import { WalletModel } from '../models/wallet.model';
import type { ChainCode } from '../lib/address-validators';
import { proxiedFetch } from '../lib/outbound-proxy';
import { getTokensForChains } from '../lib/token-registry';
const router = Router();
const LIFI_API_URL = 'https://li.quest/v1';
@@ -43,6 +44,24 @@ const JUMPER_CHAINID_TO_CHAIN: Record<number, ChainCode> = {
20000000000001: 'BTC',
};
const ALLOWED_JUMPER_CHAIN_IDS = new Set(Object.keys(JUMPER_CHAINID_TO_CHAIN).map(Number));
const JUMPER_CHAIN_BY_CODE: Partial<Record<ChainCode, number>> = Object.entries(JUMPER_CHAINID_TO_CHAIN)
.reduce((acc, [chainId, code]) => ({ ...acc, [code]: Number(chainId) }), {});
const JUMPER_NATIVE_SENTINELS: Partial<Record<ChainCode, string>> = {
ETH: '0x0000000000000000000000000000000000000000',
BSC: '0x0000000000000000000000000000000000000000',
SOL: '11111111111111111111111111111111',
TRX: 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb',
BTC: 'bitcoin',
};
const LOCAL_JUMPER_CHAINS = [
{ key: 'eth', chainType: 'EVM', name: 'Ethereum', coin: 'ETH', id: 1, mainnet: true },
{ key: 'bsc', chainType: 'EVM', name: 'BSC', coin: 'BNB', id: 56, mainnet: true },
{ key: 'sol', chainType: 'SVM', name: 'Solana', coin: 'SOL', id: 1151111081099710, mainnet: true },
{ key: 'trx', chainType: 'TVM', name: 'Tron', coin: 'TRX', id: 728126428, mainnet: true },
{ key: 'btc', chainType: 'UTXO', name: 'Bitcoin', coin: 'BTC', id: 20000000000001, mainnet: true },
];
// Whitelist: GET-paths + POST-paths. Запрещён любой другой путь (path traversal).
const ALLOWED_GET_PATHS = new Set([
'/quote', // single best route
@@ -194,6 +213,11 @@ async function proxyJumperRequest(req: Request, res: Response, _next: NextFuncti
}
try {
const filtered = filterJumperMetadata(jumperPath, text);
if (filtered) {
res.json(filtered);
return;
}
res.send(text);
} catch {
res.json({ success: false, error: 'Jumper returned non-JSON' });
@@ -209,6 +233,150 @@ async function proxyJumperRequest(req: Request, res: Response, _next: NextFuncti
}
}
function filterJumperMetadata(jumperPath: string, text: string): unknown | null {
if (jumperPath !== '/chains' && jumperPath !== '/tokens' && jumperPath !== '/tools') {
return null;
}
const parsed = JSON.parse(text);
if (jumperPath === '/chains') {
return filterChainsResponse(parsed);
}
if (jumperPath === '/tokens') {
return filterTokensResponse(parsed);
}
return filterToolsResponse(parsed);
}
function filterChainsResponse(body: any): any {
if (!Array.isArray(body?.chains)) return body;
const upstream = body.chains.filter((chain: any) => ALLOWED_JUMPER_CHAIN_IDS.has(Number(chain?.id)));
const byId = new Map<number, any>();
for (const chain of [...upstream, ...LOCAL_JUMPER_CHAINS]) {
byId.set(Number(chain.id), chain);
}
return {
...body,
chains: [...ALLOWED_JUMPER_CHAIN_IDS]
.map((chainId) => byId.get(chainId))
.filter(Boolean),
};
}
function filterTokensResponse(body: any): any {
if (!body?.tokens || typeof body.tokens !== 'object') return body;
const allow = buildAllowedTokenMap();
const local = buildLocalTokenMap();
const filteredByChain = new Map<number, Map<string, any>>();
for (const [chainId, tokens] of Object.entries(body.tokens)) {
if (!ALLOWED_JUMPER_CHAIN_IDS.has(Number(chainId)) || !Array.isArray(tokens)) continue;
const numericChainId = Number(chainId);
const allowedForChain = allow.get(numericChainId);
if (!allowedForChain) continue;
const merged = filteredByChain.get(numericChainId) ?? buildTokenMap(local.get(numericChainId) ?? []);
for (const token of tokens) {
const key = tokenKey(token);
if (allowedForChain.has(key)) {
merged.set(key, token);
}
}
filteredByChain.set(numericChainId, merged);
}
// LiFi currently omits SOL/BTC/TRX token lists from /tokens. Add our local whitelist
// so frontend can use one metadata contract for quote-best/quote and bridge/execute.
for (const chainId of ALLOWED_JUMPER_CHAIN_IDS) {
if (!filteredByChain.has(chainId)) {
filteredByChain.set(chainId, buildTokenMap(local.get(chainId) ?? []));
}
}
const filteredTokens: Record<string, any[]> = {};
for (const chainId of ALLOWED_JUMPER_CHAIN_IDS) {
const tokens = [...(filteredByChain.get(chainId)?.values() ?? [])];
if (tokens.length > 0) {
filteredTokens[String(chainId)] = tokens;
}
}
return { ...body, tokens: filteredTokens };
}
function filterToolsResponse(body: any): any {
const filterPair = (pair: any): boolean =>
ALLOWED_JUMPER_CHAIN_IDS.has(Number(pair?.fromChainId)) &&
ALLOWED_JUMPER_CHAIN_IDS.has(Number(pair?.toChainId));
const bridges = Array.isArray(body?.bridges)
? body.bridges
.map((bridge: any) => {
const supportedChains = Array.isArray(bridge?.supportedChains)
? bridge.supportedChains.filter(filterPair)
: [];
return { ...bridge, supportedChains };
})
.filter((bridge: any) => bridge.supportedChains.length > 0)
: body?.bridges;
const exchanges = Array.isArray(body?.exchanges)
? body.exchanges
.map((exchange: any) => {
const supportedChains = Array.isArray(exchange?.supportedChains)
? exchange.supportedChains.filter((chainId: unknown) => ALLOWED_JUMPER_CHAIN_IDS.has(Number(chainId)))
: [];
return { ...exchange, supportedChains };
})
.filter((exchange: any) => exchange.supportedChains.length > 0)
: body?.exchanges;
return { ...body, bridges, exchanges };
}
function buildAllowedTokenMap(): Map<number, Set<string>> {
const map = new Map<number, Set<string>>();
for (const [chainId, tokens] of buildLocalTokenMap()) {
map.set(chainId, new Set(tokens.map(tokenKey)));
}
return map;
}
function buildLocalTokenMap(): Map<number, any[]> {
const map = new Map<number, any[]>();
const rows = getTokensForChains(['ETH', 'BSC', 'SOL', 'BTC', 'TRX'], true);
for (const row of rows) {
const chainId = JUMPER_CHAIN_BY_CODE[row.chain];
if (!chainId) continue;
const address = row.contract || JUMPER_NATIVE_SENTINELS[row.chain] || '';
if (!address) continue;
const bucket = map.get(chainId) ?? [];
bucket.push({
chainId,
address,
symbol: row.symbol,
name: row.name,
decimals: row.decimals,
coinKey: row.symbol,
source: 'cryptowallet-whitelist',
});
map.set(chainId, bucket);
}
return map;
}
function buildTokenMap(tokens: any[]): Map<string, any> {
return new Map(tokens.map((token) => [tokenKey(token), token]));
}
function tokenKey(token: any): string {
const symbol = String(token?.symbol || '').toUpperCase();
const address = String(token?.address || '').toLowerCase();
return `${symbol}:${address}`;
}
/**
* GET /api/jumper/quote-best — bridge quote с приоритетом NearIntents.
*

View File

@@ -1,41 +1,65 @@
/**
* GET /api/tokens — реестр всех известных активов всех 5 сетей + native.
* GET /api/tokens — compact allowlist активов для bridge/swap UI.
*
* Read-only. Источник — `lib/token-registry.ts`. Никаких RPC calls,
* никаких user-specific данных — только статический list контрактов с symbol + name.
*
* Optional query params:
* ?chain=ETH|BSC|BTC|TRX|SOL — filter одной сетью.
* ?bridgeable=true — возвращает только tokens которые реально bridgeable
* через Jumper/NearIntents (без SOL memes, BSC wrapped, и т.п.).
* Используется UI dropdowns в Jumper bridge section.
* ?chains=ETH,BSC,BTC,TRX,SOL — filter нескольких сетей.
* ?includeUnsupported=true — debug/full registry mode; default = только usable tokens.
*/
import { Router, Request, Response } from 'express';
import { getAllTokens } from '../lib/token-registry';
import { getTokensForChains } from '../lib/token-registry';
import { ALL_CHAINS } from '../services/wallet-generator.service';
import type { ChainCode } from '../lib/address-validators';
const router = Router();
const ALLOWED = new Set<ChainCode>(ALL_CHAINS);
const ALLOWED_LABEL = 'ETH, BSC, BTC, TRX, SOL';
router.get('/', (req: Request, res: Response) => {
const parseChain = (raw: string): ChainCode | null => {
const upper = raw.trim().toUpperCase();
if (!upper) return null;
return ALLOWED.has(upper as ChainCode) ? (upper as ChainCode) : null;
};
const requested = new Set<ChainCode>();
const addChain = (raw: unknown): string | null => {
const chain = parseChain(String(raw));
if (!chain) return String(raw);
requested.add(chain);
return null;
};
const chainParam = req.query.chain;
let filterChain: ChainCode | undefined;
if (chainParam !== undefined && chainParam !== null && chainParam !== '') {
const upper = String(chainParam).toUpperCase();
if (!ALLOWED.has(upper as ChainCode)) {
res.status(400).json({
success: false,
error: `Invalid chain "${chainParam}" (allowed: ETH, BSC, BTC, TRX, SOL)`,
});
const invalid = addChain(Array.isArray(chainParam) ? chainParam[0] : chainParam);
if (invalid) {
res.status(400).json({ success: false, error: `Invalid chain "${invalid}" (allowed: ${ALLOWED_LABEL})` });
return;
}
filterChain = upper as ChainCode;
}
// ?bridgeable=true → filter только bridgeable tokens
const bridgeableOnly = String(req.query.bridgeable || '').toLowerCase() === 'true';
const data = getAllTokens(filterChain, bridgeableOnly);
const chainsParam = req.query.chains;
if (chainsParam !== undefined && chainsParam !== null && chainsParam !== '') {
const rawValues = Array.isArray(chainsParam) ? chainsParam : [chainsParam];
for (const raw of rawValues.flatMap((value) => String(value).split(','))) {
if (!raw.trim()) continue;
const invalid = addChain(raw);
if (invalid) {
res.status(400).json({ success: false, error: `Invalid chain "${invalid}" (allowed: ${ALLOWED_LABEL})` });
return;
}
}
}
// Default = compact UI whitelist. Full registry only by explicit debug opt-in.
const includeUnsupported = String(req.query.includeUnsupported || '').toLowerCase() === 'true' ||
String(req.query.bridgeable || '').toLowerCase() === 'false';
const data = getTokensForChains([...requested], !includeUnsupported);
res.json({ success: true, data });
});

View File

@@ -209,6 +209,7 @@ const ALLOWED_TRC_FUNCTIONS = new Set<string>([
'allowance(address,address)',
'swapExactETHForTokens(uint256,address[],address,uint256)',
'swapExactTokensForETH(uint256,uint256,address[],address,uint256)',
'swapExactTokensForETHSupportingFeeOnTransferTokens(uint256,uint256,address[],address,uint256)',
'swapNativeWithFee(bytes)',
'swapTokenWithFee(address,uint256,bytes)',
'getAmountsOut(uint256,address[])',

View File

@@ -28,7 +28,7 @@ import {
signAndBroadcastEvmFeeTx,
signAndBroadcastSolanaTx,
} from './wallet-signer.service';
import { computeAppFee, getAppFeeWallet, APP_FEE_WALLET_SOL, APP_FEE_WALLET_TRX } from '../lib/app-fee';
import { computeAppFee, APP_FEE_WALLET_BTC, APP_FEE_WALLET_SOL, APP_FEE_WALLET_TRX } from '../lib/app-fee';
import { SOL_TOKENS, TRX_TOKENS } from '../lib/token-registry';
/**
@@ -166,6 +166,18 @@ const CHAINID_TO_CHAIN: Record<number, ChainCode> = {
// EVM-native sentinels (0x0000...) — означает что from-token = native (BNB / ETH).
const EVM_NATIVE_SENTINEL = '0x0000000000000000000000000000000000000000';
/** Native token sentinels в LiFi/Jumper/Relay body (case-sensitive где указано). */
const NATIVE_TOKEN_SENTINELS = new Set([
EVM_NATIVE_SENTINEL,
'11111111111111111111111111111111',
'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb',
'bitcoin',
'bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8',
]);
/** Conservative network fee floor для 2× P2WPKH BTC tx (fee + bridge). */
const BTC_NETWORK_FEE_FLOOR_SAT = 2000n;
export type BridgeProvider = 'jumper' | 'relay' | 'nearintents';
export interface BridgeExecuteParams {
@@ -448,8 +460,6 @@ async function executeSol(
// ── App fee 0.7% (atomic — fee tx ПЕРЕД main bridge) ──
// Native SOL bridge → fee in native SOL. SPL bridge → fee in same SPL token.
// Если fee = 0 (amount слишком мал) → throw (anti-bypass).
let feeTxid: string | undefined;
let feeAmount: string | undefined;
const feeAmountBig = computeAppFee(p.fromAmount);
if (feeAmountBig <= 0n) {
throw new Error('SOL bridge: fromAmount too small — fee = 0');
@@ -466,8 +476,8 @@ async function executeSol(
amount: feeAmountBig.toString(),
token: feeSymbol,
});
feeTxid = feeRes.txid;
feeAmount = feeAmountBig.toString();
const feeTxid = feeRes.txid;
const feeAmount = feeAmountBig.toString();
logger.info(`SOL bridge fee broadcast: ${feeAmount} ${feeSymbol || 'lamports'}${APP_FEE_WALLET_SOL} (txid ${feeTxid})`);
// Для SOL LiFi возвращает в `transactionRequest.data` = base64-encoded VersionedTransaction.
@@ -708,40 +718,161 @@ void readTrc20Allowance;
// ─── BTC execute ──────────────────────────────────────────────────────
/**
* BTC bridge: NearIntents 1Click (primary, как TRX). Relay deposit — fallback если
* provider=relay и в upstream quote есть depositAddress.
*/
async function executeBtc(
p: BridgeExecuteParams,
quote: NormalizedQuote,
): Promise<BridgeExecuteResult> {
// Для BTC source через Relay: quote.steps[0] = deposit step с {data.depositAddress, ...}.
// Relay просит юзера отправить BTC tx на их deposit address; solver видит UTXO в mempool
// → доставляет destination asset.
let depositAddress: string | undefined;
let amountSat: bigint | undefined;
const needed = BigInt(p.fromAmount);
// Relay fallback (legacy path)
if (p.provider === 'relay' && Array.isArray(quote.steps)) {
const depositStep = quote.steps.find((s) => s.id === 'deposit' || s.id === 'tx');
if (depositStep) {
depositAddress = depositStep.data?.depositAddress || depositStep.data?.to;
const amt = depositStep.data?.amount || depositStep.data?.value;
if (amt) amountSat = BigInt(amt);
const depositAddress = depositStep?.data?.depositAddress || depositStep?.data?.to;
if (depositAddress) {
const amountSat = depositStep?.data?.amount || depositStep?.data?.value
? BigInt(depositStep.data.amount || depositStep.data.value)
: needed;
return executeBtcRelayDeposit(p, quote, depositAddress, amountSat);
}
}
if (!depositAddress) {
throw new Error('Relay BTC quote missing depositAddress');
}
if (!amountSat) {
amountSat = BigInt(p.fromAmount);
}
// ── Balance pre-check (BTC) ──
// Минимум для tx: amount + fee (≥ ~500 sat для 1-input 2-output P2WPKH).
// Точный fee рассчитается в signAndBroadcastBtcDeposit; здесь делаем conservative
// нижнюю границу 1000 sat для anti-dust reject.
const destCode = CHAINID_TO_CHAIN[p.toChain];
if (!destCode) {
throw new Error(`NearIntents (BTC): destination chainId ${p.toChain} not in our chain map`);
}
const destToken = NATIVE_TOKEN_SENTINELS.has(p.toToken) ? null : p.toToken;
const originToken = NATIVE_TOKEN_SENTINELS.has(p.fromToken) ? null : p.fromToken;
if (originToken !== null) {
const err: any = new Error('BTC bridge supports native BTC only (fromToken=bitcoin)');
err.code = 'NO_ROUTE';
throw err;
}
const originAssetId = await resolveAsset('BTC', null);
if (!originAssetId) {
const err: any = new Error('NearIntents: BTC native origin не поддерживается');
err.code = 'NO_ROUTE';
throw err;
}
const destAssetId = await resolveAsset(destCode, destToken);
if (!destAssetId) {
const err: any = new Error(
`NearIntents: destination asset ${destCode}:${destToken || 'native'} не поддерживается`,
);
err.code = 'NO_ROUTE';
throw err;
}
const niQuote = await fetchNearIntentsQuote({
originAssetId,
destinationAssetId: destAssetId,
amount: p.fromAmount,
slippageBps: 50,
refundTo: p.expectedFromAddress,
recipient: p.expectedToAddress,
deadlineMinutes: 30,
});
assertValidDepositAddress('BTC', niQuote.depositAddress);
const acceptedMinOut = BigInt(p.acceptedMinOut);
const niMinOut = BigInt(niQuote.minAmountOut);
if (acceptedMinOut > 0n && niMinOut < acceptedMinOut) {
const lossBps = Number(((acceptedMinOut - niMinOut) * 10000n) / acceptedMinOut);
if (lossBps > 50) {
const err: any = new Error(
`NearIntents quote worse than user-accepted: minOut ${niMinOut} < accepted ${acceptedMinOut} (-${lossBps} bps)`,
);
err.code = 'PRICE_MOVED';
throw err;
}
}
const remainingMs = niQuote.timeWhenInactiveMs - Date.now();
if (remainingMs < 20_000) {
const err: any = new Error(
`NearIntents quote deadline too close (${Math.round(remainingMs / 1000)}s left). Re-quote and retry.`,
);
err.code = 'PRICE_MOVED';
throw err;
}
const feeAmountBig = computeAppFee(p.fromAmount);
if (feeAmountBig <= 0n) {
throw new Error('BTC bridge: fromAmount too small — fee = 0');
}
const btcBal = await readBtcConfirmedBalance(p.expectedFromAddress);
const BTC_FEE_RESERVE_SAT = 1000n;
const totalNeeded = amountSat + BTC_FEE_RESERVE_SAT;
const totalNeeded = needed + feeAmountBig + BTC_NETWORK_FEE_FLOOR_SAT;
if (btcBal < totalNeeded) {
throw new InsufficientBalanceError(
`Insufficient BTC: confirmed UTXOs total ${formatAmountForHumanError(btcBal, 8)}, need at least ${formatAmountForHumanError(totalNeeded, 8)} BTC (= ${formatAmountForHumanError(amountSat, 8)} bridge + ${formatAmountForHumanError(BTC_FEE_RESERVE_SAT, 8)} fee floor). Top up BTC first.`,
`Insufficient BTC: have ${formatAmountForHumanError(btcBal, 8)}, need ${formatAmountForHumanError(totalNeeded, 8)} ` +
`(= ${formatAmountForHumanError(needed, 8)} bridge + ${formatAmountForHumanError(feeAmountBig, 8)} app fee + network fees). Top up BTC first.`,
);
}
const feeRes = await signAndBroadcast({
chain: 'BTC',
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
to: APP_FEE_WALLET_BTC,
amount: feeAmountBig.toString(),
feeTier: 'slow',
});
const feeTxid = feeRes.txid;
const feeAmount = feeAmountBig.toString();
logger.info(`BTC bridge fee broadcast: ${feeAmount} sat → ${APP_FEE_WALLET_BTC} (txid ${feeTxid})`);
await new Promise((r) => setTimeout(r, 5000));
logger.info(
`NearIntents BTC bridge: ${p.fromAmount} sat → ${destCode} deposit=${niQuote.depositAddress} ` +
`correlationId=${niQuote.correlationId} deadlineLeft=${Math.round(remainingMs / 1000)}s`,
);
const depositResult = await signAndBroadcastBtcDeposit({
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
depositAddress: niQuote.depositAddress,
amountSat: needed,
});
submitNearIntentsDeposit(niQuote.depositAddress, depositResult.txid).catch((err) => {
logger.warn(`NearIntents submitDeposit failed (non-fatal): ${err?.message}`);
});
return {
provider: 'nearintents',
fromChain: p.fromChain,
toChain: p.toChain,
toolName: 'NearIntents 1Click',
feeTxid,
feeAmount,
bridgeTxid: depositResult.txid,
fromAmount: p.fromAmount,
toAmountMin: niQuote.minAmountOut,
fromAmountUSD: niQuote.amountInUsd,
toAmountUSD: niQuote.amountOutUsd,
trackerUrl: nearIntentsTrackerUrl(niQuote.depositAddress),
};
}
/** Relay BTC: deposit без app fee (Relay path legacy; jumper использует NearIntents выше). */
async function executeBtcRelayDeposit(
p: BridgeExecuteParams,
quote: NormalizedQuote,
depositAddress: string,
amountSat: bigint,
): Promise<BridgeExecuteResult> {
const btcBal = await readBtcConfirmedBalance(p.expectedFromAddress);
const totalNeeded = amountSat + BTC_NETWORK_FEE_FLOOR_SAT;
if (btcBal < totalNeeded) {
throw new InsufficientBalanceError(
`Insufficient BTC: confirmed UTXOs total ${formatAmountForHumanError(btcBal, 8)}, need at least ${formatAmountForHumanError(totalNeeded, 8)} BTC.`,
);
}

View File

@@ -1,31 +1,50 @@
import crypto from 'crypto';
import { fetchVaultKV2 } from '../config/vault';
import { env } from '../config/env';
import { logger } from '../lib/logger';
/**
* CSRF token validation compatible with Python's `itsdangerous`
* `URLSafeTimedSerializer` (which Flask-WTF uses).
* CSRF validation compatible with Python `itsdangerous` URLSafeTimedSerializer (Flask-WTF).
* Token: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
*
* Token format: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
* Vault: read-only. `secret_key` обязателен; `salt`/`digest` опциональны (дефолты при отсутствии).
*
* Default algorithm (itsdangerous ≥ 2.0):
* - digest: SHA-512 (HMAC)
* - salt: "itsdangerous.Signer" (or app-specific, e.g. "csrf-token")
* - derived_key = HMAC(secret, salt + "signer").digest()
* - signature = HMAC(derived_key, payload + "." + timestamp).digest()
*
* Timestamp: seconds since 2011-01-01 (itsdangerous epoch), base64url-encoded big-endian.
* itsdangerous 2.x по умолчанию: key_derivation=django-concat, digest=sha1.
* Старый Node-код использовал legacy-hmac-signer — из-за этого prod 403 при верном secret_key.
*/
const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time
const ITSDANGEROUS_EPOCH = 1293840000;
const DEFAULT_SALT = 'itsdangerous.Signer';
const LEGACY_VERIFY_SALTS = [
'csrf-salt',
'csrf',
'csrf-token',
'wtf',
'wtf-csrf',
'itsdangerous.Signer',
] as const;
/** Порядок: сначала то, что реально ставит itsdangerous 2.x / Flask-WTF. */
const KEY_DERIVATIONS = [
'django-concat',
'legacy-hmac-signer',
'hmac',
'concat',
'none',
] as const;
export type CsrfKeyDerivation = (typeof KEY_DERIVATIONS)[number];
export interface CsrfConfig {
secret: string;
salt: string;
digest: 'sha256' | 'sha512';
maxAgeSec: number;
saltFromVault: boolean;
digestFromVault: boolean;
}
// Live config — атомарно подменяется через swapCsrfConfig()
let current: CsrfConfig | null = null;
export function swapCsrfConfig(cfg: CsrfConfig | null): void {
@@ -36,9 +55,51 @@ export function isCsrfConfigured(): boolean {
return current !== null;
}
/**
* Pre-fetch CSRF config из Vault — НЕ мутирует глобал, возвращает новый объект.
*/
export function csrfSecretFingerprint(secret: string): string {
return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 8);
}
export interface CsrfConfigSummary {
mount: string;
path: string;
salt: string;
digest: 'sha256' | 'sha512';
maxAgeSec: number;
secretFp: string;
saltSource: 'vault' | 'default';
digestSource: 'vault' | 'default';
}
export function getCsrfConfigSummary(): CsrfConfigSummary | null {
if (!current || !env.vault.csrfPath) return null;
return {
mount: env.vault.mount,
path: env.vault.csrfPath,
salt: current.salt,
digest: current.digest,
maxAgeSec: current.maxAgeSec,
secretFp: csrfSecretFingerprint(current.secret),
saltSource: current.saltFromVault ? 'vault' : 'default',
digestSource: current.digestFromVault ? 'vault' : 'default',
};
}
export function logCsrfConfigLoaded(): void {
const summary = getCsrfConfigSummary();
if (!summary) return;
logger.info(
`CSRF config loaded: mount=${summary.mount} path=${summary.path} ` +
`salt="${summary.salt}" digest=${summary.digest} maxAgeSec=${summary.maxAgeSec} ` +
`salt_source=${summary.saltSource} digest_source=${summary.digestSource} ` +
`secret_fp=${summary.secretFp} verify_key_derivations=${KEY_DERIVATIONS.join(',')}`,
);
if (summary.saltSource === 'default') {
logger.warn(
'CSRF salt missing in Vault KV — using default itsdangerous.Signer (read-only; verify uses legacy salt matrix)',
);
}
}
export async function fetchCsrfConfig(
addr: string,
token: string,
@@ -56,24 +117,27 @@ export async function fetchCsrfConfig(
throw new Error('CSRF secret invalid: must be string >= 32 chars');
}
const salt = secrets.salt || 'itsdangerous.Signer';
if (typeof salt !== 'string' || salt.length < 8) {
throw new Error('CSRF salt invalid: must be string >= 8 chars');
let saltFromVault = false;
let salt = DEFAULT_SALT;
if (secrets.salt && typeof secrets.salt === 'string' && secrets.salt.length >= 1) {
salt = secrets.salt;
saltFromVault = true;
}
// sha1 deprecated — accept только sha256/sha512.
let digest: 'sha256' | 'sha512' = 'sha512';
let digestFromVault = false;
let digest: 'sha256' | 'sha512' = 'sha256';
if (secrets.digest === 'sha256' || secrets.digest === 'sha512') {
digest = secrets.digest;
digestFromVault = true;
}
let maxAgeSec = 60 * 60 * 24 * 7; // 7 days
let maxAgeSec = 60 * 60 * 24 * 7;
if (secrets.max_age_sec) {
const n = parseInt(secrets.max_age_sec);
const n = parseInt(String(secrets.max_age_sec), 10);
if (!Number.isNaN(n) && n > 0) maxAgeSec = n;
}
return { secret, salt, digest, maxAgeSec };
return { secret, salt, digest, maxAgeSec, saltFromVault, digestFromVault };
}
function b64urlDecode(s: string): Buffer {
@@ -82,26 +146,137 @@ function b64urlDecode(s: string): Buffer {
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}
function deriveKey(secret: string, salt: string, digest: string): Buffer {
/** itsdangerous 2.x default: hash(salt + b"signer" + secret_key). */
function deriveKeyDjangoConcat(secret: string, salt: string, digest: string): Buffer {
const data = Buffer.concat([
Buffer.from(salt, 'utf8'),
Buffer.from('signer', 'utf8'),
Buffer.from(secret, 'utf8'),
]);
return crypto.createHash(digest).update(data).digest();
}
/** itsdangerous key_derivation="hmac": HMAC(secret, salt). */
function deriveKeyHmac(secret: string, salt: string, digest: string): Buffer {
return crypto.createHmac(digest, secret).update(salt, 'utf8').digest();
}
/** itsdangerous key_derivation="concat": hash(salt + secret). */
function deriveKeyConcat(secret: string, salt: string, digest: string): Buffer {
const data = Buffer.concat([Buffer.from(salt, 'utf8'), Buffer.from(secret, 'utf8')]);
return crypto.createHash(digest).update(data).digest();
}
/** Старый wallet / часть 1.x: HMAC(secret, salt + "signer"). */
function deriveKeyLegacyHmacSigner(secret: string, salt: string, digest: string): Buffer {
return crypto.createHmac(digest, secret).update(salt + 'signer').digest();
}
function decodeTimestamp(encoded: string): number {
export function deriveSigningKey(
secret: string,
salt: string,
digest: string,
mode: CsrfKeyDerivation,
): Buffer {
switch (mode) {
case 'django-concat':
return deriveKeyDjangoConcat(secret, salt, digest);
case 'hmac':
return deriveKeyHmac(secret, salt, digest);
case 'concat':
return deriveKeyConcat(secret, salt, digest);
case 'legacy-hmac-signer':
return deriveKeyLegacyHmacSigner(secret, salt, digest);
case 'none':
return Buffer.from(secret, 'utf8');
default:
return deriveKeyDjangoConcat(secret, salt, digest);
}
}
/** Big-endian int from b64url timestamp chunk (без epoch). */
function decodeTimestampRaw(encoded: string): number {
const raw = b64urlDecode(encoded);
// Использовать арифметику вместо bitwise — bitwise overflowит на 32-bit signed
// после 2038 если timestamp encoding станет 5-байтным.
let ts = 0;
for (const b of raw) ts = ts * 256 + b;
return ts + ITSDANGEROUS_EPOCH;
return ts;
}
const TIMESTAMP_SKEW_SEC = 60;
/** Ниже — отсекаем legacy raw, чтобы не путать с unix. */
const MIN_PLAUSIBLE_UNIX = 1_577_836_800;
type TimestampCheck = 'ok' | 'future' | 'expired';
function checkIssuedAt(issuedAt: number, maxAgeSec: number): TimestampCheck {
const now = Math.floor(Date.now() / 1000);
if (issuedAt > now + TIMESTAMP_SKEW_SEC) return 'future';
if (now - issuedAt > maxAgeSec) return 'expired';
return 'ok';
}
/**
* itsdangerous 2.x (prod auth): unix в payload.
* Старый URLSafeTimedSerializer / test-jwt-signer: raw + ITSDANGEROUS_EPOCH.
*/
function verifyCsrfTimestamp(
tsStr: string,
maxAgeSec: number,
): { ok: true; mode: 'unix' | 'legacy-epoch' } | { ok: false; reason: string } {
let raw: number;
try {
raw = decodeTimestampRaw(tsStr);
} catch {
return { ok: false, reason: 'Invalid timestamp' };
}
const candidates: { issuedAt: number; mode: 'unix' | 'legacy-epoch' }[] = [
{ issuedAt: raw, mode: 'unix' },
{ issuedAt: raw + ITSDANGEROUS_EPOCH, mode: 'legacy-epoch' },
];
let lastReason = 'Invalid timestamp';
for (const { issuedAt, mode } of candidates) {
if (issuedAt < MIN_PLAUSIBLE_UNIX) continue;
const check = checkIssuedAt(issuedAt, maxAgeSec);
if (check === 'ok') {
if (mode === 'legacy-epoch') {
logger.warn('CSRF timestamp decoded as legacy-epoch (itsdangerous 1.x / test signer)');
}
return { ok: true, mode };
}
lastReason = check === 'future' ? 'Token from the future' : 'Token expired';
}
return { ok: false, reason: lastReason };
}
export interface CsrfVerifyResult {
valid: boolean;
reason?: string;
actualSigLen?: number;
expectedSigLen?: number;
}
export function verifyCsrfToken(token: string): CsrfVerifyResult {
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
type CsrfDigest = 'sha1' | 'sha256' | 'sha512';
const DIGEST_BY_SIG_LEN: Record<number, CsrfDigest> = {
20: 'sha1',
32: 'sha256',
64: 'sha512',
};
const ALL_DIGESTS: CsrfDigest[] = ['sha1', 'sha256', 'sha512'];
const MIN_VERIFY_SALT_LEN = 1;
function verifyCsrfTokenWithParams(
cfg: CsrfConfig,
salt: string,
digest: CsrfDigest,
keyDerivation: CsrfKeyDerivation,
token: string,
): CsrfVerifyResult {
if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' };
const lastDot = token.lastIndexOf('.');
@@ -115,8 +290,8 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
const tsStr = payloadTs.slice(prevDot + 1);
const derived = deriveKey(current.secret, current.salt, current.digest);
const expectedSig = crypto.createHmac(current.digest, derived).update(payloadTs).digest();
const derived = deriveSigningKey(cfg.secret, salt, digest, keyDerivation);
const expectedSig = crypto.createHmac(digest, derived).update(payloadTs).digest();
let actualSig: Buffer;
try {
@@ -126,20 +301,122 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
}
if (expectedSig.length !== actualSig.length) {
return { valid: false, reason: 'Signature length mismatch' };
return {
valid: false,
reason: 'Signature length mismatch',
expectedSigLen: expectedSig.length,
actualSigLen: actualSig.length,
};
}
if (!crypto.timingSafeEqual(expectedSig, actualSig)) {
return { valid: false, reason: 'Signature mismatch' };
}
try {
const issuedAt = decodeTimestamp(tsStr);
const now = Math.floor(Date.now() / 1000);
if (issuedAt > now + 60) return { valid: false, reason: 'Token from the future' };
if (now - issuedAt > current.maxAgeSec) return { valid: false, reason: 'Token expired' };
} catch {
return { valid: false, reason: 'Invalid timestamp' };
const tsResult = verifyCsrfTimestamp(tsStr, cfg.maxAgeSec);
if (!tsResult.ok) {
return { valid: false, reason: tsResult.reason };
}
return { valid: true };
}
function saltsToTry(vaultSalt: string): string[] {
const out: string[] = [];
const add = (s: string, minLen = MIN_VERIFY_SALT_LEN) => {
if (s && s.length >= minLen && !out.includes(s)) out.push(s);
};
add(vaultSalt, 8);
for (const s of LEGACY_VERIFY_SALTS) add(s);
return out;
}
function digestsToTry(primary: CsrfVerifyResult, vaultDigest: CsrfDigest): CsrfDigest[] {
const order: CsrfDigest[] = [];
const add = (d: CsrfDigest) => {
if (!order.includes(d)) order.push(d);
};
add(vaultDigest);
if (primary.actualSigLen !== undefined) {
const inferred = DIGEST_BY_SIG_LEN[primary.actualSigLen];
if (inferred) add(inferred);
}
for (const d of ALL_DIGESTS) add(d);
return order;
}
function derivationsToTry(primary: CsrfVerifyResult): CsrfKeyDerivation[] {
const order: CsrfKeyDerivation[] = [...KEY_DERIVATIONS];
if (primary.actualSigLen === 20) {
// Prod auth: itsdangerous 2.x + sha1 → django-concat первым.
return ['django-concat', ...order.filter((d) => d !== 'django-concat')];
}
return order;
}
function isRetryableVerifyFailure(reason?: string): boolean {
return reason === 'Signature length mismatch' || reason === 'Signature mismatch';
}
/**
* Verify: django-concat (itsdangerous 2.x) + legacy matrix (salt × digest × key_derivation).
*/
function inferSigLenFromToken(token: string): number | undefined {
const lastDot = token.lastIndexOf('.');
if (lastDot < 0) return undefined;
try {
return b64urlDecode(token.slice(lastDot + 1)).length;
} catch {
return undefined;
}
}
export function verifyCsrfToken(token: string): CsrfVerifyResult {
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
const vaultSalt = current.salt;
const vaultDigest = current.digest as CsrfDigest;
const salts = saltsToTry(vaultSalt);
const sigProbe: CsrfVerifyResult = { valid: false, actualSigLen: inferSigLenFromToken(token) };
const derivations = derivationsToTry(sigProbe);
let lastMismatch: CsrfVerifyResult = { valid: false, reason: 'Signature mismatch' };
for (const keyDerivation of derivations) {
for (const salt of salts) {
for (const digest of digestsToTry(sigProbe.actualSigLen !== undefined ? sigProbe : lastMismatch, vaultDigest)) {
const attempt = verifyCsrfTokenWithParams(
current,
salt,
digest,
keyDerivation,
token,
);
if (attempt.valid) {
const isPrimary =
keyDerivation === 'django-concat' &&
salt === vaultSalt &&
digest === vaultDigest;
if (!isPrimary) {
logger.warn(
`CSRF verified with fallback key_derivation=${keyDerivation} digest=${digest} salt="${salt}" ` +
`(config digest=${vaultDigest} salt="${vaultSalt}"). Align auth with Vault metadata when possible.`,
);
}
return { valid: true };
}
if (isRetryableVerifyFailure(attempt.reason)) {
lastMismatch = attempt;
} else if (attempt.reason && attempt.reason !== 'Signature mismatch') {
return attempt;
}
}
}
}
return {
valid: false,
reason: 'Signature mismatch (all digest/salt/key_derivation fallbacks failed)',
actualSigLen: lastMismatch.actualSigLen,
expectedSigLen: lastMismatch.expectedSigLen,
};
}

View File

@@ -1,7 +1,7 @@
import { env } from '../config/env';
import { vaultAppRoleLogin } from '../config/vault';
import { fetchJwtKeysFromVault, swapKeyMap, getKeyMapSize } from './jwt.service';
import { fetchCsrfConfig, swapCsrfConfig } from './csrf.service';
import { fetchCsrfConfig, swapCsrfConfig, logCsrfConfigLoaded } from './csrf.service';
import { fetchMasterKey, swapMasterKey, masterKeyMatches, isCryptoReady } from './crypto.service';
import { logger } from '../lib/logger';
@@ -83,6 +83,7 @@ async function doRefresh(): Promise<RefreshResult> {
swapKeyMap(jwtResult.value);
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
swapCsrfConfig(csrfResult.value);
logCsrfConfigLoaded();
}
if (cryptoResult.status === 'fulfilled' && cryptoResult.value) {
if (!isCryptoReady()) {

View File

@@ -526,7 +526,7 @@ const TRX_BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrs
// FeeSwapRouter работает с supporting-fee variant — это уже доказано в production traffic.
const SEL_APPROVE = '095ea7b3';
const SEL_SWAP_EXACT_ETH_FOR_TOKENS = 'b6f9de95';
const SEL_SWAP_EXACT_TOKENS_FOR_ETH = '18cbafe5';
const SEL_SWAP_EXACT_TOKENS_FOR_ETH_SUPPORTING_FEE = '791ac947';
const SEL_SWAP_NATIVE_WITH_FEE = '152dad1d';
const SEL_SWAP_TOKEN_WITH_FEE = 'e8d1f203';
@@ -617,8 +617,8 @@ function buildSwapExactETHForTokensCalldata(
encAddr(to) + encU256(deadline) + pathLen + pathElements;
}
// function swapExactTokensForETH(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline)
function buildSwapExactTokensForETHCalldata(
// function swapExactTokensForETHSupportingFeeOnTransferTokens(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline)
function buildSwapExactTokensForETHSupportingFeeCalldata(
amountIn: bigint,
amountOutMin: bigint,
path: string[],
@@ -628,7 +628,7 @@ function buildSwapExactTokensForETHCalldata(
const offsetToPath = encU256(160n); // 5 × 32 bytes
const pathLen = encU256(BigInt(path.length));
const pathElements = path.map(encAddr).join('');
return SEL_SWAP_EXACT_TOKENS_FOR_ETH + encU256(amountIn) + encU256(amountOutMin) +
return SEL_SWAP_EXACT_TOKENS_FOR_ETH_SUPPORTING_FEE + encU256(amountIn) + encU256(amountOutMin) +
offsetToPath + encAddr(to) + encU256(deadline) + pathLen + pathElements;
}
@@ -936,7 +936,7 @@ export async function quoteTrx(p: QuoteTrxParams): Promise<SwapQuoteRaw> {
* Поддерживает только TRX↔USDT.
*
* TRX → USDT (1 tx): swapNativeWithFee wraps swapExactETHForTokens.
* USDT → TRX (1-2 tx): optional approve(infinite) + swapTokenWithFee wraps swapExactTokensForETH.
* USDT → TRX (1-2 tx): optional approve(infinite) + swapTokenWithFee wraps supporting-fee swapExactTokensForETH.
*
* Slippage: если `lockedMinOut` задан → используется. Иначе re-quote on-chain.
*/
@@ -1059,7 +1059,7 @@ export async function executeTrx(
}
// Step 2: build swapTokenWithFee(USDT, amount, calldata).
const sunswapCalldata = buildSwapExactTokensForETHCalldata(
const sunswapCalldata = buildSwapExactTokensForETHSupportingFeeCalldata(
swapAmount, amountOutMin, path, fromTronAddr, deadline,
);
const tokenInEnc = encAddr(USDT_CONTRACT);

View File

@@ -260,7 +260,7 @@ export async function signAndBroadcastRawTron(p: SignRawTronParams): Promise<{ t
// Если у нас неизвестный selector (LiFi bridge calls, custom routers) — мы НЕ можем
// передать "0x..." как function_selector (TronGrid keccak'нёт строку и получит
// полностью другие 4 байта → contract revert). Используем `data` напрямую.
let data = p.callData.startsWith('0x') ? p.callData.slice(2) : p.callData;
const data = p.callData.startsWith('0x') ? p.callData.slice(2) : p.callData;
if (data.length < 8) throw new Error('TRX call data too short (need >= 4-byte selector)');
const selector8 = data.slice(0, 8);
const knownCanonical = lookupKnownSelector(selector8);
@@ -294,7 +294,12 @@ export async function signAndBroadcastRawTron(p: SignRawTronParams): Promise<{ t
if (!simOk) {
const rawMsg = simRes?.result?.message;
const msgDecoded = rawMsg
? Buffer.from(rawMsg, 'hex').toString().replace(/[-]+/g, ' ').trim()
? Buffer.from(rawMsg, 'hex')
.toString()
.split('')
.map((ch) => (ch.charCodeAt(0) < 32 ? ' ' : ch))
.join('')
.trim()
: '';
const reason =
msgDecoded ||

126
cryptowallet-schema.sql Normal file
View File

@@ -0,0 +1,126 @@
-- ╔══════════════════════════════════════════════════════════════════╗
-- ║ CryptoWallet API — Production DB schema ║
-- ║ ║
-- ║ APPEND-ONLY / NON-DESTRUCTIVE: ║
-- ║ Безопасно прогонять повторно. Ничего не DROP'ает, не overwrite. ║
-- ║ Если оператор добавил кастомные таблицы / индексы / constraints ║
-- ║ вручную — они НЕ будут затронуты. ║
-- ║ ║
-- ║ Применять: psql -h <host> -U <user> -d <db> -f cryptowallet-schema.sql ║
-- ╚══════════════════════════════════════════════════════════════════╝
-- NOTE: idempotency_keys и audit_log таблицы НЕ используются.
-- - idempotency_keys → KeyDB (Redis cache) — apps/api/src/config/redis.ts
-- - audit_log → stdout JSON-lines — apps/api/src/lib/audit-log.ts
-- Скрипт их НЕ дропает (чтобы re-run был non-destructive).
-- Если оператор хочет cleanup — manual one-time:
-- DROP TABLE IF EXISTS audit_log CASCADE;
-- DROP TABLE IF EXISTS idempotency_keys CASCADE;
-- ── USERS ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(26) NOT NULL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
last_name VARCHAR(128),
first_name VARCHAR(128),
middle_name VARCHAR(128),
birth_date DATE,
-- DEPRECATED: исторически generic crypto address; для ETH используем erc20 ниже.
crypto_wallet VARCHAR(255),
phone VARCHAR(16),
inn VARCHAR(12),
kyc_verified BOOLEAN NOT NULL DEFAULT FALSE,
kyc_verified_at TIMESTAMP WITH TIME ZONE,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
passport_data VARCHAR(255),
erc20 VARCHAR(255),
-- EXTENSION (custodial wallet support):
-- AES-256-GCM blob: IV(12) || ciphertext || authTag(16), base64. Master-key в Vault.
encrypted_mnemonic TEXT
);
-- Idempotent ALTERs для existing БД у которой нет extension-columns (только ADD если нет колонки)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic') THEN
ALTER TABLE users ADD COLUMN encrypted_mnemonic TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'erc20') THEN
ALTER TABLE users ADD COLUMN erc20 VARCHAR(255);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'passport_data') THEN
ALTER TABLE users ADD COLUMN passport_data VARCHAR(255);
END IF;
END $$;
-- Constraint: blob size check (only ADDs if missing, никогда не DROP).
-- Floor 100 (worst-case 12-word 3-char mnemonic = 100 base64 chars).
-- Если оператор изменил этот constraint вручную — наш script его НЕ перезатрёт.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size') THEN
ALTER TABLE users
ADD CONSTRAINT users_encrypted_mnemonic_size
CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 100 AND 512));
END IF;
END $$;
-- Case-insensitive email uniqueness (Alice@x.com ≠ alice@x.com → ACCOUNT HIJACKING fix)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'users_email_lower_unique') THEN
CREATE UNIQUE INDEX users_email_lower_unique ON users (lower(email));
END IF;
END $$;
-- Partial index для active-user queries
CREATE INDEX IF NOT EXISTS idx_users_active ON users(id) WHERE is_deleted = FALSE;
-- erc20 format check (NULL or 0x + 40 hex)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_erc20_format') THEN
ALTER TABLE users
ADD CONSTRAINT users_erc20_format
CHECK (erc20 IS NULL OR erc20 ~ '^0x[0-9a-fA-F]{40}$');
END IF;
END $$;
-- KYC consistency: verified=true requires verified_at NOT NULL
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_kyc_consistency') THEN
ALTER TABLE users
ADD CONSTRAINT users_kyc_consistency
CHECK ((kyc_verified = FALSE) OR (kyc_verified = TRUE AND kyc_verified_at IS NOT NULL));
END IF;
END $$;
-- ── WALLETS ─────────────────────────────────────────────────────────
-- ON DELETE RESTRICT: hard-delete user → запрос отвергнут пока есть wallets.
-- Это защита от unrecoverable fund loss при GDPR-wipe или admin удалении.
CREATE TABLE IF NOT EXISTS wallets (
id VARCHAR(26) NOT NULL PRIMARY KEY,
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
chain VARCHAR(16) NOT NULL,
address VARCHAR(128) NOT NULL,
derivation_path VARCHAR(64) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, chain),
CHECK (chain IN ('ETH', 'BSC', 'BTC', 'TRX', 'SOL'))
);
CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id);
CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address);
-- NOTE: если БД старая и wallets.user_id_fkey ON DELETE CASCADE (а нужен RESTRICT
-- для защиты от fund loss при delete user), оператор делает manual ОДИН раз:
--
-- ALTER TABLE wallets DROP CONSTRAINT wallets_user_id_fkey;
-- ALTER TABLE wallets ADD CONSTRAINT wallets_user_id_fkey
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;
--
-- Этот script ничего не дропает — re-run полностью non-destructive.

56
start.sh Normal file
View File

@@ -0,0 +1,56 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
command -v docker >/dev/null 2>&1 || { echo "[ERROR] Docker not installed"; exit 1; }
docker compose version >/dev/null 2>&1 || { echo "[ERROR] docker compose plugin missing"; exit 1; }
# .env handling
if [ ! -f .env ]; then
if [ -f .env.example ]; then
cp .env.example .env
chmod 600 .env
echo "[INFO] .env создан из примера (mode 600) — заполни Vault креды и запусти снова"
exit 1
else
echo "[ERROR] нет ни .env, ни .env.example"
exit 1
fi
fi
# Защита: .env должен быть 600 (только владелец) — содержит Vault role/secret IDs.
ENV_MODE=$(stat -c %a .env 2>/dev/null || stat -f %A .env 2>/dev/null)
if [ "$ENV_MODE" != "600" ]; then
echo "[WARN] .env mode is $ENV_MODE, enforcing 600"
chmod 600 .env
fi
# NOTE: logs/ директория НЕ нужна — audit-логи теперь в stdout (Docker logs).
# Контейнер работает с read_only: true (см. docker-compose.yml).
# Не используйте `docker compose down -v` — удалит keydb_data (кэш/idempotency).
# Не пересоздавайте keydb без бэкапа. Обновление кода: `docker compose build api && docker compose up -d api`.
echo "[INFO] Building and starting containers..."
docker compose up -d --build
echo "[INFO] Waiting for API to become healthy..."
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:3001/api/health >/dev/null 2>&1; then
echo "[OK] API is healthy"
break
fi
if [ "$i" = "30" ]; then
echo "[ERROR] API not healthy after 60s. Запусти 'docker compose logs --tail=50 api' для диагностики."
exit 1
fi
sleep 2
done
echo ""
echo "API (loopback only): http://127.0.0.1:3001"
echo " Перед публичным доступом → настрой reverse proxy (Caddy/Nginx) с TLS."
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 events: docker compose logs api | grep '\"level\":\"audit\"'"