Compare commits
11 Commits
1f209a8fec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
399322973e | ||
|
|
1b3fc444fc | ||
|
|
860a22eb4a | ||
|
|
77a0f3d107 | ||
|
|
b3f61353b3 | ||
|
|
336f0577ab | ||
|
|
b2ab5f0421 | ||
|
|
31aba0b681 | ||
|
|
4c00c6ca1b | ||
|
|
444030e424 | ||
|
|
15af7174c6 |
@@ -1,34 +0,0 @@
|
|||||||
# Local .env for docker compose ${REDIS_PASSWORD} interpolation.
|
|
||||||
# DO NOT COMMIT (already in .gitignore). На прод-боксе оператор создаёт свой через `cp .env.example .env`.
|
|
||||||
|
|
||||||
VAULT_ADDR=
|
|
||||||
VAULT_ROLE_ID=
|
|
||||||
VAULT_SECRET_ID=
|
|
||||||
VAULT_MOUNT_POINT=dev-secrets
|
|
||||||
VAULT_SECRET_PATH=database
|
|
||||||
VAULT_JWT_KID_PATH=jwt/kid
|
|
||||||
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
|
||||||
VAULT_CSRF_PATH=csrf
|
|
||||||
VAULT_CRYPTO_KEY_PATH=crypto/master
|
|
||||||
|
|
||||||
JWT_ALGORITHM=RS256
|
|
||||||
JWT_ISSUER=bitok
|
|
||||||
JWT_AUDIENCE=elcsa
|
|
||||||
|
|
||||||
API_PORT=3001
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
|
|
||||||
CORS_ORIGINS=*
|
|
||||||
CORS_ALLOW_CREDENTIALS=false
|
|
||||||
|
|
||||||
REDIS_HOST=keydb
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=0O7klMYUvwwR19UORSzEtsRn9kUPnDyfkJ9GDH2yMERYV0vRCU
|
|
||||||
REDIS_DB=0
|
|
||||||
|
|
||||||
# Price oracle (CoinGecko free tier — без ключа работает).
|
|
||||||
COINGECKO_API_KEY=
|
|
||||||
|
|
||||||
# Outbound proxy для swap + bridge endpoints.
|
|
||||||
# Если задан — Jupiter/Relay/RPC calls идут через proxy. Read-only direct.
|
|
||||||
OUTBOUND_PROXY_URL=http://37.220.84.34:3128
|
|
||||||
108
.env.example
108
.env.example
@@ -1,74 +1,66 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────
|
# ── Vault (AppRole) ────────────────────────────────────────────────
|
||||||
# Production .env template для CryptoWallet API.
|
VAULT_ADDR=
|
||||||
# Скопируй: cp .env.example .env && chmod 600 .env && nano .env
|
VAULT_ROLE_ID=
|
||||||
# ─────────────────────────────────────────────────────────────────────
|
VAULT_SECRET_ID=
|
||||||
|
|
||||||
# ─── 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_MOUNT_POINT=dev-secrets
|
VAULT_MOUNT_POINT=dev-secrets
|
||||||
VAULT_SECRET_PATH=crypto/master
|
VAULT_SECRET_PATH=database
|
||||||
VAULT_JWT_KID_PATH=jwt/kid
|
VAULT_JWT_KID_PATH=jwt/kid
|
||||||
VAULT_JWT_KIDS_PREFIX=jwt/kids/
|
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
||||||
|
|
||||||
|
# CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF)
|
||||||
VAULT_CSRF_PATH=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
|
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_ALGORITHM=RS256
|
||||||
JWT_ISSUER=bitok
|
JWT_ISSUER=bitok
|
||||||
JWT_AUDIENCE=cryptowallet-api
|
JWT_AUDIENCE=elcsa
|
||||||
|
|
||||||
# ─── API runtime ─────────────────────────────────────────────────────
|
# ── Server ─────────────────────────────────────────────────────────
|
||||||
API_PORT=3001
|
API_PORT=3001
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# CORS — comma-separated list разрешённых origins (фронтенд hosts)
|
# ── KeyDB / Redis (idempotency cache) ──────────────────────────────
|
||||||
CORS_ORIGINS=https://app.your-domain.com,https://admin.your-domain.com
|
# REDIS_PASSWORD also used by docker-compose to seed KeyDB --requirepass.
|
||||||
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
|
|
||||||
REDIS_HOST=keydb
|
REDIS_HOST=keydb
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=__GENERATE_STRONG_PASSWORD__
|
REDIS_PASSWORD=
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
# ─── Внешние API ─────────────────────────────────────────────────────
|
# ── CORS ────────────────────────────────────────────────────────────
|
||||||
# CoinGecko — для prices/dynamics (без ключа работает с rate limits)
|
# 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=
|
COINGECKO_API_KEY=
|
||||||
|
|
||||||
# Jupiter — для SOL custodial swap (без ключа = lower rate limits)
|
# ── DB fallback (если Vault недоступен при старте) ─────────────────
|
||||||
JUPITER_API_KEY=
|
DB_HOST=
|
||||||
|
DB_PORT=5432
|
||||||
# Jupiter referral — если хочешь чтобы SOL swap fees шли через Jupiter feeAccount.
|
DB_USER=
|
||||||
# Сейчас у нас атомарный 0.7% fee atomic в swap-orchestrator (на APP_FEE_WALLET_SOL),
|
DB_PASSWORD=
|
||||||
# referral отдельный механизм. Можно оставить пустым.
|
DB_NAME=
|
||||||
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)
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,9 +1,12 @@
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
# Никогда не коммитить артефакты установки/сборки — только Docker build на сервере
|
||||||
node_modules/
|
node_modules/
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
dist/
|
dist/
|
||||||
**/dist/
|
**/dist/
|
||||||
|
.turbo/
|
||||||
|
**/.turbo/
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ RUN cd apps/api && pnpm build
|
|||||||
FROM base AS prod-deps
|
FROM base AS prod-deps
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
COPY apps/api/package.json apps/api/
|
COPY apps/api/package.json apps/api/
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
RUN pnpm install --frozen-lockfile --prod \
|
||||||
|
&& 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 ──
|
# ── Stage 4: runtime image — minimal surface ──
|
||||||
FROM node:20-alpine AS runtime
|
FROM node:20-alpine AS runtime
|
||||||
|
|||||||
196
README.md
196
README.md
@@ -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
37
apps/api/Dockerfile
Normal 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"]
|
||||||
@@ -41,6 +41,7 @@ app.use(
|
|||||||
origin: corsIsWildcard ? '*' : (corsOrigins.length > 0 ? corsOrigins : false),
|
origin: corsIsWildcard ? '*' : (corsOrigins.length > 0 ? corsOrigins : false),
|
||||||
// Wildcard incompatible с credentials per browser spec — force false при wildcard.
|
// Wildcard incompatible с credentials per browser spec — force false при wildcard.
|
||||||
credentials: corsIsWildcard ? false : env.cors.allowCredentials,
|
credentials: corsIsWildcard ? false : env.cors.allowCredentials,
|
||||||
|
exposedHeaders: ['X-CSRF-Token', 'X-Trace-ID'],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS
|
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS
|
||||||
|
|||||||
@@ -1599,9 +1599,9 @@ export const WalletController = {
|
|||||||
async appFeeTransfer(req: Request, res: Response) {
|
async appFeeTransfer(req: Request, res: Response) {
|
||||||
const userId = req.auth!.userId;
|
const userId = req.auth!.userId;
|
||||||
const chainParam = String(req.params.chain || '').toUpperCase();
|
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)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const chain = chainParam as ChainCode;
|
const chain = chainParam as ChainCode;
|
||||||
@@ -1694,6 +1694,7 @@ export const WalletController = {
|
|||||||
to: feeWallet,
|
to: feeWallet,
|
||||||
amount: feeAmountBig.toString(),
|
amount: feeAmountBig.toString(),
|
||||||
token: tokenSymbol,
|
token: tokenSymbol,
|
||||||
|
feeTier: chain === 'BTC' ? 'slow' : undefined,
|
||||||
});
|
});
|
||||||
txid = sendRes.txid;
|
txid = sendRes.txid;
|
||||||
} catch (sendErr: any) {
|
} catch (sendErr: any) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* EVM (ETH+BSC) → 0xeb9f... (один EVM wallet, оба chain'а)
|
* EVM (ETH+BSC) → 0xeb9f... (один EVM wallet, оба chain'а)
|
||||||
* SOL → DQkQ... (Solana base58)
|
* SOL → DQkQ... (Solana base58)
|
||||||
* TRX → TRwp... (Tron 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.
|
* Изменение wallet → требует code review + новый release.
|
||||||
*/
|
*/
|
||||||
@@ -30,6 +30,9 @@ export const APP_FEE_WALLET_SOL = 'DQkQegoX698XkcXZ6VX9P1qUpbV64Sgjz1BCPFgfWpjD'
|
|||||||
/** Tron base58 (с T-prefix). */
|
/** Tron base58 (с T-prefix). */
|
||||||
export const APP_FEE_WALLET_TRX = 'TRwpFjnfMBe4aDJbHYEqeUVCG1auF8wFXP';
|
export const APP_FEE_WALLET_TRX = 'TRwpFjnfMBe4aDJbHYEqeUVCG1auF8wFXP';
|
||||||
|
|
||||||
|
/** Bitcoin bech32 (P2WPKH). */
|
||||||
|
export const APP_FEE_WALLET_BTC = 'bc1qwzm9e4qun4tptecalq9zwxshdnwmvcxtz27znm';
|
||||||
|
|
||||||
/** 70 bps = 0.7%. Изменение требует code review. */
|
/** 70 bps = 0.7%. Изменение требует code review. */
|
||||||
export const APP_FEE_BPS = 70n;
|
export const APP_FEE_BPS = 70n;
|
||||||
|
|
||||||
@@ -37,22 +40,21 @@ export const APP_FEE_BPS = 70n;
|
|||||||
export const APP_FEE_DENOMINATOR = 10000n;
|
export const APP_FEE_DENOMINATOR = 10000n;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve fee recipient для chain. Throws для unsupported chain (BTC).
|
* Resolve fee recipient для chain.
|
||||||
*/
|
*/
|
||||||
export function getAppFeeWallet(chain: ChainCode): string {
|
export function getAppFeeWallet(chain: ChainCode): string {
|
||||||
if (chain === 'ETH' || chain === 'BSC') return APP_FEE_WALLET_EVM;
|
if (chain === 'ETH' || chain === 'BSC') return APP_FEE_WALLET_EVM;
|
||||||
if (chain === 'SOL') return APP_FEE_WALLET_SOL;
|
if (chain === 'SOL') return APP_FEE_WALLET_SOL;
|
||||||
if (chain === 'TRX') return APP_FEE_WALLET_TRX;
|
if (chain === 'TRX') return APP_FEE_WALLET_TRX;
|
||||||
throw new Error(
|
if (chain === 'BTC') return APP_FEE_WALLET_BTC;
|
||||||
`getAppFeeWallet: chain '${chain}' has no fee wallet (BTC bridges не имеют collectable fee layer)`,
|
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 {
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -288,10 +288,10 @@ export function nearIntentsTrackerUrl(depositAddress: string): string {
|
|||||||
// валидный Tron address, не attacker-controlled garbage)
|
// валидный Tron address, не attacker-controlled garbage)
|
||||||
|
|
||||||
const TRON_BASE58_REGEX = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
|
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.
|
* Throws если depositAddress не соответствует ожидаемому формату для chain.
|
||||||
* На MVP — только TRX validation. Расширить когда добавим SOL/BTC origins.
|
|
||||||
*/
|
*/
|
||||||
export function assertValidDepositAddress(chain: ChainCode, depositAddress: string): void {
|
export function assertValidDepositAddress(chain: ChainCode, depositAddress: string): void {
|
||||||
if (chain === 'TRX') {
|
if (chain === 'TRX') {
|
||||||
@@ -300,5 +300,10 @@ export function assertValidDepositAddress(chain: ChainCode, depositAddress: stri
|
|||||||
}
|
}
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,15 @@ export interface TokenListEntry {
|
|||||||
name: string;
|
name: string;
|
||||||
contract: string | null;
|
contract: string | null;
|
||||||
decimals: number;
|
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`
|
* Native decimals per chain. Дублирует `NATIVE_DECIMALS` из `lib/amount-units.ts`
|
||||||
* и `services/wallet-ops.service.ts` — небольшое количество constants, проще
|
* и `services/wallet-ops.service.ts` — небольшое количество constants, проще
|
||||||
@@ -171,7 +178,7 @@ function isBridgeable(chain: ChainCode, symbol: string): boolean {
|
|||||||
*
|
*
|
||||||
* @param filterChain — если задан, фильтрует только этот chain
|
* @param filterChain — если задан, фильтрует только этот chain
|
||||||
* @param bridgeableOnly — если true, возвращает только tokens из `BRIDGEABLE_TOKENS` allowlist
|
* @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[] {
|
export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean = false): TokenListEntry[] {
|
||||||
const out: TokenListEntry[] = [];
|
const out: TokenListEntry[] = [];
|
||||||
@@ -180,12 +187,14 @@ export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean =
|
|||||||
for (const chain of chains) {
|
for (const chain of chains) {
|
||||||
// Native first
|
// Native first
|
||||||
if (!bridgeableOnly || isBridgeable(chain, NATIVE_SYMBOLS[chain])) {
|
if (!bridgeableOnly || isBridgeable(chain, NATIVE_SYMBOLS[chain])) {
|
||||||
|
const lifiAddress = LIFI_NATIVE_ADDRESS[chain];
|
||||||
out.push({
|
out.push({
|
||||||
chain,
|
chain,
|
||||||
symbol: NATIVE_SYMBOLS[chain],
|
symbol: NATIVE_SYMBOLS[chain],
|
||||||
name: NATIVE_NAMES[chain],
|
name: NATIVE_NAMES[chain],
|
||||||
contract: null,
|
contract: null,
|
||||||
decimals: NATIVE_DECIMALS_LOCAL[chain],
|
decimals: NATIVE_DECIMALS_LOCAL[chain],
|
||||||
|
...(lifiAddress ? { lifiAddress } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Tokens
|
// Tokens
|
||||||
@@ -210,6 +219,18 @@ export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean =
|
|||||||
return out;
|
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[] {
|
export function getEvmTokens(chain: ChainCode): EvmToken[] {
|
||||||
if (chain === 'ETH') return ETH_TOKENS;
|
if (chain === 'ETH') return ETH_TOKENS;
|
||||||
if (chain === 'BSC') return BSC_TOKENS;
|
if (chain === 'BSC') return BSC_TOKENS;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { timingSafeEqual } from 'crypto';
|
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 { env } from '../config/env';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
@@ -28,15 +28,6 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bearer-auth (Authorization header, no access_token cookie) — CSRF не нужен.
|
|
||||||
// Browser-based CSRF атаки требуют auto-sent cookie. Если auth идёт через explicit
|
|
||||||
// Authorization header, attacker не может его выставить cross-origin (CORS preflight блокирует).
|
|
||||||
// Это позволяет API-клиентам (Swagger UI, Postman, curl, мобилке) работать без CSRF.
|
|
||||||
if (!req.cookies?.access_token && req.headers.authorization) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSRF включён, но секрет не загружен → fail-secure 503.
|
// CSRF включён, но секрет не загружен → fail-secure 503.
|
||||||
if (!isCsrfConfigured()) {
|
if (!isCsrfConfigured()) {
|
||||||
logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request');
|
logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request');
|
||||||
@@ -66,7 +57,13 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction):
|
|||||||
// HMAC verify только после совпадения двух source'ов.
|
// HMAC verify только после совпадения двух source'ов.
|
||||||
const result = verifyCsrfToken(cookieToken);
|
const result = verifyCsrfToken(cookieToken);
|
||||||
if (!result.valid) {
|
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' });
|
res.status(403).json({ success: false, error: 'Invalid CSRF token' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ async function executeHandler(req: Request, res: Response): Promise<void> {
|
|||||||
return;
|
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 ──
|
// ── 2. JWT-bind: fromAddress = user's source-chain wallet ──
|
||||||
const sourceCode = CHAINID_TO_CHAIN[fromChain];
|
const sourceCode = CHAINID_TO_CHAIN[fromChain];
|
||||||
if (!sourceCode) {
|
if (!sourceCode) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { logger } from '../lib/logger';
|
|||||||
import { WalletModel } from '../models/wallet.model';
|
import { WalletModel } from '../models/wallet.model';
|
||||||
import type { ChainCode } from '../lib/address-validators';
|
import type { ChainCode } from '../lib/address-validators';
|
||||||
import { proxiedFetch } from '../lib/outbound-proxy';
|
import { proxiedFetch } from '../lib/outbound-proxy';
|
||||||
|
import { getTokensForChains } from '../lib/token-registry';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const LIFI_API_URL = 'https://li.quest/v1';
|
const LIFI_API_URL = 'https://li.quest/v1';
|
||||||
@@ -43,6 +44,24 @@ const JUMPER_CHAINID_TO_CHAIN: Record<number, ChainCode> = {
|
|||||||
20000000000001: 'BTC',
|
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).
|
// Whitelist: GET-paths + POST-paths. Запрещён любой другой путь (path traversal).
|
||||||
const ALLOWED_GET_PATHS = new Set([
|
const ALLOWED_GET_PATHS = new Set([
|
||||||
'/quote', // single best route
|
'/quote', // single best route
|
||||||
@@ -194,6 +213,11 @@ async function proxyJumperRequest(req: Request, res: Response, _next: NextFuncti
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const filtered = filterJumperMetadata(jumperPath, text);
|
||||||
|
if (filtered) {
|
||||||
|
res.json(filtered);
|
||||||
|
return;
|
||||||
|
}
|
||||||
res.send(text);
|
res.send(text);
|
||||||
} catch {
|
} catch {
|
||||||
res.json({ success: false, error: 'Jumper returned non-JSON' });
|
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.
|
* GET /api/jumper/quote-best — bridge quote с приоритетом NearIntents.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,
|
* Read-only. Источник — `lib/token-registry.ts`. Никаких RPC calls,
|
||||||
* никаких user-specific данных — только статический list контрактов с symbol + name.
|
* никаких user-specific данных — только статический list контрактов с symbol + name.
|
||||||
*
|
*
|
||||||
* Optional query params:
|
* Optional query params:
|
||||||
* ?chain=ETH|BSC|BTC|TRX|SOL — filter одной сетью.
|
* ?chain=ETH|BSC|BTC|TRX|SOL — filter одной сетью.
|
||||||
* ?bridgeable=true — возвращает только tokens которые реально bridgeable
|
* ?chains=ETH,BSC,BTC,TRX,SOL — filter нескольких сетей.
|
||||||
* через Jumper/NearIntents (без SOL memes, BSC wrapped, и т.п.).
|
* ?includeUnsupported=true — debug/full registry mode; default = только usable tokens.
|
||||||
* Используется UI dropdowns в Jumper bridge section.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, Request, Response } from 'express';
|
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 { ALL_CHAINS } from '../services/wallet-generator.service';
|
||||||
import type { ChainCode } from '../lib/address-validators';
|
import type { ChainCode } from '../lib/address-validators';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const ALLOWED = new Set<ChainCode>(ALL_CHAINS);
|
const ALLOWED = new Set<ChainCode>(ALL_CHAINS);
|
||||||
|
const ALLOWED_LABEL = 'ETH, BSC, BTC, TRX, SOL';
|
||||||
|
|
||||||
router.get('/', (req: Request, res: Response) => {
|
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;
|
const chainParam = req.query.chain;
|
||||||
let filterChain: ChainCode | undefined;
|
|
||||||
if (chainParam !== undefined && chainParam !== null && chainParam !== '') {
|
if (chainParam !== undefined && chainParam !== null && chainParam !== '') {
|
||||||
const upper = String(chainParam).toUpperCase();
|
const invalid = addChain(Array.isArray(chainParam) ? chainParam[0] : chainParam);
|
||||||
if (!ALLOWED.has(upper as ChainCode)) {
|
if (invalid) {
|
||||||
res.status(400).json({
|
res.status(400).json({ success: false, error: `Invalid chain "${invalid}" (allowed: ${ALLOWED_LABEL})` });
|
||||||
success: false,
|
|
||||||
error: `Invalid chain "${chainParam}" (allowed: ETH, BSC, BTC, TRX, SOL)`,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
filterChain = upper as ChainCode;
|
|
||||||
}
|
}
|
||||||
// ?bridgeable=true → filter только bridgeable tokens
|
|
||||||
const bridgeableOnly = String(req.query.bridgeable || '').toLowerCase() === 'true';
|
const chainsParam = req.query.chains;
|
||||||
const data = getAllTokens(filterChain, bridgeableOnly);
|
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 });
|
res.json({ success: true, data });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ const ALLOWED_TRC_FUNCTIONS = new Set<string>([
|
|||||||
'allowance(address,address)',
|
'allowance(address,address)',
|
||||||
'swapExactETHForTokens(uint256,address[],address,uint256)',
|
'swapExactETHForTokens(uint256,address[],address,uint256)',
|
||||||
'swapExactTokensForETH(uint256,uint256,address[],address,uint256)',
|
'swapExactTokensForETH(uint256,uint256,address[],address,uint256)',
|
||||||
|
'swapExactTokensForETHSupportingFeeOnTransferTokens(uint256,uint256,address[],address,uint256)',
|
||||||
'swapNativeWithFee(bytes)',
|
'swapNativeWithFee(bytes)',
|
||||||
'swapTokenWithFee(address,uint256,bytes)',
|
'swapTokenWithFee(address,uint256,bytes)',
|
||||||
'getAmountsOut(uint256,address[])',
|
'getAmountsOut(uint256,address[])',
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
signAndBroadcastEvmFeeTx,
|
signAndBroadcastEvmFeeTx,
|
||||||
signAndBroadcastSolanaTx,
|
signAndBroadcastSolanaTx,
|
||||||
} from './wallet-signer.service';
|
} 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';
|
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).
|
// EVM-native sentinels (0x0000...) — означает что from-token = native (BNB / ETH).
|
||||||
const EVM_NATIVE_SENTINEL = '0x0000000000000000000000000000000000000000';
|
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 type BridgeProvider = 'jumper' | 'relay' | 'nearintents';
|
||||||
|
|
||||||
export interface BridgeExecuteParams {
|
export interface BridgeExecuteParams {
|
||||||
@@ -448,8 +460,6 @@ async function executeSol(
|
|||||||
// ── App fee 0.7% (atomic — fee tx ПЕРЕД main bridge) ──
|
// ── App fee 0.7% (atomic — fee tx ПЕРЕД main bridge) ──
|
||||||
// Native SOL bridge → fee in native SOL. SPL bridge → fee in same SPL token.
|
// Native SOL bridge → fee in native SOL. SPL bridge → fee in same SPL token.
|
||||||
// Если fee = 0 (amount слишком мал) → throw (anti-bypass).
|
// Если fee = 0 (amount слишком мал) → throw (anti-bypass).
|
||||||
let feeTxid: string | undefined;
|
|
||||||
let feeAmount: string | undefined;
|
|
||||||
const feeAmountBig = computeAppFee(p.fromAmount);
|
const feeAmountBig = computeAppFee(p.fromAmount);
|
||||||
if (feeAmountBig <= 0n) {
|
if (feeAmountBig <= 0n) {
|
||||||
throw new Error('SOL bridge: fromAmount too small — fee = 0');
|
throw new Error('SOL bridge: fromAmount too small — fee = 0');
|
||||||
@@ -466,8 +476,8 @@ async function executeSol(
|
|||||||
amount: feeAmountBig.toString(),
|
amount: feeAmountBig.toString(),
|
||||||
token: feeSymbol,
|
token: feeSymbol,
|
||||||
});
|
});
|
||||||
feeTxid = feeRes.txid;
|
const feeTxid = feeRes.txid;
|
||||||
feeAmount = feeAmountBig.toString();
|
const feeAmount = feeAmountBig.toString();
|
||||||
logger.info(`SOL bridge fee broadcast: ${feeAmount} ${feeSymbol || 'lamports'} → ${APP_FEE_WALLET_SOL} (txid ${feeTxid})`);
|
logger.info(`SOL bridge fee broadcast: ${feeAmount} ${feeSymbol || 'lamports'} → ${APP_FEE_WALLET_SOL} (txid ${feeTxid})`);
|
||||||
|
|
||||||
// Для SOL LiFi возвращает в `transactionRequest.data` = base64-encoded VersionedTransaction.
|
// Для SOL LiFi возвращает в `transactionRequest.data` = base64-encoded VersionedTransaction.
|
||||||
@@ -708,40 +718,161 @@ void readTrc20Allowance;
|
|||||||
|
|
||||||
// ─── BTC execute ──────────────────────────────────────────────────────
|
// ─── BTC execute ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BTC bridge: NearIntents 1Click (primary, как TRX). Relay deposit — fallback если
|
||||||
|
* provider=relay и в upstream quote есть depositAddress.
|
||||||
|
*/
|
||||||
async function executeBtc(
|
async function executeBtc(
|
||||||
p: BridgeExecuteParams,
|
p: BridgeExecuteParams,
|
||||||
quote: NormalizedQuote,
|
quote: NormalizedQuote,
|
||||||
): Promise<BridgeExecuteResult> {
|
): Promise<BridgeExecuteResult> {
|
||||||
// Для BTC source через Relay: quote.steps[0] = deposit step с {data.depositAddress, ...}.
|
const needed = BigInt(p.fromAmount);
|
||||||
// Relay просит юзера отправить BTC tx на их deposit address; solver видит UTXO в mempool
|
|
||||||
// → доставляет destination asset.
|
// Relay fallback (legacy path)
|
||||||
let depositAddress: string | undefined;
|
|
||||||
let amountSat: bigint | undefined;
|
|
||||||
if (p.provider === 'relay' && Array.isArray(quote.steps)) {
|
if (p.provider === 'relay' && Array.isArray(quote.steps)) {
|
||||||
const depositStep = quote.steps.find((s) => s.id === 'deposit' || s.id === 'tx');
|
const depositStep = quote.steps.find((s) => s.id === 'deposit' || s.id === 'tx');
|
||||||
if (depositStep) {
|
const depositAddress = depositStep?.data?.depositAddress || depositStep?.data?.to;
|
||||||
depositAddress = depositStep.data?.depositAddress || depositStep.data?.to;
|
if (depositAddress) {
|
||||||
const amt = depositStep.data?.amount || depositStep.data?.value;
|
const amountSat = depositStep?.data?.amount || depositStep?.data?.value
|
||||||
if (amt) amountSat = BigInt(amt);
|
? 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) ──
|
const destCode = CHAINID_TO_CHAIN[p.toChain];
|
||||||
// Минимум для tx: amount + fee (≥ ~500 sat для 1-input 2-output P2WPKH).
|
if (!destCode) {
|
||||||
// Точный fee рассчитается в signAndBroadcastBtcDeposit; здесь делаем conservative
|
throw new Error(`NearIntents (BTC): destination chainId ${p.toChain} not in our chain map`);
|
||||||
// нижнюю границу 1000 sat для anti-dust reject.
|
}
|
||||||
|
|
||||||
|
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 btcBal = await readBtcConfirmedBalance(p.expectedFromAddress);
|
||||||
const BTC_FEE_RESERVE_SAT = 1000n;
|
const totalNeeded = needed + feeAmountBig + BTC_NETWORK_FEE_FLOOR_SAT;
|
||||||
const totalNeeded = amountSat + BTC_FEE_RESERVE_SAT;
|
|
||||||
if (btcBal < totalNeeded) {
|
if (btcBal < totalNeeded) {
|
||||||
throw new InsufficientBalanceError(
|
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.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,50 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { fetchVaultKV2 } from '../config/vault';
|
import { fetchVaultKV2 } from '../config/vault';
|
||||||
|
import { env } from '../config/env';
|
||||||
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSRF token validation compatible with Python's `itsdangerous`
|
* CSRF validation compatible with Python `itsdangerous` URLSafeTimedSerializer (Flask-WTF).
|
||||||
* `URLSafeTimedSerializer` (which Flask-WTF uses).
|
* 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):
|
* itsdangerous 2.x по умолчанию: key_derivation=django-concat, digest=sha1.
|
||||||
* - digest: SHA-512 (HMAC)
|
* Старый Node-код использовал legacy-hmac-signer — из-за этого prod 403 при верном secret_key.
|
||||||
* - 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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 {
|
export interface CsrfConfig {
|
||||||
secret: string;
|
secret: string;
|
||||||
salt: string;
|
salt: string;
|
||||||
digest: 'sha256' | 'sha512';
|
digest: 'sha256' | 'sha512';
|
||||||
maxAgeSec: number;
|
maxAgeSec: number;
|
||||||
|
saltFromVault: boolean;
|
||||||
|
digestFromVault: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live config — атомарно подменяется через swapCsrfConfig()
|
|
||||||
let current: CsrfConfig | null = null;
|
let current: CsrfConfig | null = null;
|
||||||
|
|
||||||
export function swapCsrfConfig(cfg: CsrfConfig | null): void {
|
export function swapCsrfConfig(cfg: CsrfConfig | null): void {
|
||||||
@@ -36,9 +55,51 @@ export function isCsrfConfigured(): boolean {
|
|||||||
return current !== null;
|
return current !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function csrfSecretFingerprint(secret: string): string {
|
||||||
* Pre-fetch CSRF config из Vault — НЕ мутирует глобал, возвращает новый объект.
|
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(
|
export async function fetchCsrfConfig(
|
||||||
addr: string,
|
addr: string,
|
||||||
token: string,
|
token: string,
|
||||||
@@ -56,24 +117,27 @@ export async function fetchCsrfConfig(
|
|||||||
throw new Error('CSRF secret invalid: must be string >= 32 chars');
|
throw new Error('CSRF secret invalid: must be string >= 32 chars');
|
||||||
}
|
}
|
||||||
|
|
||||||
const salt = secrets.salt || 'itsdangerous.Signer';
|
let saltFromVault = false;
|
||||||
if (typeof salt !== 'string' || salt.length < 8) {
|
let salt = DEFAULT_SALT;
|
||||||
throw new Error('CSRF salt invalid: must be string >= 8 chars');
|
if (secrets.salt && typeof secrets.salt === 'string' && secrets.salt.length >= 1) {
|
||||||
|
salt = secrets.salt;
|
||||||
|
saltFromVault = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// sha1 deprecated — accept только sha256/sha512.
|
let digestFromVault = false;
|
||||||
let digest: 'sha256' | 'sha512' = 'sha512';
|
let digest: 'sha256' | 'sha512' = 'sha256';
|
||||||
if (secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
if (secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
||||||
digest = secrets.digest;
|
digest = secrets.digest;
|
||||||
|
digestFromVault = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxAgeSec = 60 * 60 * 24 * 7; // 7 days
|
let maxAgeSec = 60 * 60 * 24 * 7;
|
||||||
if (secrets.max_age_sec) {
|
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;
|
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 {
|
function b64urlDecode(s: string): Buffer {
|
||||||
@@ -82,26 +146,137 @@ function b64urlDecode(s: string): Buffer {
|
|||||||
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
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();
|
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);
|
const raw = b64urlDecode(encoded);
|
||||||
// Использовать арифметику вместо bitwise — bitwise overflowит на 32-bit signed
|
|
||||||
// после 2038 если timestamp encoding станет 5-байтным.
|
|
||||||
let ts = 0;
|
let ts = 0;
|
||||||
for (const b of raw) ts = ts * 256 + b;
|
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 {
|
export interface CsrfVerifyResult {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
actualSigLen?: number;
|
||||||
|
expectedSigLen?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
type CsrfDigest = 'sha1' | 'sha256' | 'sha512';
|
||||||
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
|
||||||
|
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' };
|
if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' };
|
||||||
|
|
||||||
const lastDot = token.lastIndexOf('.');
|
const lastDot = token.lastIndexOf('.');
|
||||||
@@ -115,8 +290,8 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
|||||||
|
|
||||||
const tsStr = payloadTs.slice(prevDot + 1);
|
const tsStr = payloadTs.slice(prevDot + 1);
|
||||||
|
|
||||||
const derived = deriveKey(current.secret, current.salt, current.digest);
|
const derived = deriveSigningKey(cfg.secret, salt, digest, keyDerivation);
|
||||||
const expectedSig = crypto.createHmac(current.digest, derived).update(payloadTs).digest();
|
const expectedSig = crypto.createHmac(digest, derived).update(payloadTs).digest();
|
||||||
|
|
||||||
let actualSig: Buffer;
|
let actualSig: Buffer;
|
||||||
try {
|
try {
|
||||||
@@ -126,20 +301,122 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (expectedSig.length !== actualSig.length) {
|
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)) {
|
if (!crypto.timingSafeEqual(expectedSig, actualSig)) {
|
||||||
return { valid: false, reason: 'Signature mismatch' };
|
return { valid: false, reason: 'Signature mismatch' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const tsResult = verifyCsrfTimestamp(tsStr, cfg.maxAgeSec);
|
||||||
const issuedAt = decodeTimestamp(tsStr);
|
if (!tsResult.ok) {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
return { valid: false, reason: tsResult.reason };
|
||||||
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' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { valid: true };
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
import { vaultAppRoleLogin } from '../config/vault';
|
import { vaultAppRoleLogin } from '../config/vault';
|
||||||
import { fetchJwtKeysFromVault, swapKeyMap, getKeyMapSize } from './jwt.service';
|
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 { fetchMasterKey, swapMasterKey, masterKeyMatches, isCryptoReady } from './crypto.service';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
@@ -83,6 +83,7 @@ async function doRefresh(): Promise<RefreshResult> {
|
|||||||
swapKeyMap(jwtResult.value);
|
swapKeyMap(jwtResult.value);
|
||||||
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
|
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
|
||||||
swapCsrfConfig(csrfResult.value);
|
swapCsrfConfig(csrfResult.value);
|
||||||
|
logCsrfConfigLoaded();
|
||||||
}
|
}
|
||||||
if (cryptoResult.status === 'fulfilled' && cryptoResult.value) {
|
if (cryptoResult.status === 'fulfilled' && cryptoResult.value) {
|
||||||
if (!isCryptoReady()) {
|
if (!isCryptoReady()) {
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ const TRX_BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrs
|
|||||||
// FeeSwapRouter работает с supporting-fee variant — это уже доказано в production traffic.
|
// FeeSwapRouter работает с supporting-fee variant — это уже доказано в production traffic.
|
||||||
const SEL_APPROVE = '095ea7b3';
|
const SEL_APPROVE = '095ea7b3';
|
||||||
const SEL_SWAP_EXACT_ETH_FOR_TOKENS = 'b6f9de95';
|
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_NATIVE_WITH_FEE = '152dad1d';
|
||||||
const SEL_SWAP_TOKEN_WITH_FEE = 'e8d1f203';
|
const SEL_SWAP_TOKEN_WITH_FEE = 'e8d1f203';
|
||||||
|
|
||||||
@@ -617,8 +617,8 @@ function buildSwapExactETHForTokensCalldata(
|
|||||||
encAddr(to) + encU256(deadline) + pathLen + pathElements;
|
encAddr(to) + encU256(deadline) + pathLen + pathElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
// function swapExactTokensForETH(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline)
|
// function swapExactTokensForETHSupportingFeeOnTransferTokens(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline)
|
||||||
function buildSwapExactTokensForETHCalldata(
|
function buildSwapExactTokensForETHSupportingFeeCalldata(
|
||||||
amountIn: bigint,
|
amountIn: bigint,
|
||||||
amountOutMin: bigint,
|
amountOutMin: bigint,
|
||||||
path: string[],
|
path: string[],
|
||||||
@@ -628,7 +628,7 @@ function buildSwapExactTokensForETHCalldata(
|
|||||||
const offsetToPath = encU256(160n); // 5 × 32 bytes
|
const offsetToPath = encU256(160n); // 5 × 32 bytes
|
||||||
const pathLen = encU256(BigInt(path.length));
|
const pathLen = encU256(BigInt(path.length));
|
||||||
const pathElements = path.map(encAddr).join('');
|
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;
|
offsetToPath + encAddr(to) + encU256(deadline) + pathLen + pathElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,7 +936,7 @@ export async function quoteTrx(p: QuoteTrxParams): Promise<SwapQuoteRaw> {
|
|||||||
* Поддерживает только TRX↔USDT.
|
* Поддерживает только TRX↔USDT.
|
||||||
*
|
*
|
||||||
* TRX → USDT (1 tx): swapNativeWithFee wraps swapExactETHForTokens.
|
* 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.
|
* Slippage: если `lockedMinOut` задан → используется. Иначе re-quote on-chain.
|
||||||
*/
|
*/
|
||||||
@@ -1059,7 +1059,7 @@ export async function executeTrx(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: build swapTokenWithFee(USDT, amount, calldata).
|
// Step 2: build swapTokenWithFee(USDT, amount, calldata).
|
||||||
const sunswapCalldata = buildSwapExactTokensForETHCalldata(
|
const sunswapCalldata = buildSwapExactTokensForETHSupportingFeeCalldata(
|
||||||
swapAmount, amountOutMin, path, fromTronAddr, deadline,
|
swapAmount, amountOutMin, path, fromTronAddr, deadline,
|
||||||
);
|
);
|
||||||
const tokenInEnc = encAddr(USDT_CONTRACT);
|
const tokenInEnc = encAddr(USDT_CONTRACT);
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ export async function signAndBroadcastRawTron(p: SignRawTronParams): Promise<{ t
|
|||||||
// Если у нас неизвестный selector (LiFi bridge calls, custom routers) — мы НЕ можем
|
// Если у нас неизвестный selector (LiFi bridge calls, custom routers) — мы НЕ можем
|
||||||
// передать "0x..." как function_selector (TronGrid keccak'нёт строку и получит
|
// передать "0x..." как function_selector (TronGrid keccak'нёт строку и получит
|
||||||
// полностью другие 4 байта → contract revert). Используем `data` напрямую.
|
// полностью другие 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)');
|
if (data.length < 8) throw new Error('TRX call data too short (need >= 4-byte selector)');
|
||||||
const selector8 = data.slice(0, 8);
|
const selector8 = data.slice(0, 8);
|
||||||
const knownCanonical = lookupKnownSelector(selector8);
|
const knownCanonical = lookupKnownSelector(selector8);
|
||||||
@@ -294,7 +294,12 @@ export async function signAndBroadcastRawTron(p: SignRawTronParams): Promise<{ t
|
|||||||
if (!simOk) {
|
if (!simOk) {
|
||||||
const rawMsg = simRes?.result?.message;
|
const rawMsg = simRes?.result?.message;
|
||||||
const msgDecoded = rawMsg
|
const msgDecoded = rawMsg
|
||||||
? Buffer.from(rawMsg, 'hex').toString().replace(/[ | |||||||