Compare commits

..

28 Commits

Author SHA1 Message Date
ZOMBIIIIIII
399322973e initikghuiu 2026-05-29 13:34:29 +03:00
ZOMBIIIIIII
1b3fc444fc initodwehjowecfuihe 2026-05-29 01:09:06 +03:00
ZOMBIIIIIII
860a22eb4a initluyhgulednj 2026-05-29 01:03:03 +03:00
ZOMBIIIIIII
77a0f3d107 initluyhguliohw3eufuer 2026-05-29 00:45:33 +03:00
ZOMBIIIIIII
b3f61353b3 init 2026-05-29 00:30:27 +03:00
ZOMBIIIIIII
336f0577ab initluyhgul 2026-05-29 00:07:43 +03:00
ZOMBIIIIIII
b2ab5f0421 inithilyhb 2026-05-28 23:48:09 +03:00
ZOMBIIIIIII
31aba0b681 initjirefr 2026-05-28 23:29:18 +03:00
ZOMBIIIIIII
4c00c6ca1b initrftsebfvgyhloutersvbhustdr 2026-05-28 22:40:36 +03:00
ZOMBIIIIIII
444030e424 init449494 2026-05-28 22:02:37 +03:00
ZOMBIIIIIII
15af7174c6 init339398989 2026-05-28 15:40:41 +03:00
ZOMBIIIIIII
1f209a8fec init33939 2026-05-28 14:38:57 +03:00
ZOMBIIIIIII
c2a71395fd init 2026-05-28 14:38:01 +03:00
ZOMBIIIIIII
179e05b1e8 init222228 2026-05-28 14:15:37 +03:00
ZOMBIIIIIII
a636bd573a init2121212 2026-05-28 14:01:10 +03:00
ZOMBIIIIIII
e86ff7c063 init 2026-05-28 13:51:30 +03:00
ZOMBIIIIIII
d2086b86e3 initerikfbfvi 2026-05-14 23:54:17 +03:00
ZOMBIIIIIII
079e271cc0 initfmfijirfri 2026-05-14 21:40:36 +03:00
ZOMBIIIIIII
22059373a4 initjnjnj 2026-05-14 19:52:56 +03:00
ZOMBIIIIIII
5898a6c1e2 efeidjeie 2026-05-14 18:01:09 +03:00
ZOMBIIIIIII
f6774243b2 initvglidrbtgrthijl; 2026-05-14 16:39:56 +03:00
ZOMBIIIIIII
11ee5a2c7f initlast 2026-05-14 15:20:00 +03:00
ZOMBIIIIIII
e88ee3a55f swagger2 2026-05-14 14:41:03 +03:00
ZOMBIIIIIII
53635806d6 swaggerready 2026-05-14 01:11:20 +03:00
ZOMBIIIIIII
0661fffb88 init383838 2026-05-13 23:59:32 +03:00
ZOMBIIIIIII
9fe5311bbf init2222 2026-05-13 12:35:05 +03:00
ZOMBIIIIIII
762a46871b init2222 2026-05-13 12:07:48 +03:00
ZOMBIIIIIII
3a890b79ee initkkk 2026-05-13 00:37:00 +03:00
49 changed files with 12273 additions and 1750 deletions

View File

@@ -27,6 +27,13 @@ JWT_AUDIENCE=elcsa
API_PORT=3001 API_PORT=3001
LOG_LEVEL=INFO LOG_LEVEL=INFO
# ── KeyDB / Redis (idempotency cache) ──────────────────────────────
# REDIS_PASSWORD also used by docker-compose to seed KeyDB --requirepass.
REDIS_HOST=keydb
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# ── CORS ──────────────────────────────────────────────────────────── # ── CORS ────────────────────────────────────────────────────────────
# Comma-separated list of allowed origins. ПУСТО = no cross-origin. # Comma-separated list of allowed origins. ПУСТО = no cross-origin.
# Никогда не используй wildcard * # Никогда не используй wildcard *
@@ -44,6 +51,13 @@ JUPITER_FEE_BPS=70
ETHERSCAN_API_KEY= ETHERSCAN_API_KEY=
BSCSCAN_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=
# ── DB fallback (если Vault недоступен при старте) ───────────────── # ── DB fallback (если Vault недоступен при старте) ─────────────────
DB_HOST= DB_HOST=
DB_PORT=5432 DB_PORT=5432

3
.gitignore vendored
View File

@@ -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

View File

@@ -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

114
README.md
View File

@@ -1,114 +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 (миграция идемпотентна — безопасно прогонять на existing БД)
psql -h <db-host> -U postgres_user -d postgres -f cryptowallet-schema.sql
# 4. bitok public key в Vault (для kid из JWT header)
vault kv put dev-secrets/jwt/kid active=<kid-from-bitok>
vault kv put dev-secrets/jwt/kids/<kid-from-bitok> \
algorithm=RS256 \
public_key="$(cat /path/to/bitok-public.pem)"
```
⚠️ **Master-key менять нельзя** — все existing `encrypted_mnemonic` станут нерасшифровываемыми. API на старте делает self-test: пытается декриптить любой существующий mnemonic и фейлится если ключ не подошёл.
## Deploy
```bash
# Залить bundle на сервер
scp -P 2222 -r deployserver/ server@<host>:~/cryptowallet/
# На сервере: заполнить .env, поднять
ssh server@<host> -p 2222
cd ~/cryptowallet
cp .env.example .env
chmod 600 .env
nano .env # заполни VAULT_*, JWT_*, CORS_ORIGINS
./start.sh
```
В `.env` **обязательны**: `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`, `JWT_ISSUER=bitok`, `JWT_AUDIENCE`, `CORS_ORIGINS`.
## Update / Rebuild
```bash
scp -P 2222 -r deployserver/apps server@<host>:~/cryptowallet/
ssh server@<host> -p 2222 'cd cryptowallet && docker compose up -d --build'
```
## Endpoints
| Method | Path | Описание |
|---|---|---|
| GET | `/api/health` | Liveness (public) |
| GET | `/api/docs` | Swagger UI |
| GET | `/api/docs/swagger.json` | OpenAPI JSON |
| POST | **`/api/wallets/create`** | Сервер создаёт коша (no body, returns 5 addresses) |
| GET | `/api/wallets` | Список адресов юзера |
| POST | **`/api/wallets/mnemonic/reveal`** | Reveal seed (body confirm + 5/час rate-limit) |
| GET | `/api/wallets/{chain}/balance` | Баланс (native + все известные токены чейна) |
| GET | `/api/wallets/{chain}/transactions` | История tx |
| POST | **`/api/wallets/{chain}/send`** | Сервер подписывает + broadcast. Body: `{to, amount, token?, feeTier?}` |
| GET | **`/api/wallets/{chain}/gas-suggestions`** | Slow/normal/fast tiers (ETH/BSC, parsed из eth_feeHistory) |
| POST | **`/api/wallets/{chain}/sign-raw-evm-tx`** | Подписать произвольную EVM tx (для Relay/Swap execute responses) |
| — | `/api/btc/*` `/api/tron/*` `/api/sol/*` `/api/bsc/*` `/api/relay/*` | Proxy endpoints (swap quote/build, bridge quote/execute/status) |
## Security highlights
- **AES-256-GCM** для encrypted_mnemonic (12-byte random IV, 16-byte auth tag, fail-secure)
- **Master-key set-once** (rotation запрещена в коде)
- **Crypto self-test на старте** — fail-fast если master-key не декриптит existing mnemonics
- **Race-safe createWallet** — `db.transaction` + `UPDATE WHERE encrypted_mnemonic IS NULL` (set-once primitive)
- **Atomic erc20 update** — ETH-адрес кладётся в `users.erc20` внутри той же транзакции
- **TRX MITM defense** — local recompute txID + 4-layer raw_data verification перед подписью
- **EVM gas cap** 500 gwei (применён к tx, не только check)
- **EVM gas oracle** через `eth_feeHistory` p25/p50/p75 — minimum-but-works fees (BSC floor 0.05, ETH 0.5 gwei)
- **BTC fee** tier-based (slow=144 blocks, normal=6, fast=1) + floor 2 sat/vB
- **TRX fee_limit** cap 30 TRX (раньше 100, излишне)
- **Address checksum validation** (BTC bitcoinjs-lib, TRX bs58check, SOL PublicKey, EVM EIP-55)
- **assertAddressMatch** — derived(mnemonic, path) === DB.address перед подписью
- **SOL confirmTransaction** — ждём подтверждения сети
- **BTC** P2WPKH bech32, dust 294, broadcast 20s timeout
- **POST mnemonic/reveal** + CSRF + body confirm token + 5/час rate-limit + audit-log
- **Logger sanitization** — password/token/mnemonic/hex64/BIP39-phrase patterns маскируются
- **Audit log в stdout** (структурированный JSON с `"level":"audit"`) — `wallet.create` / `wallet.send` / `mnemonic.reveal` / `wallet.sign_raw_evm`
- **Hourly key rotation** — JWT keys + CSRF secret из Vault (master-key НЕ ротируется)
- **Fail-fast** — сервис не стартует без master-key, JWT_ISSUER, JWT_AUDIENCE
- **Container hardening** — read-only fs, cap_drop ALL, no-new-privileges, pids/mem/cpu limits, non-root uid 1001, loopback-only port
- **Relay proxy** whitelist method+path — `/quote` (POST), `/intents/status/v3` (GET), `/execute/{swap|bridge}` (POST). Никаких freeform action'ов
- **Bridge UI dropdown** ETH/BSC/SOL only (TRX через Relay убран — плохая ликвидность)
## Logs
Файловых логов **нет**. Всё в stdout, подбирается Docker log driver:
```bash
docker compose logs -f api # все логи (structured JSON)
docker compose logs api | grep '"level":"audit"' # только audit events
docker compose logs api | grep '"level":"ERROR"' # только ошибки
```
## Production hardening checklist (опционально)
- [ ] Vault server-mode (raft/file backend) с unseal flow
- [ ] TLS termination на reverse-proxy (Caddy / Nginx) перед `127.0.0.1:3001`
- [ ] Swagger UI скрыть за basic-auth (endpoints всё ещё доступны через `/api/docs/swagger.json`)
- [ ] Postgres backups (pg_dump → S3 по cron)
- [ ] Vault root token ротация
- [ ] Mnemonic-reveal endpoint — 2FA / time-based confirmation tokens
- [ ] Rate-limit tune под реальный трафик

37
apps/api/Dockerfile Normal file
View File

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

View File

@@ -10,6 +10,7 @@
"lint": "eslint src/ --ext .ts" "lint": "eslint src/ --ext .ts"
}, },
"dependencies": { "dependencies": {
"@solana/spl-token": "^0.4.14",
"@solana/web3.js": "^1.98.4", "@solana/web3.js": "^1.98.4",
"bip32": "^4.0.0", "bip32": "^4.0.0",
"bip39": "^3.1.0", "bip39": "^3.1.0",
@@ -23,12 +24,14 @@
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"ioredis": "^5.4.0",
"jose": "^6.2.2", "jose": "^6.2.2",
"knex": "^3.1.0", "knex": "^3.1.0",
"pg": "^8.13.0", "pg": "^8.13.0",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"tiny-secp256k1": "^2.2.3", "tiny-secp256k1": "^2.2.3",
"ulidx": "^2.4.1" "ulidx": "^2.4.1",
"undici": "^6.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/cookie-parser": "^1.4.7", "@types/cookie-parser": "^1.4.7",

View File

@@ -10,14 +10,14 @@ import { authMiddleware } from './middleware/auth';
import { csrfMiddleware } from './middleware/csrf'; import { csrfMiddleware } from './middleware/csrf';
import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit'; import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit';
import { errorHandler } from './middleware/error-handler'; import { errorHandler } from './middleware/error-handler';
import { WalletController } from './controllers/wallet.controller';
import walletRoutes from './routes/wallet.routes'; import walletRoutes from './routes/wallet.routes';
import relayProxyRoutes from './routes/relay-proxy.routes'; import relayProxyRoutes from './routes/relay-proxy.routes';
import jumperProxyRoutes from './routes/jumper-proxy.routes';
import tronProxyRoutes from './routes/tron-proxy.routes'; import tronProxyRoutes from './routes/tron-proxy.routes';
import solSwapProxyRoutes from './routes/sol-swap-proxy.routes';
import tronSwapProxyRoutes from './routes/tron-swap-proxy.routes';
import btcProxyRoutes from './routes/btc-proxy.routes'; import btcProxyRoutes from './routes/btc-proxy.routes';
import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes'; import pricesRoutes from './routes/prices.routes';
import tokensRoutes from './routes/tokens.routes';
import bridgeRoutes from './routes/bridge.routes';
const app = express(); const app = express();
@@ -25,10 +25,23 @@ const app = express();
app.set('trust proxy', 1); app.set('trust proxy', 1);
app.use(helmet()); app.use(helmet());
// CORS — поддерживаем 3 режима:
// 1. wildcard ['*'] — любой origin (для dev/staging); credentials force=false (browser spec)
// 2. whitelist [a, b, c] — только эти origins
// 3. пустой массив — все cross-origin blocked (fail-secure default)
const corsOrigins = env.cors.origins;
const corsIsWildcard = corsOrigins.length === 1 && corsOrigins[0] === '*';
if (corsIsWildcard) {
// eslint-disable-next-line no-console
console.warn('[CORS] WILDCARD enabled (CORS_ORIGINS=*) — any origin can call API. Use only for dev/staging. Production: use explicit whitelist.');
}
app.use( app.use(
cors({ cors({
origin: env.cors.origins.length > 0 ? env.cors.origins : false, origin: corsIsWildcard ? '*' : (corsOrigins.length > 0 ? corsOrigins : false),
credentials: env.cors.allowCredentials, // Wildcard incompatible с credentials per browser spec — force false при wildcard.
credentials: corsIsWildcard ? false : env.cors.allowCredentials,
exposedHeaders: ['X-CSRF-Token', 'X-Trace-ID'],
}), }),
); );
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS
@@ -85,19 +98,31 @@ app.use('/api/docs', docsGate, swaggerUi.serve, swaggerUi.setup(swaggerSpec));
const protect = [authMiddleware, csrfMiddleware]; const protect = [authMiddleware, csrfMiddleware];
// Sensitive — самый строгий лимит. Каждый POST защищён JWT + CSRF. // Sensitive — самый строгий лимит. Каждый POST защищён JWT + CSRF.
app.use('/api/wallets/create', ...protect, sensitiveLimiter);
app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter); app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter);
app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter); app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter);
// Mutating (proxy + read endpoints) — повышенный лимит // Mutating (proxy + read endpoints) — повышенный лимит
app.post('/api/wallets/create', sensitiveLimiter, WalletController.createWallet);
app.get('/api/wallets', mutateLimiter, WalletController.getWallets);
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes); app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes); app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes);
// Jumper.xyz — LiFi-backed bridge aggregator (50+ chains: ETH/BSC/SOL/TRX/BTC + others).
// Используется когда Relay не поддерживает направление (TRX/BTC bridges).
app.use('/api/jumper', ...protect, mutateLimiter, jumperProxyRoutes);
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes); app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes);
app.use('/api/sol/swap', ...protect, mutateLimiter, solSwapProxyRoutes);
app.use('/api/tron/swap', ...protect, mutateLimiter, tronSwapProxyRoutes);
app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes); app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes);
app.use('/api/bsc/swap', ...protect, mutateLimiter, bscSwapProxyRoutes); // Legacy non-custodial swap proxies (/api/bsc/swap, /api/sol/swap, /api/tron/swap)
// УДАЛЕНЫ. Custodial 2-step swap живёт под /api/wallets/{chain}/swap{,/quote}.
// USD-цены (CoinGecko + KeyDB cache). GET-only, auth required, max 50 symbols.
app.use('/api/prices', ...protect, mutateLimiter, pricesRoutes);
// Token registry — всех известных contracts/mints по всем chain'ам. GET-only, auth required.
app.use('/api/tokens', ...protect, mutateLimiter, tokensRoutes);
// Bridge execute — one-click "Подтвердить" для bridge через Jumper (LiFi) / Relay.
// Dispatcher по source chain: EVM (approve+fee+bridge) / SOL (versioned tx) / TRX (TRC20 approve+bridge) / BTC (PSBT deposit).
// Sign + broadcast custodial через server (mnemonic не покидает API).
app.use('/api/bridge', ...protect, mutateLimiter, bridgeRoutes);
// 404 для всего что не сматчилось выше — единый JSON-ответ, не express default text // 404 для всего что не сматчилось выше — единый JSON-ответ, не express default text
app.use((_req, res) => { app.use((_req, res) => {

View File

@@ -34,25 +34,36 @@ export let env = {
cryptoKeyPath: p.VAULT_CRYPTO_KEY_PATH || 'crypto/master', cryptoKeyPath: p.VAULT_CRYPTO_KEY_PATH || 'crypto/master',
}, },
cors: { cors: {
// Каждый деплой явно указывает CORS_ORIGINS, иначе пустой массив = нет cross-origin. // CORS_ORIGINS:
// Validate каждый origin через URL parse — отклоняем мусор. Default https-only для prod-safety. // - comma-separated list of origins → whitelist (recommended for prod)
origins: (p.CORS_ORIGINS || '') // - "*" → wildcard, любой origin принят (для dev/staging)
.split(',') // - "" → cross-origin blocked (fail-secure default)
.map((o) => o.trim()) // Wildcard incompatible с CORS_ALLOW_CREDENTIALS=true (browser spec).
.filter(Boolean) origins: (() => {
.filter((o) => { const raw = (p.CORS_ORIGINS || '').split(',').map((o) => o.trim()).filter(Boolean);
// Wildcard sentinel — единственное значение `*` активирует wildcard mode.
if (raw.length === 1 && raw[0] === '*') return ['*'];
// Иначе строгая URL-валидация каждого origin'а.
return raw.filter((o) => {
try { try {
const u = new URL(o); const u = new URL(o);
return u.protocol === 'https:' || u.protocol === 'http:'; return u.protocol === 'https:' || u.protocol === 'http:';
} catch { } catch {
return false; return false;
} }
}), });
})(),
// Default = false (fail-secure). Чтобы включить credentials cross-origin — // Default = false (fail-secure). Чтобы включить credentials cross-origin —
// ОБЯЗАТЕЛЬНО явный CORS_ALLOW_CREDENTIALS=true. // ОБЯЗАТЕЛЬНО явный CORS_ALLOW_CREDENTIALS=true.
allowCredentials: p.CORS_ALLOW_CREDENTIALS === 'true', allowCredentials: p.CORS_ALLOW_CREDENTIALS === 'true',
}, },
port: parseInt(p.API_PORT || '3001'), port: parseInt(p.API_PORT || '3001'),
redis: {
host: p.REDIS_HOST || 'keydb',
port: parseInt(p.REDIS_PORT || '6379'),
password: p.REDIS_PASSWORD || '',
db: parseInt(p.REDIS_DB || '0'),
},
relayApiKey: p.RELAY_API_KEY || null, relayApiKey: p.RELAY_API_KEY || null,
tronApiKey: p.TRON_API_KEY || null, tronApiKey: p.TRON_API_KEY || null,
jupiterApiKey: p.JUPITER_API_KEY || null, jupiterApiKey: p.JUPITER_API_KEY || null,

View File

@@ -0,0 +1,86 @@
/**
* KeyDB / Redis singleton client.
*
* Используется для idempotency cache (см. `lib/idempotency.ts`).
*
* Connection:
* REDIS_HOST=keydb (docker service name) / REDIS_PORT=6379 / REDIS_PASSWORD / REDIS_DB=0
*
* Startup contract: `pingRedis()` вызывается из `index.ts` и throws если KeyDB
* unreachable — fail-fast, потому что idempotency critical для money flow.
*/
import Redis, { type RedisOptions } from 'ioredis';
import { logger } from '../lib/logger';
let _client: Redis | null = null;
function buildClient(): Redis {
const host = process.env.REDIS_HOST || 'keydb';
const port = parseInt(process.env.REDIS_PORT || '6379', 10);
const password = process.env.REDIS_PASSWORD || '';
const db = parseInt(process.env.REDIS_DB || '0', 10);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
throw new Error(`Invalid REDIS_PORT ${process.env.REDIS_PORT}`);
}
if (!Number.isFinite(db) || db < 0 || db > 15) {
throw new Error(`Invalid REDIS_DB ${process.env.REDIS_DB} (must be 0-15)`);
}
const opts: RedisOptions = {
host,
port,
db,
lazyConnect: true,
// Не зависать forever — fail-fast если cache недоступен
connectTimeout: 5000,
maxRetriesPerRequest: 3,
// Reconnect strategy: exponential backoff, max 5s
retryStrategy: (times) => Math.min(times * 200, 5000),
};
if (password) opts.password = password;
const client = new Redis(opts);
client.on('error', (err) => {
// Не логируем secret в случае конфигурационной ошибки
logger.error(`Redis client error: ${err.message}`);
});
client.on('connect', () => logger.info(`Redis connected (host=${host}:${port} db=${db})`));
client.on('reconnecting', (delay: number) => logger.warn(`Redis reconnecting in ${delay}ms`));
return client;
}
/** Lazily initialised singleton. */
export function getRedis(): Redis {
if (!_client) {
_client = buildClient();
}
return _client;
}
/**
* Startup ping. Throws on failure → caller process.exit(1).
* Connect-on-demand (lazyConnect=true), .ping() триггерит connect + первый round-trip.
*/
export async function pingRedis(): Promise<void> {
const client = getRedis();
try {
const pong = await client.ping();
if (pong !== 'PONG') {
throw new Error(`Redis PING returned ${pong} (expected PONG)`);
}
} catch (err: any) {
throw new Error(`Redis ping failed: ${err.message}`);
}
}
/** Graceful shutdown — closes connection cleanly. */
export async function closeRedis(): Promise<void> {
if (_client) {
await _client.quit().catch(() => _client?.disconnect());
_client = null;
}
}

View File

@@ -0,0 +1,222 @@
/**
* GET /api/prices — USD prices for selected token symbols.
*
* Security:
* S1 — whitelist через `getCoingeckoId`. Любой symbol вне registry → 400.
* S2 — лимит max 50 (symbol, chain) пар. Иначе → 400.
* S5 — общий 502 при failure, без stack trace.
* S7 — auth provided by router middleware.
*/
import { Request, Response } from 'express';
import { getCoingeckoId } from '../lib/token-registry';
import { ALL_CHAINS } from '../services/wallet-generator.service';
import { getPricesBySymbols } from '../services/price-oracle.service';
// getPricesWithChangeByIds импортируется dynamic'но в getDynamics handler ниже.
import type { ChainCode } from '../lib/address-validators';
import { logger } from '../lib/logger';
const MAX_SYMBOLS_PER_REQUEST = 50;
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
const SYMBOL_RE = /^[A-Z0-9]{1,16}$/;
function isChain(v: unknown): v is ChainCode {
return typeof v === 'string' && ALLOWED_CHAINS.has(v as ChainCode);
}
export const PricesController = {
/**
* GET /api/prices?symbols=BTC,ETH,USDT&chain=ETH
*
* Params:
* - symbols: comma-separated list, max 50. Каждый symbol должен быть в whitelist.
* - chain (опционально): chain для disambiguation (USDT на ETH vs USDT на BSC).
* Если не указан — для каждого symbol fallback порядок: ETH → BSC → SOL → TRX → BTC.
* Native symbol (BTC/ETH/...) всегда matches its chain.
*
* Response 200:
* { success: true, data: { "BTC": { "usd": 67432.12 }, "ETH": { "usd": 3210.45 }, "FOO": { "usd": null } } }
*/
async getPrices(req: Request, res: Response) {
try {
const rawSymbols = String(req.query.symbols || '').trim();
if (!rawSymbols) {
res.status(400).json({ success: false, error: 'symbols query param is required (csv)' });
return;
}
const requestedChain = req.query.chain ? String(req.query.chain).toUpperCase() : null;
if (requestedChain && !isChain(requestedChain)) {
res.status(400).json({ success: false, error: 'Invalid chain parameter' });
return;
}
const symbols = rawSymbols
.split(',')
.map((s) => s.trim().toUpperCase())
.filter((s) => s.length > 0);
if (symbols.length === 0) {
res.status(400).json({ success: false, error: 'symbols list is empty' });
return;
}
if (symbols.length > MAX_SYMBOLS_PER_REQUEST) {
res.status(400).json({
success: false,
error: `Too many symbols (max ${MAX_SYMBOLS_PER_REQUEST})`,
});
return;
}
// Strict symbol shape (S1 belt-and-suspenders).
for (const s of symbols) {
if (!SYMBOL_RE.test(s)) {
res.status(400).json({ success: false, error: `Invalid symbol: ${s}` });
return;
}
}
// Build (chain, symbol) pairs.
// Fallback resolution order при отсутствии явного chain:
// native symbol == chain code → that chain;
// иначе пробуем ETH, BSC, SOL, TRX, BTC по очереди.
const fallbackChains: ChainCode[] = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC'];
const pairs: { chain: ChainCode; symbol: string; key: string }[] = [];
for (const sym of symbols) {
if (requestedChain) {
pairs.push({ chain: requestedChain as ChainCode, symbol: sym, key: sym });
continue;
}
let resolvedChain: ChainCode | null = null;
if (ALLOWED_CHAINS.has(sym as ChainCode)) {
resolvedChain = sym as ChainCode;
} else {
for (const c of fallbackChains) {
if (getCoingeckoId(c, sym)) {
resolvedChain = c;
break;
}
}
}
if (!resolvedChain) {
// Symbol не находится ни в одной chain → 400 (S1: whitelist enforcement).
res.status(400).json({ success: false, error: `Unknown symbol: ${sym}` });
return;
}
pairs.push({ chain: resolvedChain, symbol: sym, key: sym });
}
// Если явный chain задан — повторная проверка whitelist для каждого symbol
// (native symbol для chain'а тоже разрешён).
if (requestedChain) {
for (const p of pairs) {
if (!getCoingeckoId(p.chain, p.symbol)) {
res.status(400).json({
success: false,
error: `Unknown symbol ${p.symbol} for chain ${p.chain}`,
});
return;
}
}
}
const prices = await getPricesBySymbols(
pairs.map((p) => ({ chain: p.chain, symbol: p.symbol })),
);
const data: Record<string, { usd: number | null }> = {};
for (const p of pairs) {
const lookupKey = `${p.chain}:${p.symbol}`;
data[p.key] = { usd: prices.get(lookupKey) ?? null };
}
res.json({ success: true, data });
} catch (err: any) {
logger.error(`getPrices failed: ${err?.stack || err?.message || 'unknown'}`);
res.status(502).json({ success: false, error: 'Upstream price oracle error' });
}
},
/**
* GET /api/prices/dynamics?symbols=BTC,ETH,BNB,SOL,TRX
*
* Возвращает USD-цену + 24h % изменения для списка symbols.
* Default symbols (если query не задан): BTC,ETH,BNB,SOL,TRX.
* Source: CoinGecko `include_24hr_change=true` (rolling 24h, не anchored).
*
* Response 200:
* { success: true, data: { "BTC": { "usd": 67432.12, "change24h": -1.38 }, ... } }
*/
async getDynamics(req: Request, res: Response) {
try {
const rawSymbols = String(req.query.symbols || '').trim();
const symbols = rawSymbols
? rawSymbols.split(',').map((s) => s.trim().toUpperCase()).filter((s) => s.length > 0)
: ['BTC', 'ETH', 'BNB', 'SOL', 'TRX'];
if (symbols.length === 0) {
res.status(400).json({ success: false, error: 'symbols list is empty' });
return;
}
if (symbols.length > MAX_SYMBOLS_PER_REQUEST) {
res.status(400).json({
success: false,
error: `Too many symbols (max ${MAX_SYMBOLS_PER_REQUEST})`,
});
return;
}
for (const s of symbols) {
if (!SYMBOL_RE.test(s)) {
res.status(400).json({ success: false, error: `Invalid symbol: ${s}` });
return;
}
}
// Resolve каждый symbol в CoinGecko id напрямую.
// Native tickers: BTC=bitcoin, ETH=ethereum, BNB=binancecoin, SOL=solana, TRX=tron.
// Для non-native: пытаемся getCoingeckoId через chain fallback.
const NATIVE_TICKER_TO_COINGECKO: Record<string, string> = {
BTC: 'bitcoin',
ETH: 'ethereum',
BNB: 'binancecoin',
SOL: 'solana',
TRX: 'tron',
};
const fallbackChains: ChainCode[] = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC'];
const symbolToCgId = new Map<string, string>();
for (const sym of symbols) {
let cgId: string | null = NATIVE_TICKER_TO_COINGECKO[sym] ?? null;
if (!cgId) {
for (const c of fallbackChains) {
const id = getCoingeckoId(c, sym);
if (id) { cgId = id; break; }
}
}
if (!cgId) {
res.status(400).json({ success: false, error: `Unknown symbol: ${sym}` });
return;
}
symbolToCgId.set(sym, cgId);
}
const { getPricesWithChangeByIds } = await import('../services/price-oracle.service');
const rich = await getPricesWithChangeByIds(Array.from(new Set(symbolToCgId.values())));
const data: Record<string, { usd: number | null; change24h: number | null }> = {};
for (const sym of symbols) {
const cgId = symbolToCgId.get(sym)!;
const v = rich[cgId];
data[sym] = {
usd: v?.usd ?? null,
change24h: v?.change24h ?? null,
};
}
res.json({ success: true, data });
} catch (err: any) {
logger.error(`getDynamics failed: ${err?.stack || err?.message || 'unknown'}`);
res.status(502).json({ success: false, error: 'Upstream price oracle error' });
}
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { env, initEnv } from './config/env';
import { refreshAllKeys, startKeyRotation, stopKeyRotation } from './services/key-rotation.service'; import { refreshAllKeys, startKeyRotation, stopKeyRotation } from './services/key-rotation.service';
import { isCryptoReady, decryptMnemonic, encryptMnemonic } from './services/crypto.service'; import { isCryptoReady, decryptMnemonic, encryptMnemonic } from './services/crypto.service';
import { db } from './config/database'; import { db } from './config/database';
import { pingRedis, closeRedis } from './config/redis';
import { logger } from './lib/logger'; import { logger } from './lib/logger';
// Global error handlers — иначе unhandled errors идут в stderr без sanitization (leak secrets) // Global error handlers — иначе unhandled errors идут в stderr без sanitization (leak secrets)
@@ -36,6 +37,15 @@ async function main() {
// и любой /send или /sign будет тихо валиться с GCM auth error. Лучше упасть сразу. // и любой /send или /sign будет тихо валиться с GCM auth error. Лучше упасть сразу.
await runCryptoIntegritySelfTest(); await runCryptoIntegritySelfTest();
// KeyDB / Redis ping — idempotency critical для money flow; fail-fast если недоступен.
try {
await pingRedis();
logger.info('KeyDB/Redis self-test: PASSED');
} catch (err: any) {
logger.error(`KeyDB/Redis ping failed: ${err.message}. Refusing to start (idempotency unavailable).`);
process.exit(1);
}
startKeyRotation(); startKeyRotation();
const server = app.listen(env.port, () => { const server = app.listen(env.port, () => {
@@ -45,6 +55,7 @@ async function main() {
const shutdown = (signal: string) => { const shutdown = (signal: string) => {
logger.info(`${signal} received, shutting down gracefully`); logger.info(`${signal} received, shutting down gracefully`);
stopKeyRotation(); stopKeyRotation();
void closeRedis();
server.close(() => process.exit(0)); server.close(() => process.exit(0));
// Force exit if shutdown takes too long // Force exit if shutdown takes too long
setTimeout(() => process.exit(1), 10_000).unref(); setTimeout(() => process.exit(1), 10_000).unref();

View File

@@ -0,0 +1,222 @@
/**
* Amount unit utilities.
*
* API контракт исторически — `amount: string` в smallest-units (wei/lamports/sun/satoshi).
* Этот файл добавляет ОПЦИОНАЛЬНЫЙ парсинг `amountHuman: "0.01"` через token decimals из
* `token-registry`. Старое поле `amount` остаётся 100% backward-compatible.
*
* Все вычисления — BigInt-based, без float'ов (для finance: precision critical).
*
* Используется в:
* - sendFromChain (body: {amount | amountHuman, token?})
* - quoteSwap / swapOnChain legacy (body: {from/inputMint, amount | amountHuman})
* - relay-proxy /quote preprocessing (body: {originCurrency, amount | amountHuman})
* - cost-estimate endpoints (body: same)
*/
import type { ChainCode } from './address-validators';
import {
getTokenInfo,
getEvmTokens,
TRX_TOKENS,
SOL_TOKENS,
} from './token-registry';
/**
* Native decimals per chain. Дублирует `NATIVE_DECIMALS` из `wallet-ops.service.ts`
* чтобы избежать circular dep (wallet-ops импортирует address-validators которое
* импортирует этот файл косвенно).
*/
export const NATIVE_DECIMALS: Record<ChainCode, number> = {
ETH: 18,
BSC: 18,
BTC: 8,
TRX: 6,
SOL: 9,
};
const SOL_WRAPPED_NATIVE_MINT = 'So11111111111111111111111111111111111111112';
/**
* Парсит "0.01" → "10000000" (для SOL 9 decimals) через BigInt.
*
* Правила:
* - "10" + dec=6 → "10000000" (integer, без точки)
* - "0.01" + dec=6 → "10000" (1 + 4 zeros)
* - "1.5" + dec=6 → "1500000"
* - "0.000001" + dec=6 → "1"
* - "0.1234567" + dec=6 → "123456" (truncate — не round; consistent с frontend parseAmount)
* - "0" + → throw (zero amount = error per existing isValidAmount)
* - "-1" / "1e3" + → throw
* - "0.1" + dec=0 → throw "no fractional digits for 0-decimal token"
*/
export function parseHumanAmount(human: string, decimals: number): string {
if (typeof human !== 'string') {
throw new Error('amountHuman must be a string');
}
const s = human.trim();
if (!s) throw new Error('amountHuman is empty');
// Defense-in-depth: длинная строка (например `"1" + "0".repeat(10000)`) форсирует
// O(n) парсинг + BigInt round-trip. 64KB body-limit (express.json) — общий gate;
// этот check — specific для нового парсера. Legit amount'ы укладываются в 80 chars
// (36-decimal fractional + integer + dot). Атакующий не сможет drain CPU.
if (s.length > 80) {
throw new Error('amountHuman too long (max 80 chars)');
}
if (!/^\d+(\.\d+)?$/.test(s)) {
throw new Error(`amountHuman invalid format "${s}" (expected "1" or "0.01")`);
}
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) {
throw new Error(`Invalid decimals ${decimals}`);
}
const [whole, frac = ''] = s.split('.');
if (decimals === 0 && frac.length > 0) {
throw new Error(`This token has 0 decimals, use integer amount (got "${s}")`);
}
// Truncate (not round) лишние цифры дробной части.
const fracTrunc = frac.slice(0, decimals);
const padded = fracTrunc.padEnd(decimals, '0');
// Strip leading zeros чтобы получился чистый BigInt-friendly string.
const result = (whole + padded).replace(/^0+/, '') || '0';
if (result === '0') {
throw new Error(`amountHuman "${s}" evaluates to 0 smallest units`);
}
return result;
}
/**
* Inverse of parseHumanAmount. "10000000" + dec=6 → "10".
* Используется для логирования / formatted output в cost-estimate.
*/
export function formatSmallestUnits(smallest: string, decimals: number): string {
if (typeof smallest !== 'string' || !/^\d+$/.test(smallest)) {
return '0';
}
if (decimals === 0) return smallest;
if (smallest.length <= decimals) {
const padded = smallest.padStart(decimals + 1, '0');
const whole = padded.slice(0, padded.length - decimals);
const frac = padded.slice(padded.length - decimals).replace(/0+$/, '');
return frac ? `${whole}.${frac}` : whole;
}
const whole = smallest.slice(0, smallest.length - decimals);
const frac = smallest.slice(smallest.length - decimals).replace(/0+$/, '');
return frac ? `${whole}.${frac}` : whole;
}
/**
* Decimals для send endpoint'а.
* - token задан → token-registry lookup (case-insensitive)
* - token пуст → native decimals
*/
export function resolveSendDecimals(chain: ChainCode, token?: string | null): number {
if (!token) return NATIVE_DECIMALS[chain];
const info = getTokenInfo(chain, token);
if (!info) {
throw new Error(`Unknown token "${token}" on ${chain} — cannot resolve decimals`);
}
return info.decimals;
}
/**
* Decimals для BSC/TRX swap (`from` symbol).
* - "BNB" / "TRX" → native (18 / 6)
* - "USDT" / etc → registry lookup
*/
export function resolveSwapDecimalsBscTrx(chain: 'BSC' | 'TRX', symbol: string): number {
const upper = symbol.toUpperCase();
if (upper === chain) return NATIVE_DECIMALS[chain]; // BSC→BNB через chain code не сработает,
// но BNB→18 и TRX→6 совпадают с NATIVE_DECIMALS, поэтому fallback ниже работает.
if (chain === 'BSC' && upper === 'BNB') return NATIVE_DECIMALS.BSC;
if (chain === 'TRX' && upper === 'TRX') return NATIVE_DECIMALS.TRX;
const info = getTokenInfo(chain, upper);
if (!info) {
throw new Error(`Unknown ${chain} token "${symbol}" — cannot resolve decimals`);
}
return info.decimals;
}
/**
* Decimals для SOL swap (`inputMint`).
* Wrapped SOL = 9; иначе SOL_TOKENS lookup по mint.
*/
export function resolveSwapDecimalsSol(mint: string): number {
if (mint === SOL_WRAPPED_NATIVE_MINT) return NATIVE_DECIMALS.SOL;
const t = SOL_TOKENS.find((x) => x.mint === mint);
if (!t) {
throw new Error(`Unknown SOL mint "${mint}" — cannot resolve decimals`);
}
return t.decimals;
}
/**
* Decimals по contract address (для Relay /quote где body содержит
* `originCurrency: "0x..."` вместо symbol). Returns null если не найден —
* caller решает: 400 vs fallback.
*/
export function getDecimalsByContract(chain: ChainCode, contractOrMint: string): number | null {
const addr = contractOrMint.trim();
if (!addr) return null;
// Native sentinels (Relay использует 0xeeee... для native EVM).
if (chain === 'SOL' && addr === SOL_WRAPPED_NATIVE_MINT) return NATIVE_DECIMALS.SOL;
if ((chain === 'ETH' || chain === 'BSC') &&
/^0xee+/i.test(addr) || addr === '0x0000000000000000000000000000000000000000') {
return NATIVE_DECIMALS[chain];
}
if (chain === 'TRX' && (addr === 'TRX' || addr === '0x0000000000000000000000000000000000000000')) {
return NATIVE_DECIMALS.TRX;
}
if (chain === 'ETH' || chain === 'BSC') {
const lower = addr.toLowerCase();
const t = getEvmTokens(chain).find((x) => x.contractAddress.toLowerCase() === lower);
return t?.decimals ?? null;
}
if (chain === 'TRX') {
const t = TRX_TOKENS.find((x) => x.contractAddress === addr);
return t?.decimals ?? null;
}
if (chain === 'SOL') {
const t = SOL_TOKENS.find((x) => x.mint === addr);
return t?.decimals ?? null;
}
return null;
}
/**
* Main dispatcher. Body содержит ровно ОДНО поле из {amount, amountHuman}.
* - оба пусты → throw
* - оба заданы → throw "use either … not both" (поведение из плана)
* - amount задан → возврат as-is (legacy backward-compat)
* - amountHuman задан → parseHumanAmount(value, decimals)
*
* Caller передаёт `decimals` (уже resolved через resolveSendDecimals / resolveSwapDecimals*).
*/
export function resolveAmountFromBody(
body: { amount?: unknown; amountHuman?: unknown },
decimals: number,
): string {
const hasAmount = body?.amount !== undefined && body?.amount !== null && body?.amount !== '';
const hasAmountHuman = body?.amountHuman !== undefined && body?.amountHuman !== null && body?.amountHuman !== '';
if (hasAmount && hasAmountHuman) {
throw new Error('Use either "amount" (smallest units) OR "amountHuman" (human form), not both');
}
if (!hasAmount && !hasAmountHuman) {
throw new Error('Either "amount" or "amountHuman" is required');
}
if (hasAmount) {
const a = String(body.amount);
if (!/^\d+$/.test(a) || BigInt(a) <= 0n) {
throw new Error('amount must be positive integer string (smallest units)');
}
return a;
}
// amountHuman path
return parseHumanAmount(String(body.amountHuman), decimals);
}

View File

@@ -0,0 +1,85 @@
/**
* App fee 0.7% — single source of truth для всех chains.
*
* Применяется в:
* - swap-orchestrator.service.ts:executeBsc/Sol/Trx (custodial swap, atomic fee tx ДО main swap)
* - bridge-execute.service.ts:executeEvm/Sol/Tron (bridge atomic fee)
* - controllers/wallet.controller.ts:signRawEvmTx (Relay EVM bridge, when client передаёт bridgeAmount)
* - controllers/wallet.controller.ts:appFeeTransfer (NEW endpoint /wallets/{chain}/app-fee для Relay frontend hook)
*
* Wallets захардкожены — НЕ через env. Security: нельзя переопределить через body или env,
* нельзя перенаправить fee на adversary'ский адрес. Single source of truth для аудита.
*
* Address per chain family:
* EVM (ETH+BSC) → 0xeb9f... (один EVM wallet, оба chain'а)
* SOL → DQkQ... (Solana base58)
* TRX → TRwp... (Tron base58)
* BTC → bc1q... (bech32 P2WPKH, отдельная fee tx перед bridge)
*
* Изменение wallet → требует code review + новый release.
*/
import type { ChainCode } from './address-validators';
/** EVM (ETH + BSC). Single address для обеих chain. Заменил старый BSC-only 0xeDEb... */
export const APP_FEE_WALLET_EVM = '0xeb9fbf0d137ef5ea7b9959044c2ed44ec1206c68';
/** Solana base58. */
export const APP_FEE_WALLET_SOL = 'DQkQegoX698XkcXZ6VX9P1qUpbV64Sgjz1BCPFgfWpjD';
/** Tron base58 (с T-prefix). */
export const APP_FEE_WALLET_TRX = 'TRwpFjnfMBe4aDJbHYEqeUVCG1auF8wFXP';
/** Bitcoin bech32 (P2WPKH). */
export const APP_FEE_WALLET_BTC = 'bc1qwzm9e4qun4tptecalq9zwxshdnwmvcxtz27znm';
/** 70 bps = 0.7%. Изменение требует code review. */
export const APP_FEE_BPS = 70n;
/** 10000 = 100% в bps notation. */
export const APP_FEE_DENOMINATOR = 10000n;
/**
* Resolve fee recipient для chain.
*/
export function getAppFeeWallet(chain: ChainCode): string {
if (chain === 'ETH' || chain === 'BSC') return APP_FEE_WALLET_EVM;
if (chain === 'SOL') return APP_FEE_WALLET_SOL;
if (chain === 'TRX') return APP_FEE_WALLET_TRX;
if (chain === 'BTC') return APP_FEE_WALLET_BTC;
throw new Error(`getAppFeeWallet: unsupported chain '${chain}'`);
}
/**
* Check если для chain есть fee wallet.
*/
export function hasAppFee(chain: ChainCode): boolean {
return chain === 'ETH' || chain === 'BSC' || chain === 'SOL' || chain === 'TRX' || chain === 'BTC';
}
/**
* computeAppFee(amountSmallest): возвращает 0.7% от amount в smallest units (BigInt).
*
* Использует BigInt — никаких float precision losses.
* Если amount < 144 (= 10000/70) → fee округляется до 0 (BigInt integer division).
* Callers должны skip fee tx если result = 0n.
*
* @param amountSmallest строка цифр (positive integer in smallest units)
* @returns BigInt fee amount
* @throws если amount не валидный positive integer string
*/
export function computeAppFee(amountSmallest: string): bigint {
if (typeof amountSmallest !== 'string' || !/^\d+$/.test(amountSmallest)) {
throw new Error(`computeAppFee: invalid amount "${amountSmallest}" (must be positive integer string)`);
}
return (BigInt(amountSmallest) * APP_FEE_BPS) / APP_FEE_DENOMINATOR;
}
/**
* computeAmountAfterFee(amount): возвращает (amount - fee) — то что реально пойдёт
* в swap/bridge после удержания 0.7%.
*/
export function computeAmountAfterFee(amountSmallest: string): bigint {
const fee = computeAppFee(amountSmallest);
return BigInt(amountSmallest) - fee;
}

View File

@@ -1,20 +1,24 @@
/** /**
* Audit log — durable durable durable. * Audit log — STDOUT ONLY (best-effort).
* *
* Two sinks: * ⚠️ DURABLE AUDIT REMOVED. Per design choice: `audit_log` DB-таблица убрана,
* 1. **DB `audit_log` table** — primary, used by `auditLogStrict` для critical * pre-mutation INSERT pattern → not used. Audit-trail доступен только в Docker
* операций. INSERT pending → mutation → UPDATE success/failure с txid. * stdout (`level=audit` JSON lines), который log-aggregator (Loki/CloudWatch/etc.)
* Если INSERT fails — operation must NOT proceed (fail-secure). * подбирает.
* 2. **stdout JSON line** — для log-aggregator (Docker logs / Loki etc).
* Best-effort, всегда (даже если DB sink fails).
* *
* НИКОГДА не логирует mnemonic / privkey / encrypted blob. * Trade-off: stdout не обеспечивает strict fail-secure семантику. Если Docker
* log driver buffer переполнится или log-aggregator down — записи могут потеряться.
* Если потребуется restore compliance-grade audit — вернуть `audit_log` table
* и pre-mutation INSERT/UPDATE pattern (см. git history).
*
* Public API сохраняет signatures из предыдущей DB-версии для backward compat
* без рефакторинга wallet.controller.ts callers:
* - `auditLog(entry)` — best-effort, returns void
* - `auditLogStrict(entry)` — now == auditLog + returns dummy ID для compat
* - `completeAudit(id, ...)` — теперь stdout-mirror update event
*/ */
import { ulid } from 'ulidx';
import { db } from '../config/database';
import { getTraceId } from './trace-store'; import { getTraceId } from './trace-store';
import { logger } from './logger';
export interface AuditEntry { export interface AuditEntry {
event: string; event: string;
@@ -25,7 +29,7 @@ export interface AuditEntry {
errorCode?: string; errorCode?: string;
} }
function buildStdoutLine(entry: AuditEntry, status: 'pending' | 'success' | 'failure'): string { function buildLine(entry: AuditEntry, status: string): string {
return JSON.stringify({ return JSON.stringify({
level: 'audit', level: 'audit',
status, status,
@@ -39,54 +43,36 @@ function writeStdoutBestEffort(line: string): void {
try { try {
process.stdout.write(line); process.stdout.write(line);
} catch { } catch {
// swallow // EPIPE / closed — swallow
} }
} }
/** /** Best-effort: stdout only. */
* Best-effort: stdout only. Используется для info-level событий
* (wallet.create success, lookup, etc). Не блокирует request на DB.
*/
export async function auditLog(entry: AuditEntry): Promise<void> { export async function auditLog(entry: AuditEntry): Promise<void> {
const status: 'success' | 'failure' = entry.result === 'failure' ? 'failure' : 'success'; const status: 'success' | 'failure' = entry.result === 'failure' ? 'failure' : 'success';
writeStdoutBestEffort(buildStdoutLine(entry, status)); writeStdoutBestEffort(buildLine(entry, status));
} }
/** /**
* Fail-secure audit для critical custodial операций (mnemonic.reveal, wallet.send, * Backward-compat shim. Раньше это был pre-mutation DB INSERT (fail-secure).
* wallet.sign_raw_evm). * Сейчас — просто stdout audit + возвращает opaque ID для совместимости с callers
* которые передают его в `completeAudit()`.
* *
* Семантика: INSERT row в `audit_log` table перед mutation. Если INSERT FAILS * Никогда не throws (раньше throw'ил при DB failure → caller отказывал в operation).
* (DB down, connection pool exhausted, constraint violation) — throws. * Returns timestamp-based ID; не reliable identifier, чисто для completeAudit pairing.
* Caller ОБЯЗАН abort'нуть mutation, не вернуть response с funds-action.
*
* Возвращает audit row id — caller использует его в `completeAudit()` после mutation.
*/ */
export async function auditLogStrict(entry: AuditEntry & { status?: 'pending' | 'success' | 'failure' }): Promise<string> { export async function auditLogStrict(entry: AuditEntry & { status?: string }): Promise<string> {
const id = ulid();
const status = entry.status ?? 'pending'; const status = entry.status ?? 'pending';
writeStdoutBestEffort(buildLine(entry, status));
// DB INSERT — fail-secure (throws on DB failure) // Opaque ID: timestamp-ms + random suffix. Не store'им — только для symmetry call-site.
await db('audit_log').insert({ return `audit-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
id,
user_id: entry.userId,
event: entry.event,
status,
error_code: entry.errorCode ?? null,
ip: entry.ip ?? null,
trace_id: getTraceId() ?? null,
meta: entry.meta ? JSON.stringify(entry.meta) : null,
});
// Mirror to stdout (best-effort, не критично)
writeStdoutBestEffort(buildStdoutLine(entry, status));
return id;
} }
/** /**
* Update audit row после mutation (success или failure с txid/error). * Backward-compat: завершающий event audit. Раньше — DB UPDATE row.
* Best-effort — если update fails, операция уже произошла, мы just log warning. * Сейчас — просто stdout write parallel event.
*
* `auditId` параметр игнорируется (его не было где writer'у искать в БД).
*/ */
export async function completeAudit( export async function completeAudit(
auditId: string, auditId: string,
@@ -94,21 +80,10 @@ export async function completeAudit(
meta?: Record<string, unknown>, meta?: Record<string, unknown>,
errorCode?: string, errorCode?: string,
): Promise<void> { ): Promise<void> {
try { writeStdoutBestEffort(
await db('audit_log') buildLine(
.where({ id: auditId }) { event: `audit.complete:${auditId}`, userId: '<see-original-event>', meta, errorCode, result },
.update({
status: result,
error_code: errorCode ?? null,
meta: meta ? JSON.stringify(meta) : db.raw('meta'),
updated_at: db.fn.now(),
});
} catch (err: any) {
logger.error(`completeAudit failed for ${auditId}: ${err?.message}`);
}
// Mirror to stdout
writeStdoutBestEffort(buildStdoutLine(
{ event: `audit.update.${auditId}`, userId: '<see-audit-row>', meta, errorCode, result },
result, result,
)); ),
);
} }

View File

@@ -0,0 +1,26 @@
/**
* BSC fee — backwards-compat re-export shim для `app-fee.ts`.
*
* Раньше (до multi-chain fee feature) этот файл содержал hardcoded
* `BSC_FEE_WALLET = '0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718'` и `computeBscFee`.
* Теперь fee унифицирован — `app-fee.ts` source of truth с тремя wallets (EVM/SOL/TRX),
* а здесь — shim чтобы существующие callers (swap-orchestrator, wallet-signer.service,
* wallet.controller) продолжали компилироваться без edit'ов.
*
* Behavior change: после этого rebuild старый wallet `0xeDEb...` НЕ используется. Все
* EVM fees идут на новый `0xeb9fbf0d137ef5ea7b9959044c2ed44ec1206c68` (см. app-fee.ts).
*
* Существующие callers НЕ нуждаются в code change — они импортируют `BSC_FEE_WALLET`
* который теперь = `APP_FEE_WALLET_EVM`. Single source of truth.
*
* Note: пока оставляем shim для backwards-compat. Если позже захотим — refactor callers
* на прямой import из `app-fee.ts`.
*/
export {
APP_FEE_WALLET_EVM as BSC_FEE_WALLET,
APP_FEE_BPS as BSC_FEE_BPS,
APP_FEE_DENOMINATOR as BSC_FEE_DENOMINATOR,
computeAppFee as computeBscFee,
computeAmountAfterFee as computeSwapAmountAfterFee,
} from './app-fee';

View File

@@ -18,16 +18,26 @@
import { ethers } from 'ethers'; import { ethers } from 'ethers';
/** Relay-protocol router/depository contract addresses per chainId. */ /** Relay-protocol router/depository contract addresses per chainId.
*
* Relay deploys new router contracts периодически (несколько раз в год).
* Если запрос к /sign-raw-evm-tx падает с "not in allowlist" — посмотри `to` адрес
* в Relay /execute response и добавь сюда. Relay использует deterministic deployer,
* так что один и тот же router обычно деплоится на ETH и BSC с тем же адресом.
*
* Полный список: https://docs.relay.link/references/contract-addresses
*/
const RELAY_ROUTERS: Record<number, Set<string>> = { const RELAY_ROUTERS: Record<number, Set<string>> = {
// Ethereum mainnet — Relay router contracts (lowercase for canonical match) // Ethereum mainnet — Relay router contracts (lowercase for canonical match)
1: new Set([ 1: new Set([
'0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 ETH '0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 (cross-chain bridge lock)
'0xf70da97812cb96acdf810712aa562db8dfa3dbef', // Relay Router (legacy) '0xf70da97812cb96acdf810712aa562db8dfa3dbef', // Relay Router v1 (legacy intra-chain entry point)
'0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', // Relay Router v2 (current intra-chain swap entry point, since ~2025)
]), ]),
// BSC mainnet // BSC mainnet (Relay использует тот же deterministic-deployed address для router v2)
56: new Set([ 56: new Set([
'0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 BSC '0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1
'0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', // Relay Router v2 (deterministic deploy)
]), ]),
}; };
@@ -49,6 +59,13 @@ export interface PolicyParams {
value: string; value: string;
gas: string; gas: string;
maxFeePerGas: string; maxFeePerGas: string;
/**
* Dynamic trusted addresses из Redis cache (`relay-trusted:{chainId}`).
* Объединяются с статическим `RELAY_ROUTERS[chainId]` whitelist'ом.
* Каждое /relay/execute response добавляет туда `to` + approve spender'ы.
* Если caller не передаёт (legacy) — используется только static whitelist.
*/
dynamicTrusted?: Set<string>;
} }
/** /**
@@ -67,32 +84,74 @@ const CAPS = {
maxValueWei: ethers.utils.parseEther('100'), maxValueWei: ethers.utils.parseEther('100'),
}; };
const SELECTOR_APPROVE = '0x095ea7b3';
/** /**
* Применяет security policy. Throws if disallowed. * Применяет security policy. Throws if disallowed.
* *
* Возвращает result-обoject для info-логирования (matched router name, selector name). * Два разрешённых пути:
*
* **A) Прямой call к Relay router:**
* `to` ∈ RELAY_ROUTERS[chainId] AND selector ∉ FORBIDDEN_SELECTORS
* Используется для основной swap/bridge tx через Relay.
*
* **B) Approve к Relay router:**
* selector == approve(address,uint256) AND
* spender (первый параметр approve) ∈ RELAY_ROUTERS[chainId]
* `to` может быть любым ERC20 token контрактом (USDT/USDC/etc).
* Используется для первого шага в multi-step Relay swap (token → X).
* Защита: attacker не может через sign-raw сделать approve на свой контракт —
* spender обязан быть Relay router из whitelist.
*
* Возвращает info-объект для логов.
*/ */
export function applyEvmTxPolicy(p: PolicyParams): { routerName?: string; selectorName?: string } { export function applyEvmTxPolicy(p: PolicyParams): { routerName?: string; selectorName?: string; flowKind: 'router-call' | 'approve-to-relay' } {
// 1. `to` validation — должен быть в Relay router allowlist для этого chainId
const toLower = p.to.toLowerCase(); const toLower = p.to.toLowerCase();
const routers = RELAY_ROUTERS[p.chainId]; const staticRouters = RELAY_ROUTERS[p.chainId];
if (!routers) { if (!staticRouters) {
throw new Error(`Sign-raw policy: chainId ${p.chainId} not in router allowlist`); throw new Error(`Sign-raw policy: chainId ${p.chainId} not in router allowlist`);
} }
if (!routers.has(toLower)) { // Combined trust set = static whitelist dynamic cache (from /relay/execute responses)
const isTrusted = (addr: string): boolean =>
staticRouters.has(addr) || (p.dynamicTrusted?.has(addr) ?? false);
const selector = p.data.length >= 10 ? p.data.slice(0, 10).toLowerCase() : '';
let flowKind: 'router-call' | 'approve-to-relay';
let selectorName: string | undefined;
if (selector === SELECTOR_APPROVE) {
// ─── Path B: approve(spender, amount), spender must be Relay router ───
flowKind = 'approve-to-relay';
selectorName = 'approve(address,uint256)';
// calldata layout: 4-byte selector + 32-byte spender + 32-byte amount = 68 bytes = 136 hex + '0x'
if (p.data.length < 138) {
throw new Error('Sign-raw policy: malformed approve calldata (too short)');
}
// spender = lower 20 bytes of first 32-byte parameter (left-padded with zeros)
const spenderHex = '0x' + p.data.slice(10 + 24, 10 + 64).toLowerCase();
if (!isTrusted(spenderHex)) {
throw new Error(`Sign-raw policy: approve spender ${spenderHex} not in Relay router allowlist for chainId ${p.chainId}`);
}
// `to` (token contract) может быть любым — это разрешённый flow.
// value для approve() должен быть 0 (стандартный ERC20 approve не принимает value)
if (p.value !== '0' && p.value !== '0x0' && ethers.BigNumber.from(p.value).gt(0)) {
throw new Error('Sign-raw policy: approve() with non-zero value rejected');
}
} else {
// ─── Path A: direct call to Relay router ───
flowKind = 'router-call';
if (!isTrusted(toLower)) {
throw new Error(`Sign-raw policy: 'to' address ${p.to} not in Relay router allowlist for chainId ${p.chainId}`); throw new Error(`Sign-raw policy: 'to' address ${p.to} not in Relay router allowlist for chainId ${p.chainId}`);
} }
// Forbidden selectors (drain vectors) проверяются ТОЛЬКО для router-call path —
// 2. Selector blacklist — `approve()` etc. никогда не подписывается // потому что для approve у нас отдельный (более строгий) check на spender выше.
let selectorName: string | undefined; if (selector && FORBIDDEN_SELECTORS[selector]) {
if (p.data.length >= 10) {
const selector = p.data.slice(0, 10).toLowerCase();
if (FORBIDDEN_SELECTORS[selector]) {
throw new Error(`Sign-raw policy: forbidden selector ${selector} (${FORBIDDEN_SELECTORS[selector]}) — drain vector`); throw new Error(`Sign-raw policy: forbidden selector ${selector} (${FORBIDDEN_SELECTORS[selector]}) — drain vector`);
} }
selectorName = selector ? `selector ${selector}` : undefined;
} }
// 3. gas caps // ─── Common caps (для обоих путей) ───
const gas = ethers.BigNumber.from(p.gas); const gas = ethers.BigNumber.from(p.gas);
if (gas.gt(CAPS.maxGas)) { if (gas.gt(CAPS.maxGas)) {
throw new Error(`Sign-raw policy: gas ${gas.toString()} exceeds cap ${CAPS.maxGas.toString()}`); throw new Error(`Sign-raw policy: gas ${gas.toString()} exceeds cap ${CAPS.maxGas.toString()}`);
@@ -102,15 +161,14 @@ export function applyEvmTxPolicy(p: PolicyParams): { routerName?: string; select
if (budget.gt(CAPS.maxGasBudgetWei)) { if (budget.gt(CAPS.maxGasBudgetWei)) {
throw new Error(`Sign-raw policy: gas budget ${ethers.utils.formatEther(budget)} ETH exceeds cap ${ethers.utils.formatEther(CAPS.maxGasBudgetWei)} ETH`); throw new Error(`Sign-raw policy: gas budget ${ethers.utils.formatEther(budget)} ETH exceeds cap ${ethers.utils.formatEther(CAPS.maxGasBudgetWei)} ETH`);
} }
// 4. value cap
const value = ethers.BigNumber.from(p.value); const value = ethers.BigNumber.from(p.value);
if (value.gt(CAPS.maxValueWei)) { if (value.gt(CAPS.maxValueWei)) {
throw new Error(`Sign-raw policy: value ${ethers.utils.formatEther(value)} exceeds cap ${ethers.utils.formatEther(CAPS.maxValueWei)} native units`); throw new Error(`Sign-raw policy: value ${ethers.utils.formatEther(value)} exceeds cap ${ethers.utils.formatEther(CAPS.maxValueWei)} native units`);
} }
return { return {
routerName: routers.has(toLower) ? `relay-router-${p.chainId}` : undefined, routerName: flowKind === 'router-call' ? `relay-router-${p.chainId}` : `relay-approve-${p.chainId}`,
selectorName, selectorName,
flowKind,
}; };
} }

View File

@@ -1,28 +1,59 @@
/** /**
* Idempotency-Key handling — C3 защита от double-spend при retry. * Idempotency-Key handling — anti-double-spend на retry.
*
* Storage: KeyDB / Redis (см. `config/redis.ts`).
* *
* Контракт: * Контракт:
* Client передаёт header `Idempotency-Key: <opaque-string-up-to-128-chars>`. * Client передаёт header `Idempotency-Key: <opaque-string-up-to-128-chars>`.
* Server: * Server:
* 1. INSERT row (user_id, key, request_hash) — PK conflict = retry detected. * 1. `SET NX EX 600 idem:{userId}:{key} '{requestHash,status:null,body:null}'`
* 2. На retry: SELECT existing row. Если response_status is null — operation * - NX (only-if-not-exists) → atomic claim
* ещё in-flight → return 409 "retry too soon". Если response_status set → * - EX 600 → 10 минут TTL
* return cached response (same status, same body). * 2. Если NX вернул OK → fresh claim, caller proceed'ит mutation.
* Retention: 24h. Cleanup via cron. * 3. Если NX вернул null → retry detected. GET значение и:
* - request_hash отличается → 409 "key reuse with different body"
* - status null → 409 "in-flight, retry after a few seconds"
* - status set → return cached response (no double-broadcast)
*
* После mutation client вызывает `saveIdempotencyResponse(userId, key, status, body)`
* чтобы cache последующих retry'ев на тот же key.
*
* Trade-off vs DB:
* + Latency <1ms (single Redis round-trip vs ~5ms DB)
* + No DB pressure
* + Auto-expiry via Redis EX
* + Distributed (multi-replica work через shared cache)
* KeyDB single point of failure → API падает на startup ping (fail-fast)
*/ */
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { db } from '../config/database'; import { getRedis } from '../config/redis';
const TTL_SECONDS = 10 * 60; // 10 minutes
interface CacheEntry {
requestHash: string;
responseStatus: number | null; // null = in-flight
responseBody: string | null;
}
export interface IdempotencyClaim { export interface IdempotencyClaim {
fresh: boolean; fresh: boolean;
cached?: { status: number; body: string }; cached?: { status: number; body: string };
} }
function cacheKey(userId: string, key: string): string {
return `idem:${userId}:${key}`;
}
/** /**
* Try to claim the key. If first time → fresh=true, caller proceeds with mutation. * Atomic claim. Returns:
* If duplicate с existing response → fresh=false + cached response. * - fresh=true → caller обязан proceed mutation и save response
* If duplicate с pending in-flight → throws (caller returns 409). * - fresh=false + cached → return cached response без mutation (retry case)
*
* Throws при:
* - in-flight (другой attempt ещё не save'нул response)
* - body hash mismatch (replay с другим body на тот же key)
*/ */
export async function claimIdempotency( export async function claimIdempotency(
userId: string, userId: string,
@@ -33,60 +64,89 @@ export async function claimIdempotency(
.update(JSON.stringify(requestBody ?? {})) .update(JSON.stringify(requestBody ?? {}))
.digest('hex'); .digest('hex');
try { const redis = getRedis();
await db('idempotency_keys').insert({ const k = cacheKey(userId, key);
user_id: userId, const initial: CacheEntry = {
key, requestHash,
request_hash: requestHash, responseStatus: null,
}); responseBody: null,
return { fresh: true }; };
} catch (err: any) {
// PK violation = retry
const existing = await db('idempotency_keys')
.where({ user_id: userId, key })
.first();
if (!existing) throw err;
// Verify request body matches (защита от replay с другим body) // SET key value NX EX seconds — atomic claim. Returns 'OK' if set, null if existed.
if (existing.request_hash !== requestHash) { const setResult = await redis.set(k, JSON.stringify(initial), 'EX', TTL_SECONDS, 'NX');
throw new Error(`Idempotency-Key reuse with different request body. Use a new key.`); if (setResult === 'OK') {
return { fresh: true };
} }
if (existing.response_status === null || existing.response_status === undefined) { // Already exists — это retry. Читаем.
const raw = await redis.get(k);
if (!raw) {
// Race: между NX и GET значение expired. Перепопытка как fresh.
const retry = await redis.set(k, JSON.stringify(initial), 'EX', TTL_SECONDS, 'NX');
if (retry === 'OK') return { fresh: true };
throw new Error('Idempotency cache race; retry after a few seconds.');
}
let entry: CacheEntry;
try {
entry = JSON.parse(raw) as CacheEntry;
} catch {
throw new Error('Idempotency cache entry corrupt');
}
if (entry.requestHash !== requestHash) {
throw new Error('Idempotency-Key reuse with different request body. Use a new key.');
}
if (entry.responseStatus === null) {
throw new Error('Operation already in flight; retry after a few seconds.'); throw new Error('Operation already in flight; retry after a few seconds.');
} }
return { return {
fresh: false, fresh: false,
cached: { cached: {
status: existing.response_status as number, status: entry.responseStatus,
body: existing.response_body as string, body: entry.responseBody ?? '',
}, },
}; };
}
} }
/** Сохранить response в idempotency row (после mutation succeeds/fails). */ /**
* Сохранить response в cache после mutation (success или failure).
* Best-effort: если Redis недоступен — log error, не throw (mutation уже произошла,
* cache update — UX optimization для retry'ев).
*/
export async function saveIdempotencyResponse( export async function saveIdempotencyResponse(
userId: string, userId: string,
key: string, key: string,
status: number, status: number,
body: string, body: string,
): Promise<void> { ): Promise<void> {
await db('idempotency_keys') try {
.where({ user_id: userId, key }) const redis = getRedis();
.update({ const k = cacheKey(userId, key);
response_status: status, const raw = await redis.get(k);
response_body: body, if (!raw) return; // expired — skip
}); let entry: CacheEntry;
try {
entry = JSON.parse(raw) as CacheEntry;
} catch {
return;
}
entry.responseStatus = status;
entry.responseBody = body;
// Re-set with refreshed TTL чтобы retry мог получить cached response
await redis.set(k, JSON.stringify(entry), 'EX', TTL_SECONDS);
} catch {
// Cache update — non-critical
}
} }
/** Validate header format. Returns null if missing/invalid (caller may make mandatory). */ /** Validate header format. Returns null if missing/invalid. */
export function extractIdempotencyKey(headerValue: unknown): string | null { export function extractIdempotencyKey(headerValue: unknown): string | null {
if (typeof headerValue !== 'string') return null; if (typeof headerValue !== 'string') return null;
const v = headerValue.trim(); const v = headerValue.trim();
if (!v) return null; if (!v) return null;
// Restrict charset: alphanum + dash/underscore, max 128
if (!/^[A-Za-z0-9_-]{1,128}$/.test(v)) return null; if (!/^[A-Za-z0-9_-]{1,128}$/.test(v)) return null;
return v; return v;
} }

View File

@@ -0,0 +1,309 @@
/**
* NearIntents 1Click API client — direct integration (bypasses LiFi).
*
* Flow:
* 1. POST /v0/quote { originAsset, destinationAsset, amount, refundTo, recipient, deadline, ... }
* → returns { depositAddress, minAmountOut, deadline, timeWhenInactive, ... }
* 2. User sends `amount` of `originAsset` to `depositAddress` (regular transfer on source chain).
* 3. POST /v0/deposit/submit { depositAddress, txHash } — optional best-effort notification.
* 4. GET /v0/status?depositAddress=... → polls intent execution.
*
* Why we use this instead of LiFi для TRX:
* - LiFi для TRX возвращает pre-built protobuf raw_data_hex с NearIntents intent_id внутри,
* у которого 30-60s off-chain TTL. Наш pipeline превышает TTL → on-chain revert + burn fees.
* - NearIntents direct API даёт нам чистый "transfer на адрес" flow без contract calls,
* mr deadline ≥ 30 минут (мы сами выбираем). Используем existing battle-tested sendTrx.
*
* Security: ВСЕ outbound calls через `proxiedFetch` (HTTPS-only, 20s timeout, IP rotation).
* NO mnemonic / wallet access в этом модуле — это чистый HTTP клиент.
*/
import { proxiedFetch } from './outbound-proxy';
import { logger } from './logger';
import type { ChainCode } from './address-validators';
const NEARINTENTS_API_URL = 'https://1click.chaindefuser.com';
const NEARINTENTS_TIMEOUT_MS = 20_000;
// ─── Dynamic asset map: ChainCode + (null|contract) → NearIntents assetId ──────
//
// Раньше был hardcoded map с угаданными assetId — это привело к "tokenOut is not valid"
// для BSC (BSC assets имеют формат nep245:v2_1.omni.hot.tg:56_<id>, а не nep141:bsc.omft.near
// как я предположил). Теперь fetch /v0/tokens напрямую от NearIntents — authoritative source.
//
// Cache: in-memory, TTL 1 час, lazy refresh на miss. NearIntents tokens list стабилен
// (обновляется при добавлении новых chains, не часто).
interface NearIntentsToken {
blockchain: string; // 'tron', 'sol', 'eth', 'bsc', 'btc', 'aptos', 'arb', etc.
symbol: string;
contractAddress: string | null; // null для native; lowercased contract для EVM/SOL/TRX
decimals: number;
assetId: string; // 'nep141:...' или 'nep245:...' (depends on bridge provider)
price?: number;
priceUpdatedAt?: string;
}
interface AssetMapCache {
map: Map<string, string>; // key = '<blockchain>:<contract|native>' → assetId
expiresAt: number;
}
let _assetMapCache: AssetMapCache | null = null;
const ASSET_MAP_TTL_MS = 60 * 60 * 1000; // 1 hour
// Наш ChainCode → NearIntents `blockchain` field.
const CHAIN_CODE_TO_NEARINTENTS_BLOCKCHAIN: Partial<Record<ChainCode, string>> = {
ETH: 'eth',
BSC: 'bsc',
TRX: 'tron',
SOL: 'sol',
BTC: 'btc',
};
function assetMapKey(blockchain: string, contract: string | null): string {
return `${blockchain.toLowerCase()}:${(contract ?? 'native').toLowerCase()}`;
}
/**
* Fetch full asset map от NearIntents. Cached in-memory 1h.
* On cache miss или expiry — refetch. On HTTP error — throws.
*/
async function fetchAssetMap(): Promise<Map<string, string>> {
if (_assetMapCache && _assetMapCache.expiresAt > Date.now()) {
return _assetMapCache.map;
}
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), NEARINTENTS_TIMEOUT_MS);
let res: globalThis.Response;
try {
res = await proxiedFetch(`${NEARINTENTS_API_URL}/v0/tokens`, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: ctrl.signal,
});
} finally {
clearTimeout(t);
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`NearIntents /v0/tokens fetch failed (${res.status}): ${text.slice(0, 200)}`);
}
const tokens = (await res.json()) as NearIntentsToken[];
if (!Array.isArray(tokens)) {
throw new Error('NearIntents /v0/tokens returned non-array');
}
const map = new Map<string, string>();
for (const t of tokens) {
if (!t.blockchain || !t.assetId) continue;
map.set(assetMapKey(t.blockchain, t.contractAddress), t.assetId);
}
_assetMapCache = { map, expiresAt: Date.now() + ASSET_MAP_TTL_MS };
logger.info(`NearIntents asset map loaded: ${map.size} entries across ${new Set(tokens.map((t) => t.blockchain)).size} chains`);
return map;
}
/**
* Resolve ChainCode + token → NearIntents assetId via dynamic /v0/tokens lookup.
* Returns null если pair не supported (caller должен throw clear error).
*
* `token === null` → native. Contract addresses lowercased internally для matching.
*/
export async function resolveAsset(chain: ChainCode, token: string | null): Promise<string | null> {
const blockchain = CHAIN_CODE_TO_NEARINTENTS_BLOCKCHAIN[chain];
if (!blockchain) {
logger.warn(`NearIntents: chain ${chain} not in CHAIN_CODE_TO_NEARINTENTS_BLOCKCHAIN map`);
return null;
}
const map = await fetchAssetMap();
return map.get(assetMapKey(blockchain, token)) || null;
}
/**
* Force refresh asset map cache (для тестов / admin debug). Не используется в production flow.
*/
export function _invalidateAssetMapCache(): void {
_assetMapCache = null;
}
// ─── Quote ──────────────────────────────────────────────────────────────────
export interface NearIntentsQuoteInput {
/** Pre-resolved NearIntents assetId (e.g. 'nep141:tron.omft.near'). Используй `resolveAsset()` для получения. */
originAssetId: string;
/** Pre-resolved NearIntents assetId для destination. */
destinationAssetId: string;
amount: string; // smallest units of origin asset
/** Slippage tolerance в bps (basis points). 50 = 0.5%. Hard-cap 500 (5%) на server. */
slippageBps: number;
/** User's wallet на origin chain — куда NearIntents вернёт средства если intent fails */
refundTo: string;
/** User's wallet на destination chain — куда solver доставит destination asset */
recipient: string;
/** Сколько минут (от сейчас) intent остаётся valid. Default 30, max 60. */
deadlineMinutes?: number;
}
export interface NearIntentsQuoteResult {
/** Tron base58 address куда юзеру отправить amount */
depositAddress: string;
amountIn: string;
amountInFormatted: string;
amountInUsd?: string;
minAmountIn: string;
amountOut: string;
amountOutFormatted: string;
amountOutUsd?: string;
minAmountOut: string;
deadline: string; // ISO timestamp
deadlineMs: number; // parsed convenience
timeWhenInactive: string; // ISO — когда solver перестаёт обрабатывать
timeWhenInactiveMs: number;
timeEstimateSec: number;
signature: string; // ed25519:... — anti-MEV proof, store в audit
correlationId: string;
}
/**
* Fetch a NearIntents 1Click quote (real, not dry-run — returns depositAddress).
*/
export async function fetchNearIntentsQuote(input: NearIntentsQuoteInput): Promise<NearIntentsQuoteResult> {
if (!input.originAssetId || !input.destinationAssetId) {
throw new Error('NearIntents: originAssetId + destinationAssetId required (use resolveAsset() first)');
}
// Server-side slippage cap (anti-foot-gun)
const slippageBps = Math.min(Math.max(input.slippageBps, 10), 500);
// Deadline — мы выбираем сами, NearIntents примет любой reasonable timestamp.
// 30 минут default (хватает на user reaction + broadcast + solver delivery).
const deadlineMinutes = Math.min(Math.max(input.deadlineMinutes ?? 30, 5), 60);
const deadline = new Date(Date.now() + deadlineMinutes * 60_000).toISOString();
const body = {
dry: false,
swapType: 'EXACT_INPUT',
slippageTolerance: slippageBps,
originAsset: input.originAssetId,
depositType: 'ORIGIN_CHAIN',
destinationAsset: input.destinationAssetId,
amount: input.amount,
refundTo: input.refundTo,
refundType: 'ORIGIN_CHAIN',
recipient: input.recipient,
recipientType: 'DESTINATION_CHAIN',
deadline,
};
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), NEARINTENTS_TIMEOUT_MS);
let res: globalThis.Response;
try {
res = await proxiedFetch(`${NEARINTENTS_API_URL}/v0/quote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
signal: ctrl.signal,
});
} finally {
clearTimeout(t);
}
const text = await res.text();
if (!res.ok) {
logger.warn(`NearIntents /v0/quote ${res.status}: ${text.slice(0, 200)}`);
throw new Error(`NearIntents quote failed (${res.status}): ${text.slice(0, 200)}`);
}
let json: any;
try {
json = JSON.parse(text);
} catch {
throw new Error('NearIntents returned non-JSON');
}
const q = json.quote;
if (!q || !q.depositAddress) {
throw new Error(`NearIntents quote missing depositAddress: ${text.slice(0, 200)}`);
}
return {
depositAddress: String(q.depositAddress),
amountIn: String(q.amountIn),
amountInFormatted: String(q.amountInFormatted || ''),
amountInUsd: q.amountInUsd ? String(q.amountInUsd) : undefined,
minAmountIn: String(q.minAmountIn || q.amountIn),
amountOut: String(q.amountOut),
amountOutFormatted: String(q.amountOutFormatted || ''),
amountOutUsd: q.amountOutUsd ? String(q.amountOutUsd) : undefined,
minAmountOut: String(q.minAmountOut),
deadline: String(q.deadline),
deadlineMs: new Date(q.deadline).getTime(),
timeWhenInactive: String(q.timeWhenInactive || q.deadline),
timeWhenInactiveMs: new Date(q.timeWhenInactive || q.deadline).getTime(),
timeEstimateSec: Number(q.timeEstimate || 60),
signature: String(json.signature || ''),
correlationId: String(json.correlationId || ''),
};
}
// ─── Deposit notification ────────────────────────────────────────────────────
/**
* Best-effort: notify NearIntents что мы отправили tx. Solver всё равно мониторит
* на-chain — поэтому если этот POST fails, intent всё ещё процессится.
*
* Errors NOT thrown (caller should ignore failures).
*/
export async function submitNearIntentsDeposit(depositAddress: string, txHash: string): Promise<void> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), NEARINTENTS_TIMEOUT_MS);
try {
const res = await proxiedFetch(`${NEARINTENTS_API_URL}/v0/deposit/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ depositAddress, txHash }),
signal: ctrl.signal,
});
if (!res.ok) {
const txt = await res.text().catch(() => '');
logger.warn(`NearIntents deposit submit ${res.status}: ${txt.slice(0, 200)}`);
} else {
logger.info(`NearIntents deposit submitted: ${depositAddress}${txHash}`);
}
} catch (err: any) {
logger.warn(`NearIntents deposit submit network error: ${err?.message}`);
} finally {
clearTimeout(t);
}
}
// ─── Status polling helper (для frontend tracker) ────────────────────────────
export function nearIntentsTrackerUrl(depositAddress: string): string {
return `${NEARINTENTS_API_URL}/v0/status?depositAddress=${encodeURIComponent(depositAddress)}`;
}
// ─── depositAddress validation (security: ensure NearIntents возвращает
// валидный Tron address, не attacker-controlled garbage)
const TRON_BASE58_REGEX = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
const BTC_BECH32_REGEX = /^bc1[ac-hj-np-z02-9]{6,}$/;
/**
* Throws если depositAddress не соответствует ожидаемому формату для chain.
*/
export function assertValidDepositAddress(chain: ChainCode, depositAddress: string): void {
if (chain === 'TRX') {
if (!TRON_BASE58_REGEX.test(depositAddress)) {
throw new Error(`NearIntents depositAddress ${depositAddress} не валидный Tron base58 — abort`);
}
return;
}
if (chain === 'BTC') {
if (!BTC_BECH32_REGEX.test(depositAddress)) {
throw new Error(`NearIntents depositAddress ${depositAddress} не валидный Bitcoin bech32 — abort`);
}
return;
}
}

View File

@@ -0,0 +1,157 @@
/**
* Outbound HTTP/HTTPS proxy для swap + bridge endpoints (только).
*
* Когда `OUTBOUND_PROXY_URL` задан в env — все calls к Jupiter / Relay / EVM RPC /
* Solana RPC / TronGrid из:
* - swap-orchestrator.service.ts (custodial swap BSC/TRX/SOL)
* - routes/relay-proxy.routes.ts (Relay /quote, /execute, /intents/status)
* - wallet-signer.service.ts:signAndBroadcastRawEvm (bridge sign-raw)
* - wallet-signer.service.ts:signAndBroadcastSolanaTx + signAndBroadcastSolanaInstructions
* идут через proxy.
*
* НЕ через proxy (direct outbound):
* - /balance, /transactions, /send (basic)
* - /prices (CoinGecko)
* - gas-suggestions
* - legacy /api/{btc,tron,sol/swap,tron/swap,bsc/swap}/* proxy routes
*
* Proxy формат (squid-style, без auth по дефолту):
* OUTBOUND_PROXY_URL=http://37.220.84.34:3128
* OUTBOUND_PROXY_URL=http://user:pass@host:port (если нужен auth)
* OUTBOUND_PROXY_URL=https://host:port (если TLS до прокси)
*
* Если env пустой — fallback на native fetch / прямой Connection.
*/
import { ProxyAgent, fetch as undiciFetch } from 'undici';
import { ethers } from 'ethers';
import { Connection, type Commitment } from '@solana/web3.js';
import { logger } from './logger';
let _agent: ProxyAgent | null = null;
let _agentChecked = false;
/**
* Lazy-init `ProxyAgent` from `OUTBOUND_PROXY_URL` env. Returns `null` if env is empty
* (callers should fallback to native fetch).
*/
export function getProxyAgent(): ProxyAgent | null {
if (_agentChecked) return _agent;
_agentChecked = true;
const url = process.env.OUTBOUND_PROXY_URL?.trim();
if (!url) return null;
try {
_agent = new ProxyAgent({
uri: url,
// Некоторые RPC endpoints используют неполные cert chains через прокси;
// для swap/bridge transport-level MITM не критичен (sigs проверяются on-chain).
requestTls: { rejectUnauthorized: false },
});
// Маскируем basic-auth credentials в логе
const masked = url.replace(/:\/\/[^@]+@/, '://***:***@');
logger.info(`Outbound proxy enabled (swap+bridge only): ${masked}`);
return _agent;
} catch (err: any) {
logger.error(`Failed to init OUTBOUND_PROXY_URL=${url}: ${err?.message || 'unknown'}`);
return null;
}
}
/**
* fetch() через proxy если задан, иначе обычный globalThis.fetch.
* Сигнатура совместима с native fetch.
*/
export async function proxiedFetch(
input: string | URL,
init?: RequestInit & { signal?: AbortSignal },
): Promise<Response> {
const agent = getProxyAgent();
if (!agent) {
return fetch(input as any, init as any);
}
// undici.fetch поддерживает `dispatcher` для per-call routing через ProxyAgent.
// Возвращаемый тип Response совместим с native — приводим через unknown для TS.
return undiciFetch(input as any, {
...(init as any),
dispatcher: agent,
}) as unknown as Response;
}
/**
* ethers v5 JsonRpcProvider с overridden `send()` — отправляет JSON-RPC через proxiedFetch.
*
* ethers v5 internal fetchJson не использует globalThis.fetch + не поддерживает proxy agent,
* поэтому override `send()` — единственный надёжный путь.
*/
export class ProxiedJsonRpcProvider extends ethers.providers.StaticJsonRpcProvider {
private _id = 1;
async send(method: string, params: unknown[]): Promise<any> {
const url = (this.connection as any).url as string;
const body = JSON.stringify({
jsonrpc: '2.0',
id: this._id++,
method,
params,
});
const res = await proxiedFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`RPC ${method} HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const json = (await res.json()) as { result?: unknown; error?: { code?: number; message?: string; data?: unknown } };
if (json.error) {
const e: any = new Error(json.error.message || `RPC ${method} error`);
e.code = json.error.code;
e.data = json.error.data;
throw e;
}
return json.result;
}
}
/**
* Failover: пробуем `rpcs` последовательно через proxied JSON-RPC, возвращаем первый-живой.
* Замена `pickProvider` для swap/bridge code paths.
*/
export async function pickProxiedEvmProvider(
rpcs: string[],
chainId: number,
): Promise<ProxiedJsonRpcProvider> {
let lastErr: any;
for (const url of rpcs) {
const p = new ProxiedJsonRpcProvider(url, chainId);
try {
await Promise.race([
p.getBlockNumber(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('rpc_alive_timeout')), 5000),
),
]);
return p;
} catch (err) {
lastErr = err;
}
}
throw new Error(
`All proxied RPCs failed (chainId=${chainId}, n=${rpcs.length}): ${(lastErr as any)?.message || 'unknown'}`,
);
}
/**
* Solana Connection с custom fetch = proxiedFetch.
* @solana/web3.js ConnectionConfig поддерживает поле `fetch` — наш entry point.
*/
export function getProxiedSolConnection(
rpcUrl: string,
commitment: Commitment = 'confirmed',
): Connection {
return new Connection(rpcUrl, {
commitment,
fetch: proxiedFetch as unknown as typeof fetch,
});
}

View File

@@ -0,0 +1,96 @@
/**
* Dynamic cache "trusted EVM addresses from Relay /execute responses".
*
* Каждый раз когда юзер делает /relay/execute/* → ответ Relay содержит unsigned tx'ы
* (`steps[].items[].data.to` + selector/parameter в `data` для approve).
* Эти адреса — официальные от Relay (т.к. идут через наш proxy к api.relay.link),
* безопасно довериться им для последующего sign-raw-evm-tx за короткое окно.
*
* Хранилище: KeyDB Redis set `relay-trusted:{chainId}` с TTL 30 минут.
* При sign-raw-evm-tx `applyEvmTxPolicy` объединяет static whitelist + этот cache.
*
* Защита от drain:
* - addresses попадают в cache ТОЛЬКО через /relay/execute response (наш proxy fetch'ит
* api.relay.link — компрометированный upstream может потенциально подсунуть свой
* адрес, но Relay уже trusted в security-модели; если они скомпрометированы,
* мы тоже).
* - TTL 30 минут — addresses сами протухают.
* - Set deduplicates, размер каждого set'а ≤ ~50 (Relay routers стабильные).
*/
import { getRedis } from '../config/redis';
import { logger } from './logger';
const CACHE_TTL_SECONDS = 30 * 60; // 30 минут
const SELECTOR_APPROVE = '0x095ea7b3';
const SET_PREFIX = 'relay-trusted:';
/**
* Parse Relay execute response и записать обнаруженные EVM-адреса в KeyDB set.
* Не-EVM steps (chainId не указан / 0 / SOL=792703809) скипаем.
*/
export async function indexRelayExecuteResponse(payload: unknown): Promise<void> {
try {
if (!payload || typeof payload !== 'object') return;
const steps = (payload as any).steps;
if (!Array.isArray(steps)) return;
// Группируем addresses по chainId — один pipeline на chain
const perChain = new Map<number, Set<string>>();
for (const step of steps) {
const items = step?.items;
if (!Array.isArray(items)) continue;
for (const item of items) {
const data = item?.data;
if (!data || typeof data !== 'object') continue;
const chainId = Number(data.chainId);
if (!Number.isFinite(chainId) || chainId <= 0) continue;
// Skip non-EVM (SOL = 792703809, и т.п.) — у них не EVM `to`/`data`.
if (chainId > 1_000_000) continue;
const set = perChain.get(chainId) ?? new Set<string>();
perChain.set(chainId, set);
// 1) сам `to` контракт
const to = String(data.to || '').toLowerCase();
if (/^0x[0-9a-f]{40}$/.test(to)) set.add(to);
// 2) approve spender — если selector approve, parse first param
const calldata = String(data.data || '').toLowerCase();
if (calldata.startsWith(SELECTOR_APPROVE) && calldata.length >= 138) {
const spender = '0x' + calldata.slice(10 + 24, 10 + 64);
if (/^0x[0-9a-f]{40}$/.test(spender)) set.add(spender);
}
}
}
if (perChain.size === 0) return;
const redis = getRedis();
const pipeline = redis.pipeline();
for (const [chainId, addrs] of perChain.entries()) {
const key = `${SET_PREFIX}${chainId}`;
pipeline.sadd(key, ...Array.from(addrs));
pipeline.expire(key, CACHE_TTL_SECONDS);
}
await pipeline.exec();
} catch (err: any) {
// Не валим запрос — это enrichment, основной flow продолжается
logger.warn(`indexRelayExecuteResponse skipped: ${err?.message || 'unknown'}`);
}
}
/**
* Получить set trusted addresses для chainId. Никогда не throws.
* Возвращает пустой Set если cache недоступен / пустой.
*/
export async function getRelayTrustedAddresses(chainId: number): Promise<Set<string>> {
try {
const redis = getRedis();
const members = await redis.smembers(`${SET_PREFIX}${chainId}`);
return new Set(members.map((m) => m.toLowerCase()));
} catch (err: any) {
logger.warn(`getRelayTrustedAddresses(${chainId}) failed: ${err?.message || 'unknown'}`);
return new Set();
}
}

View File

@@ -0,0 +1,146 @@
/**
* Swap quote cache (KeyDB).
*
* Используется 2-step swap flow: `quoteSwap` сохраняет результат расчёта в Redis
* под опаковым `quoteId`, `swapOnChain` читает по `{userId, quoteId}` и выполняет
* swap с зафиксированными параметрами (anti-MEV защита от изменения minOut между
* quote и execute).
*
* Cache key:
* swap-quote:{userId}:{quoteId}
*
* TTL: 30 секунд (default). После expire — execute вернёт 410 Gone и юзер
* перезапросит quote.
*
* Anti-replay: успешный execute удаляет cache entry (см. `deleteQuote`).
*
* Security:
* - quoteId сгенерирован через ULID (collision-resistant)
* - cache key включает userId — even if quoteId leak'нет, другой юзер не
* сможет execute (DB read будет miss)
* - extra check на field `userId` внутри cached object — defence-in-depth
*/
import { getRedis } from '../config/redis';
import { logger } from './logger';
const KEY_PREFIX = 'swap-quote:';
const DEFAULT_TTL_SECONDS = 30;
export interface CachedSwapQuote {
// Метаданные
quoteId: string;
userId: string;
chain: 'BSC' | 'TRX' | 'SOL';
createdAt: number; // unix ms
expiresAt: number; // unix ms
// Параметры execute — locked
// Для BSC/TRX: from/to/amount — symbols. SOL: inputMint/outputMint/amount — mints.
params: {
from?: string;
to?: string;
inputMint?: string;
outputMint?: string;
amount: string;
slippageBps: number;
feeTier?: 'slow' | 'normal' | 'fast';
};
// Locked-in expectation для execute path (защита от MEV-sandwich).
// На execute мы передаём `minOut` (BSC/TRX) ИЛИ напрямую `quoteResponse`
// (SOL — Jupiter API требует full quote object).
locked: {
expectedOut: string;
minOut: string;
// SOL only: serialized Jupiter /quote response (для re-use на /swap step).
jupiterQuoteResponse?: any;
};
// Snapshot всех полей quote — возвращается клиенту, попадает в audit_log.
preview: any;
}
function buildKey(userId: string, quoteId: string): string {
return `${KEY_PREFIX}${userId}:${quoteId}`;
}
/**
* Сохраняет quote в KeyDB с TTL.
* Возвращает true если успешно, false если cache write failed (не критично — caller
* может вернуть 503).
*/
export async function saveQuote(
quote: CachedSwapQuote,
ttlSeconds: number = DEFAULT_TTL_SECONDS,
): Promise<boolean> {
if (ttlSeconds < 1 || ttlSeconds > 600) {
throw new Error(`saveQuote: ttlSeconds ${ttlSeconds} out of range [1,600]`);
}
try {
const key = buildKey(quote.userId, quote.quoteId);
await getRedis().set(key, JSON.stringify(quote), 'EX', ttlSeconds);
return true;
} catch (err: any) {
logger.error(`swap-quote-cache.saveQuote failed: ${err.message}`);
return false;
}
}
/**
* Читает quote из KeyDB по `{userId, quoteId}`.
* Возвращает `null` если:
* - не найден (expired или never existed)
* - userId mismatch в cached object (defence-in-depth)
* - JSON parse error
* - Redis unavailable (логируется error)
*/
export async function getQuote(
userId: string,
quoteId: string,
): Promise<CachedSwapQuote | null> {
if (!userId || !quoteId) return null;
// Базовая sanity-check: quoteId должен быть alphanumeric (ULID = 26 chars), но
// допускаем любые printable для гибкости. Бьём только entrants с обвидно
// malformed input.
if (quoteId.length > 64 || /[\r\n\s]/.test(quoteId)) return null;
try {
const key = buildKey(userId, quoteId);
const raw = await getRedis().get(key);
if (!raw) return null;
let parsed: CachedSwapQuote;
try {
parsed = JSON.parse(raw) as CachedSwapQuote;
} catch {
logger.error(`swap-quote-cache.getQuote: JSON parse failed for key=${key}`);
return null;
}
// Defence-in-depth: cache key уже content-binds userId, но проверяем поле.
if (parsed.userId !== userId) {
logger.error(`swap-quote-cache.getQuote: userId mismatch (key=${userId}, body=${parsed.userId})`);
return null;
}
return parsed;
} catch (err: any) {
logger.error(`swap-quote-cache.getQuote failed: ${err.message}`);
return null;
}
}
/**
* Удаляет quote после успешного execute (anti-replay).
* Best-effort — ошибки логируются и swallowed.
*/
export async function deleteQuote(userId: string, quoteId: string): Promise<void> {
try {
await getRedis().del(buildKey(userId, quoteId));
} catch (err: any) {
logger.warn(`swap-quote-cache.deleteQuote failed: ${err.message}`);
}
}
export const QUOTE_TTL_SECONDS = DEFAULT_TTL_SECONDS;

View File

@@ -10,61 +10,227 @@ import type { ChainCode } from './address-validators';
export interface EvmToken { export interface EvmToken {
symbol: string; symbol: string;
name: string;
contractAddress: string; contractAddress: string;
decimals: number; decimals: number;
coingeckoId?: string;
} }
export interface TrxToken { export interface TrxToken {
symbol: string; symbol: string;
name: string;
contractAddress: string; // T...base58 contractAddress: string; // T...base58
decimals: number; decimals: number;
coingeckoId?: string;
} }
export interface SolToken { export interface SolToken {
symbol: string; symbol: string;
name: string;
mint: string; // SPL mint pubkey (base58) mint: string; // SPL mint pubkey (base58)
decimals: number; decimals: number;
coingeckoId?: string;
} }
/**
* Flat shape для GET /api/tokens.
* Native coins имеют contract = null.
*
* `decimals` — нужен фронту чтобы конвертировать human-readable amount
* ("10") → smallest units ("10000000000000000000") перед отправкой в LiFi/swap API.
* Для native берётся из `NATIVE_DECIMALS` (BTC=8, ETH/BSC=18, TRX=6, SOL=9);
* для tokens — из EvmToken.decimals / TrxToken.decimals / SolToken.decimals.
*/
export interface TokenListEntry {
chain: ChainCode;
symbol: string;
name: string;
contract: string | null;
decimals: number;
/** LiFi/Jumper fromToken/toToken для native (BTC = "bitcoin"). */
lifiAddress?: string;
}
/** LiFi native sentinel для bridge quote (только BTC отличается от contract:null). */
export const LIFI_NATIVE_ADDRESS: Partial<Record<ChainCode, string>> = {
BTC: 'bitcoin',
};
/**
* Native decimals per chain. Дублирует `NATIVE_DECIMALS` из `lib/amount-units.ts`
* и `services/wallet-ops.service.ts` — небольшое количество constants, проще
* inline'нуть чем плодить cross-file deps.
*/
const NATIVE_DECIMALS_LOCAL: Record<ChainCode, number> = {
ETH: 18,
BSC: 18,
BTC: 8,
TRX: 6,
SOL: 9,
};
/**
* CoinGecko coin IDs для native монет каждой chain.
* Используется в `price-oracle.service.ts` для USD-цен в `/balance`.
*/
export const NATIVE_COINGECKO_IDS: Record<ChainCode, string> = {
BTC: 'bitcoin',
ETH: 'ethereum',
BSC: 'binancecoin',
TRX: 'tron',
SOL: 'solana',
};
/**
* Native coin human names + tickers. На BSC ticker = "BNB" (не "BSC").
* Используется в GET /api/tokens для native entries.
*/
export const NATIVE_NAMES: Record<ChainCode, string> = {
BTC: 'Bitcoin',
ETH: 'Ethereum',
BSC: 'BNB',
TRX: 'Tron',
SOL: 'Solana',
};
export const NATIVE_SYMBOLS: Record<ChainCode, string> = {
BTC: 'BTC',
ETH: 'ETH',
BSC: 'BNB', // ticker отличается от chain code
TRX: 'TRX',
SOL: 'SOL',
};
export const ETH_TOKENS: EvmToken[] = [ export const ETH_TOKENS: EvmToken[] = [
{ symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 }, { symbol: 'USDT', name: 'Tether USD', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6, coingeckoId: 'tether' },
{ symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 }, { symbol: 'USDC', name: 'USD Coin', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, coingeckoId: 'usd-coin' },
{ symbol: 'DAI', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18 }, { symbol: 'DAI', name: 'Dai Stablecoin', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18, coingeckoId: 'dai' },
{ symbol: 'WBTC', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8 }, { symbol: 'WBTC', name: 'Wrapped Bitcoin', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8, coingeckoId: 'wrapped-bitcoin' },
{ symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18 }, { symbol: 'LINK', name: 'Chainlink', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18, coingeckoId: 'chainlink' },
{ symbol: 'UNI', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18 }, { symbol: 'UNI', name: 'Uniswap', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18, coingeckoId: 'uniswap' },
]; ];
export const BSC_TOKENS: EvmToken[] = [ export const BSC_TOKENS: EvmToken[] = [
{ symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 }, { symbol: 'USDT', name: 'Tether USD', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18, coingeckoId: 'tether' },
{ symbol: 'USDC', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18 }, { symbol: 'USDC', name: 'USD Coin', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, coingeckoId: 'usd-coin' },
{ symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8 }, { symbol: 'DOGE', name: 'Dogecoin', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8, coingeckoId: 'dogecoin' },
{ symbol: 'WBNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18 }, { symbol: 'WBNB', name: 'Wrapped BNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18, coingeckoId: 'wbnb' },
{ symbol: 'BUSD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18 }, { symbol: 'BUSD', name: 'Binance USD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18, coingeckoId: 'binance-usd' },
]; ];
export const TRX_TOKENS: TrxToken[] = [ export const TRX_TOKENS: TrxToken[] = [
{ symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 }, { symbol: 'USDT', name: 'Tether USD', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6, coingeckoId: 'tether' },
{ symbol: 'USDC', contractAddress: 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', decimals: 6 },
]; ];
export const SOL_TOKENS: SolToken[] = [ export const SOL_TOKENS: SolToken[] = [
{ symbol: 'USDT', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 }, { symbol: 'USDT', name: 'Tether USD', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6, coingeckoId: 'tether' },
{ symbol: 'USDC', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 }, { symbol: 'USDC', name: 'USD Coin', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6, coingeckoId: 'usd-coin' },
{ symbol: 'PUMP', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6 }, { symbol: 'PUMP', name: 'Pump.fun', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6, coingeckoId: 'pump-fun' },
{ symbol: 'JUP', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6 }, { symbol: 'JUP', name: 'Jupiter', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6, coingeckoId: 'jupiter-exchange-solana' },
{ symbol: 'WIF', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6 }, { symbol: 'WIF', name: 'dogwifhat', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6, coingeckoId: 'dogwifcoin' },
{ symbol: 'POPCAT', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9 }, { symbol: 'POPCAT', name: 'Popcat', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9, coingeckoId: 'popcat' },
{ symbol: 'TRUMP', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6 }, { symbol: 'TRUMP', name: 'Official Trump', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6, coingeckoId: 'official-trump' },
{ symbol: 'PYTH', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6 }, { symbol: 'PYTH', name: 'Pyth Network', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6, coingeckoId: 'pyth-network' },
{ symbol: 'JTO', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9 }, { symbol: 'JTO', name: 'Jito', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9, coingeckoId: 'jito-governance-token' },
{ symbol: 'W', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6 }, { symbol: 'W', name: 'Wormhole', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6, coingeckoId: 'wormhole' },
{ symbol: 'BONK', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5 }, { symbol: 'BONK', name: 'Bonk', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5, coingeckoId: 'bonk' },
{ symbol: 'ORCA', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6 }, { symbol: 'ORCA', name: 'Orca', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6, coingeckoId: 'orca' },
{ symbol: 'PENGU', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6 }, { symbol: 'PENGU', name: 'Pudgy Penguins', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6, coingeckoId: 'pudgy-penguins' },
{ symbol: 'RAY', mint: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6 }, { symbol: 'RAY', name: 'Raydium', mint: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6, coingeckoId: 'raydium' },
]; ];
const ALL_CHAINS_ORDERED: ChainCode[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL'];
/**
* Whitelist of tokens которые реально bridgeable через наш Jumper/NearIntents/Relay path.
* Если token не в этом set'е — UI dropdown'ы не показывают его (frontend filter via
* `/api/tokens?chain=X&bridgeable=true`).
*
* Source: cross-reference нашего registry с (а) NearIntents /v0/tokens supported list
* (167 assets), (б) LiFi major tokens (USDT/USDC/native + select DeFi), (в) Relay coverage.
*
* Tokens NOT here (потому что нет ликвидности в bridges):
* - SOL: PUMP, JUP, POPCAT, PYTH, JTO, W, BONK, ORCA, RAY — memecoins / DeFi не в NearIntents/LiFi
* - BSC: DOGE (BSC-wrapped), WBNB, BUSD — deprecated / no bridge
*
* Format: 'CHAIN:SYMBOL'. Native всегда included.
*/
const BRIDGEABLE_TOKENS: Set<string> = new Set([
// ETH
'ETH:ETH', 'ETH:USDT', 'ETH:USDC', 'ETH:DAI', 'ETH:LINK', 'ETH:UNI', 'ETH:WBTC',
// BSC
'BSC:BNB', 'BSC:USDT', 'BSC:USDC',
// TRX
'TRX:TRX', 'TRX:USDT',
// SOL
'SOL:SOL', 'SOL:USDT', 'SOL:USDC', 'SOL:WIF', 'SOL:TRUMP', 'SOL:PENGU',
// BTC
'BTC:BTC',
]);
function isBridgeable(chain: ChainCode, symbol: string): boolean {
return BRIDGEABLE_TOKENS.has(`${chain}:${symbol.toUpperCase()}`);
}
/**
* Возвращает flat-list всех известных активов: native + tokens, для всех (или одного) chain.
* Используется в GET /api/tokens.
*
* @param filterChain — если задан, фильтрует только этот chain
* @param bridgeableOnly — если true, возвращает только tokens из `BRIDGEABLE_TOKENS` allowlist
* (used by bridge/swap UI чтобы не показывать unsupported memecoins)
*/
export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean = false): TokenListEntry[] {
const out: TokenListEntry[] = [];
const chains: ChainCode[] = filterChain ? [filterChain] : ALL_CHAINS_ORDERED;
for (const chain of chains) {
// Native first
if (!bridgeableOnly || isBridgeable(chain, NATIVE_SYMBOLS[chain])) {
const lifiAddress = LIFI_NATIVE_ADDRESS[chain];
out.push({
chain,
symbol: NATIVE_SYMBOLS[chain],
name: NATIVE_NAMES[chain],
contract: null,
decimals: NATIVE_DECIMALS_LOCAL[chain],
...(lifiAddress ? { lifiAddress } : {}),
});
}
// Tokens
if (chain === 'ETH' || chain === 'BSC') {
for (const tk of getEvmTokens(chain)) {
if (bridgeableOnly && !isBridgeable(chain, tk.symbol)) continue;
out.push({ chain, symbol: tk.symbol, name: tk.name, contract: tk.contractAddress, decimals: tk.decimals });
}
} else if (chain === 'TRX') {
for (const tk of TRX_TOKENS) {
if (bridgeableOnly && !isBridgeable(chain, tk.symbol)) continue;
out.push({ chain, symbol: tk.symbol, name: tk.name, contract: tk.contractAddress, decimals: tk.decimals });
}
} else if (chain === 'SOL') {
for (const tk of SOL_TOKENS) {
if (bridgeableOnly && !isBridgeable(chain, tk.symbol)) continue;
out.push({ chain, symbol: tk.symbol, name: tk.name, contract: tk.mint, decimals: tk.decimals });
}
}
// BTC — только native (уже handled выше)
}
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;
@@ -78,3 +244,69 @@ export function getTrxTokens(): TrxToken[] {
export function getSolTokens(): SolToken[] { export function getSolTokens(): SolToken[] {
return SOL_TOKENS; return SOL_TOKENS;
} }
/**
* Universal lookup для send flow. Returns address+decimals или null если token не в registry.
* Symbol comparison case-insensitive.
*
* Usage:
* const info = getTokenInfo('BSC', 'USDC');
* // → { address: '0x8AC76a51...', decimals: 18 }
*
* const info = getTokenInfo('SOL', 'USDT');
* // → { address: 'Es9vMFrza...', decimals: 6 } (mint address)
*/
export function getTokenInfo(chain: ChainCode, symbol: string): { address: string; decimals: number } | null {
const upper = String(symbol).toUpperCase();
if (chain === 'ETH' || chain === 'BSC') {
const t = getEvmTokens(chain).find((x) => x.symbol.toUpperCase() === upper);
return t ? { address: t.contractAddress, decimals: t.decimals } : null;
}
if (chain === 'TRX') {
const t = TRX_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
return t ? { address: t.contractAddress, decimals: t.decimals } : null;
}
if (chain === 'SOL') {
const t = SOL_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
return t ? { address: t.mint, decimals: t.decimals } : null;
}
return null;
}
/**
* Resolves the CoinGecko coin id for a given (chain, symbol) pair.
*
* Если `symbol` совпадает с самим именем chain (BTC/ETH/BSC/TRX/SOL) — возвращает
* native id (`NATIVE_COINGECKO_IDS[chain]`).
* В остальных случаях ищет токен в реестре сети и возвращает его `coingeckoId`.
*
* Возвращает `null` если:
* - chain неизвестен;
* - symbol не найден в реестре сети;
* - токен найден, но `coingeckoId` для него не задан.
*
* Используется исключительно как whitelist для price oracle (см. S1 в плане):
* никакой свободный user-input не попадает в CoinGecko URL.
*/
export function getCoingeckoId(chain: ChainCode, symbol: string): string | null {
if (!chain) return null;
const upper = String(symbol || '').toUpperCase();
if (!upper) return null;
// Native — symbol === chain code (BTC, ETH, ...).
if (upper === chain) return NATIVE_COINGECKO_IDS[chain] ?? null;
if (chain === 'ETH' || chain === 'BSC') {
const t = getEvmTokens(chain).find((x) => x.symbol.toUpperCase() === upper);
return t?.coingeckoId ?? null;
}
if (chain === 'TRX') {
const t = TRX_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
return t?.coingeckoId ?? null;
}
if (chain === 'SOL') {
const t = SOL_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
return t?.coingeckoId ?? null;
}
return null;
}

View File

@@ -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;
} }

View File

@@ -66,11 +66,17 @@ export const UserModel = {
}, },
/** /**
* Set-once: возвращает true только если этот вызов реально занял slot. * Set-once: возвращает 'claimed' / 'already_has' / 'no_user' (H32).
* Защита от race: два параллельных createWallet не могут оба перезаписать. * Defense-in-depth: distinguishes wrong outcomes так что caller отдаёт правильный код:
* Также filter is_deleted=false — не давать zombie-account resurrection. * - claimed → каждый параллельный call'у вернёт already_has потом (мы выиграли gонку)
* - already_has → existing encrypted_mnemonic в DB
* - no_user → row not found OR is_deleted=true
*/ */
async setEncryptedMnemonicIfAbsent(id: string, blob: string, trx?: Knex.Transaction): Promise<boolean> { async setEncryptedMnemonicIfAbsent(
id: string,
blob: string,
trx?: Knex.Transaction,
): Promise<'claimed' | 'already_has' | 'no_user'> {
const k = trx || db; const k = trx || db;
const affected = await k('users') const affected = await k('users')
.where({ id, is_deleted: false }) .where({ id, is_deleted: false })
@@ -79,7 +85,60 @@ export const UserModel = {
encrypted_mnemonic: blob, encrypted_mnemonic: blob,
updated_at: k.fn.now(), updated_at: k.fn.now(),
}); });
return affected === 1; if (affected === 1) return 'claimed';
// Affected 0 — либо user gone, либо уже есть mnemonic. Distinguish.
const row = await k('users')
.where({ id, is_deleted: false })
.select('encrypted_mnemonic')
.first();
if (!row) return 'no_user';
return row.encrypted_mnemonic ? 'already_has' : 'no_user';
},
/**
* Claim placeholder row перед derive — экономит CPU + heap-secret для loser race.
* Используется как pre-step в createWallet flow (H27).
*/
async claimWalletSlot(id: string, trx?: Knex.Transaction): Promise<'claimed' | 'already_has' | 'no_user'> {
const k = trx || db;
const PLACEHOLDER = 'PENDING_DERIVATION';
const affected = await k('users')
.where({ id, is_deleted: false })
.whereNull('encrypted_mnemonic')
.update({
encrypted_mnemonic: PLACEHOLDER,
updated_at: k.fn.now(),
});
if (affected === 1) return 'claimed';
const row = await k('users')
.where({ id, is_deleted: false })
.select('encrypted_mnemonic')
.first();
if (!row) return 'no_user';
return row.encrypted_mnemonic && row.encrypted_mnemonic !== PLACEHOLDER ? 'already_has' : 'no_user';
},
/** Finalize after claimWalletSlot — overwrite placeholder с real blob. */
async finalizeWalletSlot(id: string, blob: string, trx?: Knex.Transaction): Promise<void> {
const k = trx || db;
const affected = await k('users')
.where({ id, is_deleted: false })
.update({
encrypted_mnemonic: blob,
updated_at: k.fn.now(),
});
if (affected !== 1) {
throw new Error(`finalizeWalletSlot: expected 1 row affected, got ${affected} for user ${id}`);
}
},
/** Check KYC status (H14) */
async isKycVerified(id: string): Promise<boolean> {
const row = await db('users')
.where({ id, is_deleted: false })
.select('kyc_verified', 'kyc_verified_at')
.first();
return Boolean(row?.kyc_verified && row?.kyc_verified_at);
}, },
async getEncryptedMnemonic(id: string): Promise<string | null> { async getEncryptedMnemonic(id: string): Promise<string | null> {
@@ -100,16 +159,18 @@ export const UserModel = {
/** /**
* Записать ETH-адрес custodial-кошелька в users.erc20. * Записать ETH-адрес custodial-кошелька в users.erc20.
* Вызывается из createWallet() внутри той же transaction что и WalletModel.createMany, * Throws (rolls back tx) if user не существует / is_deleted (H31).
* чтобы rollback был consistent (без orphan записей).
*/ */
async setErc20Address(id: string, address: string, trx?: Knex.Transaction): Promise<void> { async setErc20Address(id: string, address: string, trx?: Knex.Transaction): Promise<void> {
const k = trx || db; const k = trx || db;
await k('users') const affected = await k('users')
.where({ id, is_deleted: false }) .where({ id, is_deleted: false })
.update({ .update({
erc20: address, erc20: address,
updated_at: k.fn.now(), updated_at: k.fn.now(),
}); });
if (affected !== 1) {
throw new Error(`setErc20Address: user ${id} not found or deleted (affected=${affected})`);
}
}, },
}; };

View File

@@ -0,0 +1,266 @@
/**
* Unified bridge execute endpoint — one-click "Подтвердить" для bridge через Jumper/Relay.
*
* Single endpoint POST /api/bridge/execute:
* - JWT-bind: fromAddress ≡ user's wallet для source chain (защита от submitting attacker's address)
* - Idempotency-Key: anti-double-spend на retry
* - Anti-MEV: server повторно квотирует и проверяет toAmountMin ≥ acceptedMinOut
* - Audit log: каждый execute = row в audit_log с txid'ами
* - Dispatch к executeBridge() который сам выбирает signing path per chain
*
* Mount: `app.use('/api/bridge', ...protect, mutateLimiter, bridgeRoutes)` в app.ts.
*/
import { Request, Response, Router } from 'express';
import { logger } from '../lib/logger';
import { WalletModel } from '../models/wallet.model';
import { UserModel } from '../models/user.model';
import { decryptMnemonic } from '../services/crypto.service';
import { auditLog, auditLogStrict, completeAudit } from '../lib/audit-log';
import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency';
import { executeBridge, type BridgeProvider } from '../services/bridge-execute.service';
import type { ChainCode } from '../lib/address-validators';
const router = Router();
// LiFi/Relay chainId → наш ChainCode. Source chain должен быть из этого map'а
// для JWT-bind'а. Destination chain — без ограничений (bridge solver сам доставит куда угодно).
const CHAINID_TO_CHAIN: Record<number, ChainCode> = {
1: 'ETH',
56: 'BSC',
1151111081099710: 'SOL',
792703809: 'SOL',
728126428: 'TRX',
20000000000001: 'BTC',
8253038: 'BTC',
};
router.post('/execute', executeHandler);
export default router;
async function executeHandler(req: Request, res: Response): Promise<void> {
const userId = (req as any).auth?.userId;
if (!userId) {
res.status(401).json({ success: false, error: 'auth required' });
return;
}
// ── 1. Parse + validate body ──
const body = req.body || {};
const provider = String(body.provider || '').toLowerCase() as BridgeProvider;
if (provider !== 'jumper' && provider !== 'relay') {
res.status(400).json({ success: false, error: 'provider must be "jumper" or "relay"' });
return;
}
const fromChain = Number(body.fromChain);
const toChain = Number(body.toChain);
const fromToken = String(body.fromToken || '');
const toToken = String(body.toToken || '');
const fromAmount = String(body.fromAmount || '');
const fromAddress = String(body.fromAddress || '');
const toAddress = String(body.toAddress || '');
const acceptedMinOut = String(body.acceptedMinOut || '0');
if (!Number.isFinite(fromChain) || !Number.isFinite(toChain)) {
res.status(400).json({ success: false, error: 'fromChain/toChain must be numeric' });
return;
}
if (!fromToken || !toToken) {
res.status(400).json({ success: false, error: 'fromToken/toToken required' });
return;
}
if (!/^\d+$/.test(fromAmount) || fromAmount === '0') {
res.status(400).json({ success: false, error: 'fromAmount must be positive integer string (smallest units)' });
return;
}
if (!/^\d+$/.test(acceptedMinOut)) {
res.status(400).json({ success: false, error: 'acceptedMinOut must be integer string' });
return;
}
if (!fromAddress || !toAddress) {
res.status(400).json({ success: false, error: 'fromAddress/toAddress required' });
return;
}
const BTC_NATIVE_FROM_TOKENS = new Set(['bitcoin', 'bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8']);
if (CHAINID_TO_CHAIN[fromChain] === 'BTC' && !BTC_NATIVE_FROM_TOKENS.has(fromToken)) {
res.status(400).json({
success: false,
error: 'BTC bridge supports native only: fromToken must be "bitcoin" (LiFi sentinel)',
});
return;
}
// ── 2. JWT-bind: fromAddress = user's source-chain wallet ──
const sourceCode = CHAINID_TO_CHAIN[fromChain];
if (!sourceCode) {
res.status(400).json({
success: false,
error: `Unsupported source chainId ${fromChain} (allowed: 1, 56, 1151111081099710, 792703809, 728126428, 20000000000001, 8253038)`,
});
return;
}
const fromWallet = await WalletModel.findByUserAndChain(userId, sourceCode);
if (!fromWallet) {
res.status(403).json({ success: false, error: `No ${sourceCode} wallet for user — create wallet first` });
return;
}
const isEvm = sourceCode === 'ETH' || sourceCode === 'BSC';
const fromMatch = isEvm
? fromAddress.toLowerCase() === fromWallet.address.toLowerCase()
: fromAddress === fromWallet.address;
if (!fromMatch) {
res.status(403).json({
success: false,
error: `fromAddress ${fromAddress} ≠ user's ${sourceCode} wallet ${fromWallet.address}`,
});
return;
}
// toAddress: если destination chain в нашем DB → bind. Иначе — skip (LiFi бридж в Avalanche/Polygon etc.)
const destCode = CHAINID_TO_CHAIN[toChain];
let expectedToAddress = toAddress; // default = client-provided (для unsupported chains)
if (destCode) {
const toWallet = await WalletModel.findByUserAndChain(userId, destCode);
if (toWallet) {
const destEvm = destCode === 'ETH' || destCode === 'BSC';
const toMatch = destEvm
? toAddress.toLowerCase() === toWallet.address.toLowerCase()
: toAddress === toWallet.address;
if (!toMatch) {
res.status(403).json({
success: false,
error: `toAddress ${toAddress} ≠ user's ${destCode} wallet ${toWallet.address}`,
});
return;
}
expectedToAddress = toWallet.address;
} else {
logger.warn(`Bridge execute: dest chain ${destCode} not in user wallets — skip dest bind`);
}
}
// ── 3. Idempotency claim ──
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
if (idempKey) {
try {
const claim = await claimIdempotency(userId, idempKey, body);
if (!claim.fresh && claim.cached) {
res.status(claim.cached.status).type('application/json').send(claim.cached.body);
return;
}
} catch (err: any) {
res.status(409).json({ success: false, error: err.message });
return;
}
}
// ── 4. Audit row BEFORE broadcast (strict — must succeed) ──
let auditId: string;
try {
auditId = await auditLogStrict({
event: 'bridge.execute',
userId,
ip: req.ip || null,
meta: {
provider,
fromChain,
toChain,
fromToken,
toToken,
fromAmount,
acceptedMinOut,
},
});
} catch (auditErr: any) {
logger.error(`Audit DB INSERT MUST succeed for bridge.execute: ${auditErr.message}`);
res.status(503).json({ success: false, error: 'Audit service unavailable' });
return;
}
// ── 5. Decrypt mnemonic ──
const blob = await UserModel.getEncryptedMnemonic(userId);
if (!blob) {
await completeAudit(auditId, 'failure', undefined, 'NO_MNEMONIC');
res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' });
return;
}
let mnemonic: string;
try {
mnemonic = decryptMnemonic(blob);
} catch (err: any) {
await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED');
res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' });
return;
}
// ── 6. Execute bridge (dispatcher inside) ──
try {
const result = await executeBridge({
provider,
fromChain,
toChain,
fromToken,
toToken,
fromAmount,
fromAddress: fromWallet.address,
toAddress,
acceptedMinOut,
mnemonic,
expectedFromAddress: fromWallet.address,
expectedToAddress,
});
await completeAudit(auditId, 'success');
// Best-effort: extra audit row с txid'ами для удобства audit reports
auditLog({
event: 'bridge.execute.broadcast',
userId,
ip: req.ip || null,
result: 'success',
meta: {
provider,
fromChain,
toChain,
approveTxid: result.approveTxid,
feeTxid: result.feeTxid,
bridgeTxid: result.bridgeTxid,
toolName: result.toolName,
},
}).catch(() => {});
const respBody = { success: true, data: result };
if (idempKey) {
try {
await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(respBody));
} catch { /* ignore */ }
}
res.status(200).json(respBody);
} catch (err: any) {
const code =
err?.code === 'PRICE_MOVED' ? 409 :
err?.code === 'INSUFFICIENT_BALANCE' ? 400 :
err?.code === 'SIMULATION_FAILED' ? 400 :
err?.code === 'NO_ROUTE' ? 400 :
err?.code === 'NOT_IMPLEMENTED' ? 501 :
502;
await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80));
logger.warn(`Bridge execute failed: provider=${provider} fromChain=${fromChain}${err?.message}`);
const respBody = {
success: false,
error: err?.message || 'bridge execute failed',
code: err?.code,
};
if (idempKey) {
try {
await saveIdempotencyResponse(userId, idempKey, code, JSON.stringify(respBody));
} catch { /* ignore */ }
}
res.status(code).json(respBody);
} finally {
// Zeroize sensitive — best effort на JS strings (mostly cosmetic, real protection = process exit)
mnemonic = '';
}
}

View File

@@ -1,218 +0,0 @@
import { Request, Response, Router } from 'express';
import { ethers } from 'ethers';
import { logger } from '../lib/logger';
import { assertUserOwnsAddress } from '../lib/wallet-binding';
const router = Router();
const BSC_RPC = 'https://bsc-dataseed.binance.org';
const BSC_CHAIN_ID = 56;
const BSC_TIMEOUT_MS = 15_000;
// PancakeSwap V2 Router
const PANCAKE_ROUTER = '0x10ED43C718714eb63d5aA57B78B54704E256024E';
const WBNB = '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c';
// Supported tokens
const TOKEN_MAP: Record<string, string> = {
BNB: WBNB,
USDT: '0x55d398326f99059fF775485246999027B3197955',
DOGE: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
};
const TOKEN_DECIMALS: Record<string, number> = {
BNB: 18,
USDT: 18,
DOGE: 8,
};
const ROUTER_ABI = [
'function getAmountsOut(uint amountIn, address[] calldata path) external view returns (uint[] memory amounts)',
'function swapExactETHForTokensSupportingFeeOnTransferTokens(uint amountOutMin, address[] calldata path, address to, uint deadline) external payable',
'function swapExactTokensForETHSupportingFeeOnTransferTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external',
];
const ERC20_ABI = [
'function approve(address spender, uint256 amount) external returns (bool)',
'function allowance(address owner, address spender) external view returns (uint256)',
];
router.get('/quote', getSwapQuote);
router.post('/build', buildSwapTx);
export default router;
// ─── GET /quote ───
async function getSwapQuote(req: Request, res: Response) {
const from = String(req.query.from || '').toUpperCase();
const to = String(req.query.to || '').toUpperCase();
const amount = String(req.query.amount || '');
if (!TOKEN_MAP[from] || !TOKEN_MAP[to]) {
res.status(400).json({ success: false, error: 'Invalid from/to. Supported: BNB, USDT, DOGE' });
return;
}
if (from === to) {
res.status(400).json({ success: false, error: 'from and to must be different' });
return;
}
const amountBigInt = BigInt(amount || '0');
if (amountBigInt <= 0n) {
res.status(400).json({ success: false, error: 'amount must be positive' });
return;
}
try {
const provider = new ethers.providers.StaticJsonRpcProvider(BSC_RPC, BSC_CHAIN_ID);
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
const path = [TOKEN_MAP[from], TOKEN_MAP[to]];
const amounts: ethers.BigNumber[] = await withTimeout(
routerContract.getAmountsOut(amount, path),
BSC_TIMEOUT_MS,
'PancakeSwap quote timed out'
);
const amountOut = amounts[amounts.length - 1].toString();
res.json({
success: true,
amountIn: amountBigInt.toString(),
amountOut,
from,
to,
fromDecimals: TOKEN_DECIMALS[from],
toDecimals: TOKEN_DECIMALS[to],
});
} catch (error) {
logger.error(`BSC quote failed: ${(error as any)?.stack || (error as any)?.message}`);
res.status(502).json({ success: false, error: 'Failed to get BSC swap quote' });
}
}
// ─── POST /build ───
async function buildSwapTx(req: Request, res: Response) {
const { from, to, amount, amountOutMin, userAddress } = req.body;
if (!from || !to || !amount || !amountOutMin || !userAddress) {
res.status(400).json({ success: false, error: 'Missing required fields: from, to, amount, amountOutMin, userAddress' });
return;
}
const fromUpper = String(from).toUpperCase();
const toUpper = String(to).toUpperCase();
if (!TOKEN_MAP[fromUpper] || !TOKEN_MAP[toUpper] || fromUpper === toUpper) {
res.status(400).json({ success: false, error: 'Invalid from/to pair' });
return;
}
if (!ethers.utils.isAddress(userAddress)) {
res.status(400).json({ success: false, error: 'Invalid BSC address' });
return;
}
// C17 — bind userAddress to JWT. Иначе attacker может set userAddress=victim_addr
// и output swap'а пойдёт victim'у/контракту-attacker'у (reentrancy vector).
const userId = req.auth!.userId;
try {
await assertUserOwnsAddress(userId, 'BSC', userAddress);
} catch (err: any) {
res.status(403).json({ success: false, error: err.message });
return;
}
// Validate amount + amountOutMin: positive integers > 0 (string-encoded BigInt).
// "0" truthy bypass — без этого attacker может выставить amountOutMin=0 = 100% slippage
// → sandwich attack осушает swap.
if (!/^\d+$/.test(String(amount)) || BigInt(amount) <= 0n) {
res.status(400).json({ success: false, error: 'Invalid amount (must be positive integer string)' });
return;
}
if (!/^\d+$/.test(String(amountOutMin)) || BigInt(amountOutMin) <= 0n) {
res.status(400).json({ success: false, error: 'Invalid amountOutMin (must be positive; 0 = unlimited slippage)' });
return;
}
try {
const provider = new ethers.providers.StaticJsonRpcProvider(BSC_RPC, BSC_CHAIN_ID);
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
const deadline = Math.floor(Date.now() / 1000) + 1200; // 20 minutes
const path = [TOKEN_MAP[fromUpper], TOKEN_MAP[toUpper]];
const transactions: Array<{ type: string; to: string; data: string; value: string }> = [];
if (fromUpper === 'BNB') {
// BNB → Token: swapExactETHForTokensSupportingFeeOnTransferTokens
const data = routerContract.interface.encodeFunctionData(
'swapExactETHForTokensSupportingFeeOnTransferTokens',
[amountOutMin, path, userAddress, deadline]
);
transactions.push({
type: 'swap',
to: PANCAKE_ROUTER,
data,
value: amount, // BNB amount in wei
});
} else {
// Token → BNB: check allowance, build approve if needed, then swap
const tokenAddress = TOKEN_MAP[fromUpper];
const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
const currentAllowance: ethers.BigNumber = await withTimeout(
tokenContract.allowance(userAddress, PANCAKE_ROUTER),
BSC_TIMEOUT_MS,
'Allowance check timed out'
);
if (currentAllowance.lt(ethers.BigNumber.from(amount))) {
// Exact-amount approve (НЕ MaxUint256!). Infinite approval = post-tx attack vector:
// если router compromised или attacker узнаёт private key позже, attacker дренит
// всё что approved. Approve только то что нужно сейчас.
const approveData = tokenContract.interface.encodeFunctionData(
'approve',
[PANCAKE_ROUTER, amount]
);
transactions.push({
type: 'approve',
to: tokenAddress,
data: approveData,
value: '0',
});
}
// Build swap tx
const swapData = routerContract.interface.encodeFunctionData(
'swapExactTokensForETHSupportingFeeOnTransferTokens',
[amount, amountOutMin, path, userAddress, deadline]
);
transactions.push({
type: 'swap',
to: PANCAKE_ROUTER,
data: swapData,
value: '0',
});
}
res.json({ success: true, transactions });
} catch (error) {
logger.error(`BSC build failed: ${(error as any)?.stack || (error as any)?.message}`);
res.status(502).json({ success: false, error: 'Failed to build BSC swap' });
}
}
// ─── Utils ───
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => { clearTimeout(timeoutId); resolve(value); })
.catch((error) => { clearTimeout(timeoutId); reject(error); });
});
}

View File

@@ -0,0 +1,456 @@
/**
* Jumper.xyz bridge proxy — forward к LiFi API (li.quest/v1).
*
* Jumper.xyz использует LiFi как routing engine. Поддерживает bridges/swaps между 50+ chains
* включая TRX, BTC, ETH, BSC, SOL — те которые наш Relay proxy не поддерживает (TRX/BTC).
*
* Pattern идентичен `relay-proxy.routes.ts`:
* - Whitelist allowed paths (path traversal guard).
* - JWT-binding: `body.fromAddress` (POST) или `?fromAddress` (GET) должен совпадать с
* user's wallet на `fromChain` — если этот chain известен в нашем DB. Иначе skip bind.
* - Outbound через `proxiedFetch` (если задан OUTBOUND_PROXY_URL).
* - Force JSON content-type на response (anti-XSS).
* - Все upstream errors прокидываются клиенту с structured envelope.
*
* Mount: `app.use('/api/jumper', ...protect, mutateLimiter, jumperRoutes)` в app.ts.
*/
import { NextFunction, Request, Response, Router } from 'express';
import { logger } from '../lib/logger';
import { WalletModel } from '../models/wallet.model';
import type { ChainCode } from '../lib/address-validators';
import { proxiedFetch } from '../lib/outbound-proxy';
import { getTokensForChains } from '../lib/token-registry';
const router = Router();
const LIFI_API_URL = 'https://li.quest/v1';
const LIFI_TIMEOUT_MS = 20_000;
/**
* LiFi chainIds → наш ChainCode. LiFi использует custom IDs для не-EVM:
* - SOL: 1151111081099710 (КАРДИНАЛЬНО отличается от Relay's 792703809)
* - TRX: 728126428 (стандартный Tron chainId)
* - BTC: 20000000000001 (LiFi custom)
* EVM как обычно: ETH=1, BSC=56.
*
* Если в `body.fromChain` придёт что-то НЕ из этого map'а — bind skip
* (LiFi поддерживает 50+ chains, у нас wallet'ы только для 5).
*/
const JUMPER_CHAINID_TO_CHAIN: Record<number, ChainCode> = {
1: 'ETH',
56: 'BSC',
1151111081099710: 'SOL',
728126428: 'TRX',
20000000000001: 'BTC',
};
const ALLOWED_JUMPER_CHAIN_IDS = new Set(Object.keys(JUMPER_CHAINID_TO_CHAIN).map(Number));
const JUMPER_CHAIN_BY_CODE: Partial<Record<ChainCode, number>> = Object.entries(JUMPER_CHAINID_TO_CHAIN)
.reduce((acc, [chainId, code]) => ({ ...acc, [code]: Number(chainId) }), {});
const JUMPER_NATIVE_SENTINELS: Partial<Record<ChainCode, string>> = {
ETH: '0x0000000000000000000000000000000000000000',
BSC: '0x0000000000000000000000000000000000000000',
SOL: '11111111111111111111111111111111',
TRX: 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb',
BTC: 'bitcoin',
};
const LOCAL_JUMPER_CHAINS = [
{ key: 'eth', chainType: 'EVM', name: 'Ethereum', coin: 'ETH', id: 1, mainnet: true },
{ key: 'bsc', chainType: 'EVM', name: 'BSC', coin: 'BNB', id: 56, mainnet: true },
{ key: 'sol', chainType: 'SVM', name: 'Solana', coin: 'SOL', id: 1151111081099710, mainnet: true },
{ key: 'trx', chainType: 'TVM', name: 'Tron', coin: 'TRX', id: 728126428, mainnet: true },
{ key: 'btc', chainType: 'UTXO', name: 'Bitcoin', coin: 'BTC', id: 20000000000001, mainnet: true },
];
// Whitelist: GET-paths + POST-paths. Запрещён любой другой путь (path traversal).
const ALLOWED_GET_PATHS = new Set([
'/quote', // single best route
'/status', // bridge intent status poll
'/chains', // list supported chains
'/tools', // list supported bridges/exchanges
'/tokens', // list supported tokens
'/connections', // routes между конкретной парой
'/quote-best', // LOCAL alias — пробует NearIntents, fallback на best route
]);
const ALLOWED_POST_PATHS = new Set([
'/advanced/routes', // multi-route preview (POST body со всем routing prefs)
'/advanced/stepTransaction', // get single step tx for a route step
]);
router.use(proxyJumperRequest);
export default router;
async function proxyJumperRequest(req: Request, res: Response, _next: NextFunction) {
try {
const jumperPath = req.path;
let allowed = false;
if (req.method === 'GET' && ALLOWED_GET_PATHS.has(jumperPath)) {
allowed = true;
} else if (req.method === 'POST' && ALLOWED_POST_PATHS.has(jumperPath)) {
allowed = true;
}
if (!allowed) {
res.status(404).json({ success: false, error: 'Jumper endpoint not allowed' });
return;
}
// C16 — bind fromAddress to JWT user's wallet (если chain в нашем DB).
// Без bind'а authenticated user мог бы построить quote с fromAddress=attacker'а,
// подписать через /sign-raw-evm-tx (мы fee-payer'а проверяем там) — двойная защита.
if (
(req.method === 'POST' && (jumperPath === '/advanced/routes' || jumperPath === '/advanced/stepTransaction')) ||
(req.method === 'GET' && (jumperPath === '/quote' || jumperPath === '/quote-best'))
) {
const userId = (req as any).auth?.userId;
if (!userId) {
res.status(401).json({ success: false, error: 'auth required' });
return;
}
// GET: query params. POST: body.
const fromAddress = req.method === 'GET'
? String(req.query.fromAddress || '')
: String(req.body?.fromAddress || '');
const fromChainRaw = req.method === 'GET'
? Number(req.query.fromChain)
: Number(req.body?.fromChain);
if (!fromAddress) {
res.status(400).json({ success: false, error: 'Missing fromAddress' });
return;
}
if (!Number.isFinite(fromChainRaw)) {
res.status(400).json({ success: false, error: 'Missing or invalid fromChain (numeric)' });
return;
}
const ourChain = JUMPER_CHAINID_TO_CHAIN[fromChainRaw];
if (ourChain) {
// Bind на наш wallet
try {
const wallet = await WalletModel.findByUserAndChain(userId, ourChain);
if (!wallet) {
res.status(403).json({
success: false,
error: `No ${ourChain} wallet for user — cannot bind fromAddress`,
});
return;
}
const isEvm = ourChain === 'ETH' || ourChain === 'BSC';
const match = isEvm
? fromAddress.toLowerCase() === wallet.address.toLowerCase()
: fromAddress === wallet.address;
if (!match) {
res.status(403).json({
success: false,
error: `fromAddress ${fromAddress} ≠ user's ${ourChain} wallet ${wallet.address}`,
});
return;
}
} catch (err: any) {
res.status(403).json({ success: false, error: err.message });
return;
}
} else {
// Chain не в нашем DB (Avalanche, Optimism, etc.). LiFi поддерживает 50+ chain.
// Bind skip — юзер сам несёт ответственность за корректность адреса.
logger.warn(`Jumper proxy: fromChain=${fromChainRaw} not in our wallet DB — skipping bind for userId=${userId}`);
}
}
// /quote-best — local handler. Пробуем NearIntents first → fallback на best route.
if (req.method === 'GET' && jumperPath === '/quote-best') {
return handleQuoteBest(req, res);
}
// Forward query params.
const lifiUrl = new URL(`${LIFI_API_URL}${jumperPath}`);
Object.entries(req.query).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => lifiUrl.searchParams.append(key, String(item)));
return;
}
if (typeof value !== 'undefined') {
lifiUrl.searchParams.set(key, String(value));
}
});
// Explicit timeout via AbortController.
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), LIFI_TIMEOUT_MS);
let upstream: globalThis.Response;
try {
upstream = await proxiedFetch(lifiUrl.toString(), {
method: req.method,
headers: {
Accept: 'application/json',
...(req.method !== 'GET' ? { 'Content-Type': 'application/json' } : {}),
},
body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}),
signal: controller.signal,
});
} finally {
clearTimeout(t);
}
// Force JSON content-type (anti-XSS, S5 в relay-proxy).
res.status(upstream.status);
res.type('application/json');
const text = await upstream.text();
if (!upstream.ok) {
logger.warn(`Jumper (LiFi) upstream ${upstream.status}: ${text.slice(0, 200)}`);
let parsed: unknown = null;
try { parsed = JSON.parse(text); } catch { /* not JSON */ }
if (parsed && typeof parsed === 'object') {
res.json({ success: false, error: 'Jumper upstream error', upstream: parsed });
} else {
res.json({ success: false, error: 'Jumper upstream error' });
}
return;
}
try {
const filtered = filterJumperMetadata(jumperPath, text);
if (filtered) {
res.json(filtered);
return;
}
res.send(text);
} catch {
res.json({ success: false, error: 'Jumper returned non-JSON' });
return;
}
} catch (error: any) {
if (error?.name === 'AbortError') {
res.status(504).json({ success: false, error: 'Jumper request timeout' });
return;
}
logger.error(`Jumper proxy failed: ${error?.stack || error?.message}`);
res.status(502).json({ success: false, error: 'Jumper proxy error' });
}
}
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.
*
* Логика:
* 1. Пытаемся LiFi `/quote?...&allowBridges=near` — если NearIntents поддерживает пару → return.
* 2. Если 404/no route → LiFi `/quote?...` без filter → берём best route любого типа.
*
* Response = upstream LiFi quote + дополнительное поле `_source` ('near' или 'best').
*/
async function handleQuoteBest(req: Request, res: Response): Promise<void> {
const baseParams = new URLSearchParams();
Object.entries(req.query).forEach(([key, value]) => {
if (key === 'allowBridges' || key === 'denyBridges') return; // ignore client filter — мы сами управляем
if (Array.isArray(value)) {
value.forEach((item) => baseParams.append(key, String(item)));
} else if (typeof value !== 'undefined') {
baseParams.set(key, String(value));
}
});
// Helper для одного LiFi call.
async function tryLiFiQuote(extraParam?: { key: string; value: string }): Promise<{ ok: boolean; status: number; body: any }> {
const params = new URLSearchParams(baseParams);
if (extraParam) params.set(extraParam.key, extraParam.value);
const url = `${LIFI_API_URL}/quote?${params.toString()}`;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), LIFI_TIMEOUT_MS);
try {
const upstream = await proxiedFetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: ctrl.signal,
});
const text = await upstream.text();
let parsed: any = null;
try { parsed = JSON.parse(text); } catch { /* not JSON */ }
return { ok: upstream.ok, status: upstream.status, body: parsed ?? { _raw: text.slice(0, 300) } };
} finally {
clearTimeout(t);
}
}
res.type('application/json');
try {
// Step 1 — NearIntents only.
const nearRes = await tryLiFiQuote({ key: 'allowBridges', value: 'near' });
if (nearRes.ok && nearRes.body && (nearRes.body.estimate || nearRes.body.action)) {
res.status(200).json({ ...nearRes.body, _source: 'near' });
return;
}
logger.info(`Jumper /quote-best: NearIntents unavailable (status=${nearRes.status}); falling back to best route`);
// Step 2 — fallback на любой best route.
const bestRes = await tryLiFiQuote();
if (bestRes.ok && bestRes.body && (bestRes.body.estimate || bestRes.body.action)) {
res.status(200).json({ ...bestRes.body, _source: 'best' });
return;
}
// Оба варианта не дали валидный route.
res.status(bestRes.status || 502).json({
success: false,
error: 'No bridge route found (tried NearIntents + best)',
upstream: bestRes.body ?? nearRes.body,
});
} catch (error: any) {
if (error?.name === 'AbortError') {
res.status(504).json({ success: false, error: 'LiFi quote timeout' });
return;
}
logger.error(`handleQuoteBest failed: ${error?.stack || error?.message}`);
res.status(502).json({ success: false, error: 'Quote-best failed' });
}
}

View File

@@ -0,0 +1,10 @@
import { Router } from 'express';
import { PricesController } from '../controllers/prices.controller';
const router = Router();
// IMPORTANT: /dynamics ПЕРЕД / (Express specific-first)
router.get('/dynamics', PricesController.getDynamics);
router.get('/', PricesController.getPrices);
export default router;

View File

@@ -3,6 +3,9 @@ import { env } from '../config/env';
import { logger } from '../lib/logger'; 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 { indexRelayExecuteResponse } from '../lib/relay-trusted-cache';
import { proxiedFetch } from '../lib/outbound-proxy';
import { getDecimalsByContract, parseHumanAmount } from '../lib/amount-units';
const router = Router(); const router = Router();
const RELAY_API_URL = 'https://api.relay.link'; const RELAY_API_URL = 'https://api.relay.link';
@@ -19,7 +22,9 @@ const RELAY_CHAINID_TO_CHAIN: Record<number, ChainCode> = {
// Без него `req.path` после `/execute/` свободен → `/execute/../admin` обходит startsWith-check. // Без него `req.path` после `/execute/` свободен → `/execute/../admin` обходит startsWith-check.
// Relay API: `POST /quote` (раньше был `GET /quote/v2` — upstream сменили в 2025). // Relay API: `POST /quote` (раньше был `GET /quote/v2` — upstream сменили в 2025).
const ALLOWED_GET_PATHS = new Set(['/intents/status/v3']); const ALLOWED_GET_PATHS = new Set(['/intents/status/v3']);
const ALLOWED_POST_PATHS = new Set(['/quote']); // `/cost-estimate` — LOCAL alias (not a Relay endpoint). Internally calls Relay /quote и
// фильтрует response — отдаёт только fees + details (без steps[]).
const ALLOWED_POST_PATHS = new Set(['/quote', '/cost-estimate']);
const ALLOWED_EXECUTE_ACTIONS = new Set([ const ALLOWED_EXECUTE_ACTIONS = new Set([
'swap', 'swap',
'bridge', 'bridge',
@@ -53,10 +58,14 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
return; return;
} }
// Detect local-only /cost-estimate endpoint — internally forwarded к Relay /quote,
// response trimmed (без steps[]).
const isCostEstimate = req.method === 'POST' && relayPath === '/cost-estimate';
// C16 — bind body.user / body.recipient to JWT user's wallet. // C16 — bind body.user / body.recipient to JWT user's wallet.
// Без этого authenticated user может set recipient=attacker → Relay строит quote → // Без этого authenticated user может set recipient=attacker → Relay строит quote →
// victim signs → bridge funds к attacker'у. // victim signs → bridge funds к attacker'у.
if (req.method === 'POST' && (relayPath === '/quote' || relayPath.startsWith('/execute/'))) { if (req.method === 'POST' && (relayPath === '/quote' || relayPath === '/cost-estimate' || relayPath.startsWith('/execute/'))) {
const userId = (req as any).auth?.userId; const userId = (req as any).auth?.userId;
if (!userId) { if (!userId) {
res.status(401).json({ success: false, error: 'auth required' }); res.status(401).json({ success: false, error: 'auth required' });
@@ -112,7 +121,45 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
} }
} }
const relayUrl = new URL(`${RELAY_API_URL}${relayPath}`); // ADDITIVE: amountHuman preprocessing для /quote, /cost-estimate, /execute/*.
// Если body содержит amountHuman → разрешаем через originCurrency contract → decimals.
// Старое поле `amount` (smallest units) продолжает работать unchanged.
if (req.method === 'POST' &&
(relayPath === '/quote' || relayPath === '/cost-estimate' || relayPath.startsWith('/execute/'))) {
const body = req.body ?? {};
const hasAmount = body.amount !== undefined && body.amount !== null && body.amount !== '';
const hasAmountHuman = body.amountHuman !== undefined && body.amountHuman !== null && body.amountHuman !== '';
if (hasAmount && hasAmountHuman) {
res.status(400).json({ success: false, error: 'Use either "amount" or "amountHuman", not both' });
return;
}
if (hasAmountHuman) {
const originCurrency = String(body.originCurrency ?? '');
const originChainId = Number(body.originChainId);
const originChain = RELAY_CHAINID_TO_CHAIN[originChainId];
if (!originChain) {
res.status(400).json({ success: false, error: `originChainId ${originChainId} not in allowlist (needed для amountHuman → decimals)` });
return;
}
const dec = getDecimalsByContract(originChain, originCurrency);
if (dec == null) {
res.status(400).json({ success: false, error: `Unknown originCurrency "${originCurrency}" — supply "amount" (smallest units) directly` });
return;
}
try {
const resolved = parseHumanAmount(String(body.amountHuman), dec);
req.body.amount = resolved;
delete req.body.amountHuman;
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
return;
}
}
}
// Map local /cost-estimate → real Relay /quote endpoint.
const upstreamPath = isCostEstimate ? '/quote' : relayPath;
const relayUrl = new URL(`${RELAY_API_URL}${upstreamPath}`);
Object.entries(req.query).forEach(([key, value]) => { Object.entries(req.query).forEach(([key, value]) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@@ -130,7 +177,9 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
let upstream: globalThis.Response; let upstream: globalThis.Response;
try { try {
upstream = await fetch(relayUrl.toString(), { // Через OUTBOUND_PROXY_URL если задан (bridge path) — Relay calls идут через proxy.
// Fallback на native fetch если env пустой.
upstream = await proxiedFetch(relayUrl.toString(), {
method: req.method, method: req.method,
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@@ -165,11 +214,62 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
return; return;
} }
// /cost-estimate — trim response (только fees + details, без steps[]).
if (isCostEstimate) {
let trimmed: any;
try {
const full = JSON.parse(text);
const fees = full?.fees ?? {};
let totalUsd: number | null = 0;
for (const k of ['gas', 'relayer', 'app']) {
const u = Number(fees?.[k]?.amountUsd);
if (Number.isFinite(u)) totalUsd += u;
else { totalUsd = null; break; }
}
trimmed = {
success: true,
data: {
fees: {
gas: fees.gas ?? null,
relayer: fees.relayer ?? null,
app: fees.app ?? null,
total: { amountUsd: totalUsd },
},
rate: full?.details?.rate ?? null,
priceImpactPct: full?.details?.totalImpact?.percent ?? null,
priceImpactUsd: full?.details?.totalImpact?.usd ?? null,
timeEstimate: full?.details?.timeEstimate ?? null,
currencyIn: full?.details?.currencyIn ?? null,
currencyOut: full?.details?.currencyOut ?? null,
},
};
} catch {
trimmed = { success: false, error: 'Relay returned non-JSON for /cost-estimate' };
}
res.json(trimmed);
return;
}
// Send raw text если это валидный JSON, иначе обернём // Send raw text если это валидный JSON, иначе обернём
try { try {
res.send(text); res.send(text);
} catch { } catch {
res.json({ success: false, error: 'Relay returned non-JSON' }); res.json({ success: false, error: 'Relay returned non-JSON' });
return;
}
// Indexing trusted Relay addresses в KeyDB (для последующего sign-raw-evm-tx).
// Только для /execute/* — там steps[].items[].data.to/data парсятся.
// Fire-and-forget — не блокирует response.
if (req.method === 'POST' && relayPath.startsWith('/execute/')) {
try {
const parsed = JSON.parse(text);
indexRelayExecuteResponse(parsed).catch((err) =>
logger.warn(`indexRelayExecuteResponse error (ignored): ${err?.message || 'unknown'}`),
);
} catch {
// ignore — already shipped response к юзеру
}
} }
} catch (error: any) { } catch (error: any) {
if (error?.name === 'AbortError') { if (error?.name === 'AbortError') {

View File

@@ -1,210 +0,0 @@
import { Request, Response, Router } from 'express';
import { env } from '../config/env';
import { logger } from '../lib/logger';
import { assertUserOwnsAddress } from '../lib/wallet-binding';
import { PublicKey } from '@solana/web3.js';
const router = Router();
const JUPITER_BASE = 'https://api.jup.ag/swap/v1';
const JUPITER_TIMEOUT_MS = 15_000;
const ALLOWED_MINTS = new Set([
'So11111111111111111111111111111111111111112', // SOL
'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', // USDT
'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC
'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', // PUMP
'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', // JUP
'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', // WIF
'7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', // POPCAT
'6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', // TRUMP
'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', // PYTH
'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', // JTO
'85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', // W
'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', // BONK
'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', // ORCA
'2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', // PENGU
'4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', // RAY
]);
router.get('/quote', getQuote);
router.post('/build', buildSwap);
export default router;
/**
* GET /api/sol/swap/quote
* Proxies to Jupiter GET /v6/quote
*/
async function getQuote(req: Request, res: Response) {
const { inputMint, outputMint, amount, slippageBps } = req.query;
if (!inputMint || !outputMint || !amount || !slippageBps) {
res.status(400).json({ success: false, error: 'Missing required params: inputMint, outputMint, amount, slippageBps' });
return;
}
if (!ALLOWED_MINTS.has(String(inputMint)) || !ALLOWED_MINTS.has(String(outputMint))) {
res.status(400).json({ success: false, error: 'Token mint not in whitelist' });
return;
}
if (inputMint === outputMint) {
res.status(400).json({ success: false, error: 'inputMint and outputMint must be different' });
return;
}
const parsedAmount = parseInt(String(amount), 10);
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
res.status(400).json({ success: false, error: 'amount must be a positive integer' });
return;
}
const parsedSlippage = parseInt(String(slippageBps), 10);
if (!Number.isFinite(parsedSlippage) || parsedSlippage < 1 || parsedSlippage > 500) {
res.status(400).json({ success: false, error: 'slippageBps must be 1-500 (max 5%)' });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS);
try {
const url = new URL(`${JUPITER_BASE}/quote`);
url.searchParams.set('inputMint', String(inputMint));
url.searchParams.set('outputMint', String(outputMint));
url.searchParams.set('amount', String(parsedAmount));
// H38 — forward PARSED value, not raw query string ("300abc" → "300" но raw forwarded as "300abc")
url.searchParams.set('slippageBps', String(parsedSlippage));
// Platform fee (0.7%) — Jupiter deducts this natively
if (env.jupiterFeeBps > 0) {
url.searchParams.set('platformFeeBps', String(env.jupiterFeeBps));
}
const headers: Record<string, string> = { Accept: 'application/json' };
if (env.jupiterApiKey) {
headers['x-api-key'] = env.jupiterApiKey;
}
const response = await fetch(url.toString(), { headers, signal: controller.signal });
if (!response.ok) {
const text = await response.text().catch(() => '');
// НЕ reflect'им upstream body — может содержать internal config (feeAccount, refs)
logger.error(`Jupiter quote ${response.status}: ${text.slice(0, 200)}`);
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
return;
}
const data = await response.json();
res.json(data);
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'Jupiter quote request timed out' });
return;
}
res.status(502).json({ success: false, error: 'Failed to reach Jupiter API' });
} finally {
clearTimeout(timeout);
}
}
/**
* POST /api/sol/swap/build
* Proxies to Jupiter POST /v6/swap — returns serialized transaction for client signing
*/
async function buildSwap(req: Request, res: Response) {
const { quoteResponse, userPublicKey } = req.body;
if (!quoteResponse || typeof quoteResponse !== 'object') {
res.status(400).json({ success: false, error: 'Missing quoteResponse object' });
return;
}
if (!userPublicKey || typeof userPublicKey !== 'string') {
res.status(400).json({ success: false, error: 'Missing userPublicKey string' });
return;
}
// Validate userPublicKey syntactically
try {
new PublicKey(userPublicKey);
} catch {
res.status(400).json({ success: false, error: 'Invalid userPublicKey (not a valid base58 32-byte pubkey)' });
return;
}
// C9 — bind userPublicKey to JWT (без bind: feeAccount/referral hijacking, ATA pre-staging)
const userId = req.auth!.userId;
try {
await assertUserOwnsAddress(userId, 'SOL', userPublicKey);
} catch (err: any) {
res.status(403).json({ success: false, error: err.message });
return;
}
// C8 — re-validate input/output mints в quoteResponse против ALLOWED_MINTS.
// Иначе attacker может вызвать /quote с whitelisted mints (passes), затем POST /build
// с hand-crafted quoteResponse где outputMint = scam-mint, и Jupiter trust'нет shape.
const qInputMint = (quoteResponse as any)?.inputMint;
const qOutputMint = (quoteResponse as any)?.outputMint;
if (typeof qInputMint !== 'string' || !ALLOWED_MINTS.has(qInputMint)) {
res.status(400).json({ success: false, error: 'quoteResponse.inputMint not in allowlist' });
return;
}
if (typeof qOutputMint !== 'string' || !ALLOWED_MINTS.has(qOutputMint)) {
res.status(400).json({ success: false, error: 'quoteResponse.outputMint not in allowlist' });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (env.jupiterApiKey) {
headers['x-api-key'] = env.jupiterApiKey;
}
const swapBody: Record<string, unknown> = {
quoteResponse,
userPublicKey,
wrapAndUnwrapSol: true,
dynamicComputeUnitLimit: true,
prioritizationFeeLamports: 'auto',
};
// Attach referral fee account for Jupiter to route platform fees
if (env.jupiterReferralAccount) {
swapBody.feeAccount = env.jupiterReferralAccount;
}
const response = await fetch(`${JUPITER_BASE}/swap`, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify(swapBody),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
logger.error(`Jupiter swap build ${response.status}: ${text.slice(0, 200)}`);
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
return;
}
const data = await response.json();
res.json(data);
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'Jupiter swap build timed out' });
return;
}
res.status(502).json({ success: false, error: 'Failed to reach Jupiter API' });
} finally {
clearTimeout(timeout);
}
}

View File

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

View File

@@ -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[])',

View File

@@ -1,499 +0,0 @@
import { Request, Response, Router } from 'express';
import { env } from '../config/env';
import { logger } from '../lib/logger';
import { assertUserOwnsAddress } from '../lib/wallet-binding';
const router = Router();
const TRONGRID_BASE = 'https://api.trongrid.io';
const TRON_TIMEOUT_MS = 15_000;
const TRON_ADDRESS_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
// Contracts
const SUNSWAP_SMART_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax';
const USDT_CONTRACT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
const WTRX_CONTRACT = 'TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR';
// FeeSwapRouter_TRX — deployed contract, 0.7% fee
const FEE_SWAP_ROUTER_TRX = 'TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E';
const FEE_BPS = 70n;
const BPS_DENOMINATOR = 10_000n;
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
// Token map
const TOKEN_MAP: Record<string, string> = {
TRX: WTRX_CONTRACT,
USDT: USDT_CONTRACT,
};
const TOKEN_DECIMALS: Record<string, number> = {
TRX: 6,
USDT: 6,
};
router.get('/quote', getSwapQuote);
router.post('/build', buildSwapTx);
router.post('/broadcast', broadcastTx);
export default router;
// ─── Helpers ───
function tronAddressToHex(address: string): string {
let num = 0n;
for (const char of address) {
const index = BASE58_ALPHABET.indexOf(char);
if (index === -1) throw new Error('Invalid base58 character');
num = num * 58n + BigInt(index);
}
const hex = num.toString(16).padStart(50, '0');
return hex.slice(2, 42); // skip 0x41, take 20 bytes
}
function encodeUint256(value: bigint): string {
return value.toString(16).padStart(64, '0');
}
function encodeAddress(tronAddress: string): string {
const hex = tronAddressToHex(tronAddress);
return hex.padStart(64, '0');
}
function tronHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (env.tronApiKey) {
headers['TRON-PRO-API-KEY'] = env.tronApiKey;
}
return headers;
}
// Encode bytes calldata as ABI dynamic bytes parameter
function encodeDynamicBytes(hexData: string): string {
// Remove 0x prefix if present
const data = hexData.startsWith('0x') ? hexData.slice(2) : hexData;
const byteLength = data.length / 2;
const lengthEncoded = encodeUint256(BigInt(byteLength));
// Pad data to 32-byte boundary
const paddedData = data.padEnd(Math.ceil(data.length / 64) * 64, '0');
return lengthEncoded + paddedData;
}
// ─── GET /quote ───
async function getSwapQuote(req: Request, res: Response) {
const from = String(req.query.from || '').toUpperCase();
const to = String(req.query.to || '').toUpperCase();
const amount = String(req.query.amount || '');
if (!TOKEN_MAP[from] || !TOKEN_MAP[to]) {
res.status(400).json({ success: false, error: 'Invalid from/to. Use TRX or USDT' });
return;
}
if (from === to) {
res.status(400).json({ success: false, error: 'from and to must be different' });
return;
}
const amountBigInt = BigInt(amount || '0');
if (amountBigInt <= 0n) {
res.status(400).json({ success: false, error: 'amount must be positive' });
return;
}
// Deduct 0.7% fee — SunSwap will only receive 99.3%
const feeAmount = (amountBigInt * FEE_BPS) / BPS_DENOMINATOR;
const amountAfterFee = amountBigInt - feeAmount;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
try {
const fromToken = TOKEN_MAP[from];
const toToken = TOKEN_MAP[to];
// ABI: getAmountsOut(uint256 amountIn, address[] path)
const amountHex = encodeUint256(amountAfterFee);
const offsetHex = encodeUint256(64n);
const lengthHex = encodeUint256(2n);
const addr0Hex = encodeAddress(fromToken);
const addr1Hex = encodeAddress(toToken);
const parameter = amountHex + offsetHex + lengthHex + addr0Hex + addr1Hex;
const response = await fetch(`${TRONGRID_BASE}/wallet/triggerconstantcontract`, {
method: 'POST',
headers: tronHeaders(),
signal: controller.signal,
body: JSON.stringify({
owner_address: SUNSWAP_SMART_ROUTER,
contract_address: SUNSWAP_SMART_ROUTER,
function_selector: 'getAmountsOut(uint256,address[])',
parameter,
visible: true,
}),
});
if (!response.ok) {
res.status(response.status).json({ success: false, error: 'TronGrid error' });
return;
}
const body = (await response.json()) as {
constant_result?: string[];
result?: { result?: boolean; message?: string };
};
if (!body.constant_result?.[0]) {
const errorMsg = body.result?.message
? Buffer.from(body.result.message, 'hex').toString('utf8')
: 'No result from getAmountsOut';
res.status(502).json({ success: false, error: errorMsg });
return;
}
const resultHex = body.constant_result[0];
const amountOutHex = resultHex.slice(-64);
const amountOut = BigInt('0x' + amountOutHex).toString();
res.json({
success: true,
amountIn: amountBigInt.toString(),
amountOut,
fee: feeAmount.toString(),
from,
to,
fromDecimals: TOKEN_DECIMALS[from],
toDecimals: TOKEN_DECIMALS[to],
});
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'TronGrid quote request timed out' });
return;
}
res.status(502).json({ success: false, error: 'Failed to reach TronGrid' });
} finally {
clearTimeout(timeout);
}
}
// ─── POST /build ───
async function buildSwapTx(req: Request, res: Response) {
const { from, to, amount, amountOutMin, userAddress } = req.body;
if (!from || !to || !amount || !amountOutMin || !userAddress) {
res.status(400).json({ success: false, error: 'Missing required fields: from, to, amount, amountOutMin, userAddress' });
return;
}
const fromUpper = String(from).toUpperCase();
const toUpper = String(to).toUpperCase();
if (!TOKEN_MAP[fromUpper] || !TOKEN_MAP[toUpper] || fromUpper === toUpper) {
res.status(400).json({ success: false, error: 'Invalid from/to pair' });
return;
}
if (!TRON_ADDRESS_RE.test(userAddress)) {
res.status(400).json({ success: false, error: 'Invalid TRON address' });
return;
}
// H47 — bind userAddress to JWT (output идёт на userAddress; без bind output к attacker'у)
const userId = req.auth!.userId;
try {
await assertUserOwnsAddress(userId, 'TRX', userAddress);
} catch (err: any) {
res.status(403).json({ success: false, error: err.message });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
try {
const transactions: Array<{ txID: string; raw_data: unknown; raw_data_hex: string; type: string }> = [];
const amountBigInt = BigInt(amount);
const minOutBigInt = BigInt(amountOutMin);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // 20 minutes
// Calculate fee and swap amounts
const feeAmount = (amountBigInt * FEE_BPS) / BPS_DENOMINATOR;
const swapAmount = amountBigInt - feeAmount;
if (fromUpper === 'TRX') {
// ═══ TRX → USDT: through FeeSwapRouter.swapNativeWithFee(bytes routerCalldata) ═══
// Step 1: Build the SunSwap calldata for swapExactETHForTokens
// SunSwap will receive swapAmount (99.3%) of TRX from FeeSwapRouter
// SunSwap sends output tokens to `to` address — must be userAddress
const sunswapCalldata = buildSwapExactETHForTokensCalldata(
minOutBigInt,
[WTRX_CONTRACT, USDT_CONTRACT],
userAddress,
deadline,
);
// Step 2: Wrap in swapNativeWithFee(bytes routerCalldata)
// ABI: swapNativeWithFee(bytes) — single dynamic bytes param
const offsetToBytes = encodeUint256(32n); // offset to dynamic bytes
const feeRouterParam = offsetToBytes + encodeDynamicBytes(sunswapCalldata);
const swapTx = await buildTriggerSmartContract({
ownerAddress: userAddress,
contractAddress: FEE_SWAP_ROUTER_TRX,
functionSelector: 'swapNativeWithFee(bytes)',
parameter: feeRouterParam,
callValue: Number(amountBigInt), // full amount — contract takes 0.7%
feeLimit: 200_000_000, // 200 TRX
signal: controller.signal,
});
if (swapTx) {
transactions.push({ ...swapTx, type: 'swap' });
}
} else {
// ═══ USDT → TRX: through FeeSwapRouter.swapTokenWithFee(address, uint256, bytes) ═══
// Step 1: Approve USDT to FeeSwapRouter (not SunSwap!)
const allowance = await checkAllowance(userAddress, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, controller.signal);
if (allowance < amountBigInt) {
const approveTx = await buildTriggerSmartContract({
ownerAddress: userAddress,
contractAddress: USDT_CONTRACT,
functionSelector: 'approve(address,uint256)',
parameter: encodeAddress(FEE_SWAP_ROUTER_TRX) + encodeUint256(BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff')),
callValue: 0,
feeLimit: 100_000_000,
signal: controller.signal,
});
if (approveTx) {
transactions.push({ ...approveTx, type: 'approve' });
}
}
// Step 2: Build SunSwap calldata for swapExactTokensForETH
// FeeSwapRouter will approve swapAmount (99.3%) to SunSwap and forward this calldata
const sunswapCalldata = buildSwapExactTokensForETHCalldata(
swapAmount, // 99.3% — what SunSwap actually receives
minOutBigInt,
[USDT_CONTRACT, WTRX_CONTRACT],
userAddress, // output TRX goes to user
deadline,
);
// Step 3: Wrap in swapTokenWithFee(address tokenIn, uint256 amountIn, bytes routerCalldata)
const tokenInEncoded = encodeAddress(USDT_CONTRACT);
const amountInEncoded = encodeUint256(amountBigInt); // full amount — contract takes 0.7%
const offsetToBytes = encodeUint256(96n); // offset to dynamic bytes (3 * 32)
const feeRouterParam = tokenInEncoded + amountInEncoded + offsetToBytes + encodeDynamicBytes(sunswapCalldata);
const swapTx = await buildTriggerSmartContract({
ownerAddress: userAddress,
contractAddress: FEE_SWAP_ROUTER_TRX,
functionSelector: 'swapTokenWithFee(address,uint256,bytes)',
parameter: feeRouterParam,
callValue: 0,
feeLimit: 200_000_000,
signal: controller.signal,
});
if (swapTx) {
transactions.push({ ...swapTx, type: 'swap' });
}
}
if (!transactions.length) {
res.status(502).json({ success: false, error: 'Failed to build swap transactions' });
return;
}
res.json({ success: true, transactions });
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'Build request timed out' });
return;
}
logger.error(`TRON swap build failed: ${(error as any)?.stack || (error as any)?.message}`);
res.status(502).json({ success: false, error: 'Failed to build swap' });
} finally {
clearTimeout(timeout);
}
}
// ─── POST /broadcast ───
async function broadcastTx(req: Request, res: Response) {
const { signedTransaction } = req.body;
if (!signedTransaction || typeof signedTransaction !== 'object') {
res.status(400).json({ success: false, error: 'Missing or invalid signedTransaction' });
return;
}
// C6 — verify owner_address внутри signed tx равен JWT-bound TRX-адресу юзера.
// Иначе endpoint = open relay для arbitrary tx (replay leaked txs, evade IP bans).
const userId = req.auth!.userId;
const contract0 = signedTransaction?.raw_data?.contract?.[0];
const ownerAddr = contract0?.parameter?.value?.owner_address;
if (typeof ownerAddr !== 'string' || !ownerAddr) {
res.status(400).json({ success: false, error: 'Cannot extract owner_address from signedTransaction.raw_data.contract[0]' });
return;
}
try {
await assertUserOwnsAddress(userId, 'TRX', ownerAddr);
} catch (err: any) {
logger.warn(`broadcast rejected: ${err.message} userId=${userId}`);
res.status(403).json({ success: false, error: err.message });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
try {
const response = await fetch(`${TRONGRID_BASE}/wallet/broadcasttransaction`, {
method: 'POST',
headers: tronHeaders(),
signal: controller.signal,
body: JSON.stringify(signedTransaction),
});
const data = await response.json();
res.status(response.ok ? 200 : 502).json(data);
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'Broadcast timed out' });
return;
}
res.status(502).json({ success: false, error: 'Failed to broadcast transaction' });
} finally {
clearTimeout(timeout);
}
}
// ─── SunSwap Calldata Builders ───
// Build raw calldata hex for swapExactETHForTokens(uint256,address[],address,uint256)
function buildSwapExactETHForTokensCalldata(
amountOutMin: bigint,
path: string[], // TRON base58 addresses
to: string, // TRON base58 address
deadline: bigint,
): string {
// Function selector: keccak256("swapExactETHForTokens(uint256,address[],address,uint256)") first 4 bytes
const selector = 'b6f9de95';
const amountOutMinEnc = encodeUint256(amountOutMin);
const offsetToPath = encodeUint256(128n); // 4 * 32 bytes offset
const toEnc = encodeAddress(to);
const deadlineEnc = encodeUint256(deadline);
const pathLenEnc = encodeUint256(BigInt(path.length));
const pathElements = path.map((addr) => encodeAddress(addr)).join('');
return selector + amountOutMinEnc + offsetToPath + toEnc + deadlineEnc + pathLenEnc + pathElements;
}
// Build raw calldata hex for swapExactTokensForETH(uint256,uint256,address[],address,uint256)
function buildSwapExactTokensForETHCalldata(
amountIn: bigint,
amountOutMin: bigint,
path: string[], // TRON base58 addresses
to: string, // TRON base58 address
deadline: bigint,
): string {
// Function selector: keccak256("swapExactTokensForETH(uint256,uint256,address[],address,uint256)") first 4 bytes
const selector = '18cbafe5';
const amountInEnc = encodeUint256(amountIn);
const amountOutMinEnc = encodeUint256(amountOutMin);
const offsetToPath = encodeUint256(160n); // 5 * 32 bytes offset
const toEnc = encodeAddress(to);
const deadlineEnc = encodeUint256(deadline);
const pathLenEnc = encodeUint256(BigInt(path.length));
const pathElements = path.map((addr) => encodeAddress(addr)).join('');
return selector + amountInEnc + amountOutMinEnc + offsetToPath + toEnc + deadlineEnc + pathLenEnc + pathElements;
}
// ─── Internal Helpers ───
async function checkAllowance(
owner: string,
tokenContract: string,
spender: string,
signal: AbortSignal
): Promise<bigint> {
const parameter = encodeAddress(owner) + encodeAddress(spender);
const response = await fetch(`${TRONGRID_BASE}/wallet/triggerconstantcontract`, {
method: 'POST',
headers: tronHeaders(),
signal,
body: JSON.stringify({
owner_address: owner,
contract_address: tokenContract,
function_selector: 'allowance(address,address)',
parameter,
visible: true,
}),
});
if (!response.ok) return 0n;
const body = (await response.json()) as { constant_result?: string[] };
const hex = body.constant_result?.[0];
if (!hex || /^0+$/.test(hex)) return 0n;
return BigInt('0x' + hex);
}
interface TriggerSmartContractParams {
ownerAddress: string;
contractAddress: string;
functionSelector: string;
parameter: string;
callValue: number;
feeLimit: number;
signal: AbortSignal;
}
async function buildTriggerSmartContract(
params: TriggerSmartContractParams
): Promise<{ txID: string; raw_data: unknown; raw_data_hex: string } | null> {
const response = await fetch(`${TRONGRID_BASE}/wallet/triggersmartcontract`, {
method: 'POST',
headers: tronHeaders(),
signal: params.signal,
body: JSON.stringify({
owner_address: params.ownerAddress,
contract_address: params.contractAddress,
function_selector: params.functionSelector,
parameter: params.parameter,
call_value: params.callValue,
fee_limit: params.feeLimit,
visible: true,
}),
});
if (!response.ok) return null;
const body = (await response.json()) as {
result?: { result?: boolean; message?: string };
transaction?: { txID: string; raw_data: unknown; raw_data_hex: string };
};
if (!body.result?.result || !body.transaction) {
const errorMsg = body.result?.message
? Buffer.from(body.result.message, 'hex').toString('utf8')
: 'Transaction build failed';
throw new Error(errorMsg);
}
return body.transaction;
}

View File

@@ -7,10 +7,23 @@ router.post('/create', WalletController.createWallet);
router.get('/', WalletController.getWallets); router.get('/', WalletController.getWallets);
router.post('/mnemonic/reveal', WalletController.revealMnemonic); router.post('/mnemonic/reveal', WalletController.revealMnemonic);
// IMPORTANT: /portfolio ДОЛЖЕН быть ПЕРЕД /:chain/... иначе express матчит chain='portfolio'.
router.get('/portfolio', WalletController.getPortfolio);
router.get('/:chain/balance', WalletController.getChainBalance); router.get('/:chain/balance', WalletController.getChainBalance);
router.get('/:chain/transactions', WalletController.getChainTransactions); router.get('/:chain/transactions', WalletController.getChainTransactions);
router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions); router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions);
// IMPORTANT: more specific paths ДОЛЖНЫ быть зарегистрированы РАНЬШЕ — Express сматчит first.
// /:chain/send/cost-estimate ПЕРЕД /:chain/send
// /:chain/swap/quote ПЕРЕД /:chain/swap
// /:chain/swap/cost-estimate ПЕРЕД /:chain/swap
router.post('/:chain/send/cost-estimate', WalletController.estimateSendCost);
router.post('/:chain/send', WalletController.sendFromChain); router.post('/:chain/send', WalletController.sendFromChain);
router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx); router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx);
router.post('/:chain/swap/quote', WalletController.quoteSwap);
router.post('/:chain/swap/cost-estimate', WalletController.estimateSwapCost);
router.post('/:chain/swap', WalletController.swapOnChain);
router.post('/:chain/app-fee', WalletController.appFeeTransfer);
router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx);
export default router; export default router;

File diff suppressed because it is too large Load Diff

View File

@@ -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,
};
}

View File

@@ -24,9 +24,13 @@ const BSC_RPC = 'https://bsc-dataseed.binance.org';
const RPC: Record<'ETH' | 'BSC', string> = { ETH: ETH_RPC, BSC: BSC_RPC }; const RPC: Record<'ETH' | 'BSC', string> = { ETH: ETH_RPC, BSC: BSC_RPC };
// Realistic mainnet floors (gwei). // Realistic mainnet floors (gwei). 0 = without floor (use raw eth_feeHistory value).
// ETH: убран floor — eth_feeHistory сам по себе репрезентативный, искусственный floor
// перерасходовал gas в spam/low-traffic блоках.
// BSC: оставлен низкий floor — chain не полностью EIP-1559, без минимума получается
// 0.001 gwei который reject'ится min-relay.
const FLOOR_GWEI: Record<'ETH' | 'BSC', string> = { const FLOOR_GWEI: Record<'ETH' | 'BSC', string> = {
ETH: '0.5', ETH: '0',
BSC: '0.05', BSC: '0.05',
}; };

View File

@@ -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()) {

View File

@@ -0,0 +1,306 @@
/**
* USD price oracle for wallet balance responses + 24h change percentage.
*
* Data source: CoinGecko free API (https://api.coingecko.com/api/v3/simple/price).
* Cache: KeyDB (Redis), TTL = 300s.
*
* Now also returns `change24h` (price change percent over rolling 24h) — used by
* `/api/prices/dynamics`. Existing helpers `getPricesByIds` / `getPricesBySymbols`
* остаются backward-compatible (возвращают только number | null) — для них достаточно
* `usd` поля из cache.
*
* Security (см. план §"Security checklist"):
* S1 — whitelist через getCoingeckoId → user input не попадает в URL.
* S2 — лимит размеров вызовов через caller (controller `/prices`).
* S3 — strict typeof/Number.isFinite/>=0 при чтении cache.
* S4 — in-flight dedup (см. `_inflight` map) + cache.
* S5 — никаких stack-trace'ов наружу; ошибки в logger.
* S9 — CG API key, если задан, идёт ТОЛЬКО в header (не в URL).
* S10 — `Number.isFinite` guard для usdValue (применяется в `wallet-ops.service.ts`).
* S11 — жёсткий 5s AbortController timeout.
* S12 — `null` ответ не кэшируем; только успешные числа уходят в Redis.
*/
import { getRedis } from '../config/redis';
import { logger } from '../lib/logger';
import { getCoingeckoId } from '../lib/token-registry';
import type { ChainCode } from '../lib/address-validators';
const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price';
const CACHE_TTL_SECONDS = 300;
const CACHE_KEY_PREFIX = 'price:';
const FETCH_TIMEOUT_MS = 5000;
const MAX_IDS_PER_REQUEST = 100;
export interface PriceWithChange {
usd: number;
change24h: number | null; // например -1.38 (= -1.38%), 0.06, null если CG не отдал
}
interface CachedPrice {
usd: number;
change24h: number | null;
ts: number;
}
/** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */
const _inflight = new Map<string, Promise<Record<string, PriceWithChange | null>>>();
function isValidPrice(n: unknown): n is number {
return typeof n === 'number' && Number.isFinite(n) && n >= 0;
}
function isValidChange(n: unknown): n is number {
// change24h может быть negative (падение цены), но конечное число
return typeof n === 'number' && Number.isFinite(n);
}
function buildHeaders(): Record<string, string> {
const headers: Record<string, string> = { Accept: 'application/json' };
const key = process.env.COINGECKO_API_KEY;
if (key && key.length > 0) {
headers['x-cg-demo-api-key'] = key;
}
return headers;
}
/**
* Fetches CoinGecko /simple/price for a batch of coin ids.
* Now includes `include_24hr_change=true` — отдаёт usd_24h_change поле.
*/
async function fetchCoingecko(ids: string[]): Promise<Record<string, PriceWithChange | null>> {
const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd&include_24hr_change=true`;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: ctrl.signal,
headers: buildHeaders(),
});
if (!res.ok) {
logger.warn(`CoinGecko HTTP ${res.status} for ${ids.length} ids`);
const out: Record<string, PriceWithChange | null> = {};
for (const id of ids) out[id] = null;
return out;
}
const json = (await res.json()) as Record<string, { usd?: unknown; usd_24h_change?: unknown }>;
const out: Record<string, PriceWithChange | null> = {};
for (const id of ids) {
const usd = json?.[id]?.usd;
const change = json?.[id]?.usd_24h_change;
if (isValidPrice(usd)) {
out[id] = {
usd,
change24h: isValidChange(change) ? change : null,
};
} else {
out[id] = null;
}
}
return out;
} catch (err: any) {
logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`);
const out: Record<string, PriceWithChange | null> = {};
for (const id of ids) out[id] = null;
return out;
} finally {
clearTimeout(t);
}
}
/**
* Возвращает USD-цены + 24h change для списка CoinGecko ids.
* Никогда не throws — degrades to `null` per-id.
*
* Cache: read-through KeyDB, 300s TTL. Только валидные usd кэшируются (S12).
* Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4).
*/
export async function getPricesWithChangeByIds(
ids: string[],
): Promise<Record<string, PriceWithChange | null>> {
if (!Array.isArray(ids) || ids.length === 0) return {};
const uniqIds = Array.from(new Set(ids.filter((x) => typeof x === 'string' && x.length > 0)));
if (uniqIds.length === 0) return {};
const result: Record<string, PriceWithChange | null> = {};
let redis: ReturnType<typeof getRedis> | null = null;
try {
redis = getRedis();
} catch {
redis = null;
}
// 1) Read cache (pipeline)
const misses: string[] = [];
if (redis) {
try {
const pipeline = redis.pipeline();
for (const id of uniqIds) pipeline.get(CACHE_KEY_PREFIX + id);
const cached = await pipeline.exec();
uniqIds.forEach((id, i) => {
const tuple = cached?.[i];
const raw = tuple?.[1] as string | null | undefined;
if (raw) {
try {
const parsed = JSON.parse(raw) as CachedPrice;
if (isValidPrice(parsed?.usd)) {
result[id] = {
usd: parsed.usd,
change24h: isValidChange(parsed?.change24h) ? parsed.change24h : null,
};
return;
}
} catch {
// S3 — невалидный JSON в cache → fall through к refetch.
}
}
misses.push(id);
});
} catch (err: any) {
logger.warn(`Redis cache read failed: ${err?.message || 'unknown'}`);
for (const id of uniqIds) {
if (!(id in result)) misses.push(id);
}
}
} else {
for (const id of uniqIds) misses.push(id);
}
if (misses.length === 0) return result;
// 2) Fetch misses в batches + in-flight dedup (S4).
const fetched: Record<string, PriceWithChange | null> = {};
for (let i = 0; i < misses.length; i += MAX_IDS_PER_REQUEST) {
const batch = misses.slice(i, i + MAX_IDS_PER_REQUEST);
const batchKey = batch.join('|');
let p = _inflight.get(batchKey);
if (!p) {
p = fetchCoingecko(batch).finally(() => _inflight.delete(batchKey));
_inflight.set(batchKey, p);
}
const batchResult = await p;
Object.assign(fetched, batchResult);
}
// 3) Persist successes to cache (S12: skip nulls).
if (redis) {
try {
const setP = redis.pipeline();
let writes = 0;
for (const [id, val] of Object.entries(fetched)) {
if (val && isValidPrice(val.usd)) {
setP.set(
CACHE_KEY_PREFIX + id,
JSON.stringify({
usd: val.usd,
change24h: val.change24h,
ts: Date.now(),
} satisfies CachedPrice),
'EX',
CACHE_TTL_SECONDS,
);
writes += 1;
}
}
if (writes > 0) await setP.exec();
} catch (err: any) {
logger.warn(`Redis cache write failed: ${err?.message || 'unknown'}`);
}
}
// 4) Merge fetched into result.
for (const id of misses) {
result[id] = id in fetched ? fetched[id] : null;
}
return result;
}
/**
* Backward-compatible thin wrapper: возвращает только usd (без change24h).
* Все существующие callers (portfolio, swap quote USD enrichment) используют это.
*/
export async function getPricesByIds(ids: string[]): Promise<Record<string, number | null>> {
const rich = await getPricesWithChangeByIds(ids);
const out: Record<string, number | null> = {};
for (const id of Object.keys(rich)) {
out[id] = rich[id]?.usd ?? null;
}
return out;
}
/**
* Convenience-обёртка для callers которые оперируют (chain, symbol) парами.
*
* Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null.
* Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful).
*/
export async function getPricesBySymbols(
pairs: { chain: ChainCode; symbol: string }[],
): Promise<Map<string, number | null>> {
const out = new Map<string, number | null>();
if (!Array.isArray(pairs) || pairs.length === 0) return out;
const pairToId = new Map<string, string | null>();
const idsToFetch = new Set<string>();
for (const { chain, symbol } of pairs) {
const key = `${chain}:${symbol}`;
if (pairToId.has(key)) continue;
const id = getCoingeckoId(chain, symbol);
pairToId.set(key, id);
if (id) idsToFetch.add(id);
else out.set(key, null);
}
const prices = await getPricesByIds(Array.from(idsToFetch));
for (const [key, id] of pairToId.entries()) {
if (out.has(key)) continue;
if (!id) {
out.set(key, null);
continue;
}
out.set(key, prices[id] ?? null);
}
return out;
}
/**
* Same as getPricesBySymbols но возвращает PriceWithChange.
* Используется в /api/prices/dynamics.
*/
export async function getPricesWithChangeBySymbols(
pairs: { chain: ChainCode; symbol: string }[],
): Promise<Map<string, PriceWithChange | null>> {
const out = new Map<string, PriceWithChange | null>();
if (!Array.isArray(pairs) || pairs.length === 0) return out;
const pairToId = new Map<string, string | null>();
const idsToFetch = new Set<string>();
for (const { chain, symbol } of pairs) {
const key = `${chain}:${symbol}`;
if (pairToId.has(key)) continue;
const id = getCoingeckoId(chain, symbol);
pairToId.set(key, id);
if (id) idsToFetch.add(id);
else out.set(key, null);
}
const prices = await getPricesWithChangeByIds(Array.from(idsToFetch));
for (const [key, id] of pairToId.entries()) {
if (out.has(key)) continue;
if (!id) {
out.set(key, null);
continue;
}
out.set(key, prices[id] ?? null);
}
return out;
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,12 @@
* Server-side signing now lives in `wallet-signer.service.ts` (custodial). * Server-side signing now lives in `wallet-signer.service.ts` (custodial).
*/ */
import { ethers } from 'ethers'; import { ethers } from 'ethers';
import { createHash } from 'crypto';
import { env } from '../config/env'; import { env } from '../config/env';
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry'; import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
import { getPricesBySymbols } from './price-oracle.service';
import { logger } from '../lib/logger';
import { getRedis } from '../config/redis';
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC'; export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
@@ -29,6 +33,17 @@ export interface FormattedAmount {
raw: string; // smallest units (string-encoded BigInt — без потери точности) raw: string; // smallest units (string-encoded BigInt — без потери точности)
formatted: string; // human-readable, e.g. "0.003" formatted: string; // human-readable, e.g. "0.003"
decimals: number; // decimals chain'а/токена decimals: number; // decimals chain'а/токена
/**
* USD price per 1 целая единица (e.g. $67432.12 за 1 BTC).
* `null` если symbol не в реестре с `coingeckoId` или upstream price oracle недоступен.
* Источник: CoinGecko free API, cache 5 мин в KeyDB.
*/
usdPrice: number | null;
/**
* Совокупная USD-стоимость holding'а: `Number(formatted) × usdPrice`.
* Округлено до 8 знаков. `null` если `usdPrice === null` или вычисление дало `Infinity/NaN`.
*/
usdValue: number | null;
} }
export interface BalanceResult { export interface BalanceResult {
@@ -69,7 +84,67 @@ export function formatUnits(raw: string, decimals: number): string {
} }
function fmt(raw: string, decimals: number): FormattedAmount { function fmt(raw: string, decimals: number): FormattedAmount {
return { raw, formatted: formatUnits(raw, decimals), decimals }; return {
raw,
formatted: formatUnits(raw, decimals),
decimals,
usdPrice: null, // populated post-build via populatePrices()
usdValue: null,
};
}
/**
* Округление USD значения до 8 знаков после запятой (избавляемся от float-мусора).
* S10 — `Infinity`/`NaN` → `null`.
*/
function roundUsd(n: number): number | null {
if (!Number.isFinite(n)) return null;
return Math.round(n * 1e8) / 1e8;
}
/**
* Мутирует BalanceResult, проставляя `usdPrice` и `usdValue` каждому FormattedAmount.
* Никогда не throws — если price oracle упал, поля остаются `null`.
*/
async function populatePrices(result: BalanceResult): Promise<void> {
try {
const pairs: { chain: ChainCode; symbol: string }[] = [
{ chain: result.chain, symbol: result.chain }, // native (BTC/ETH/BSC/TRX/SOL)
];
if (result.tokens) {
for (const sym of Object.keys(result.tokens)) {
pairs.push({ chain: result.chain, symbol: sym });
}
}
const prices = await getPricesBySymbols(pairs);
// Native
const nativeKey = `${result.chain}:${result.chain}`;
const nativePrice = prices.get(nativeKey) ?? null;
result.native.usdPrice = nativePrice;
if (nativePrice != null) {
const formattedNum = Number(result.native.formatted);
result.native.usdValue = Number.isFinite(formattedNum)
? roundUsd(formattedNum * nativePrice)
: null;
}
// Tokens
if (result.tokens) {
for (const [sym, amt] of Object.entries(result.tokens)) {
const key = `${result.chain}:${sym}`;
const p = prices.get(key) ?? null;
amt.usdPrice = p;
if (p != null) {
const fNum = Number(amt.formatted);
amt.usdValue = Number.isFinite(fNum) ? roundUsd(fNum * p) : null;
}
}
}
} catch (err: any) {
// Не валим запрос — balance вернётся без цен.
logger.warn(`populatePrices(${result.chain}) failed: ${err?.message || 'unknown'}`);
}
} }
function fmtTokens( function fmtTokens(
@@ -85,20 +160,23 @@ function fmtTokens(
export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> { export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> {
const nativeDecimals = NATIVE_DECIMALS[chain]; const nativeDecimals = NATIVE_DECIMALS[chain];
let result: BalanceResult;
switch (chain) { switch (chain) {
case 'BTC': case 'BTC':
return { result = {
chain, address, chain, address,
native: fmt(await btcBalance(address), nativeDecimals), native: fmt(await btcBalance(address), nativeDecimals),
}; };
break;
case 'TRX': { case 'TRX': {
const { trx, tokens } = await trxBalance(address); const { trx, tokens } = await trxBalance(address);
const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals])); const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals]));
return { result = {
chain, address, chain, address,
native: fmt(trx, nativeDecimals), native: fmt(trx, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap), tokens: fmtTokens(tokens, decimalsMap),
}; };
break;
} }
case 'BSC': case 'BSC':
case 'ETH': { case 'ETH': {
@@ -110,22 +188,148 @@ export async function getBalance(chain: ChainCode, address: string): Promise<Bal
tokenList.map((t) => ({ symbol: t.symbol, addr: t.contractAddress })), tokenList.map((t) => ({ symbol: t.symbol, addr: t.contractAddress })),
); );
const decimalsMap = Object.fromEntries(tokenList.map((t) => [t.symbol, t.decimals])); const decimalsMap = Object.fromEntries(tokenList.map((t) => [t.symbol, t.decimals]));
return { result = {
chain, address, chain, address,
native: fmt(native, nativeDecimals), native: fmt(native, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap), tokens: fmtTokens(tokens, decimalsMap),
}; };
break;
} }
case 'SOL': { case 'SOL': {
const { native, tokens } = await solBalance(address); const { native, tokens } = await solBalance(address);
const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals])); const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals]));
return { result = {
chain, address, chain, address,
native: fmt(native, nativeDecimals), native: fmt(native, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap), tokens: fmtTokens(tokens, decimalsMap),
}; };
break;
} }
} }
// Populate USD prices (graceful — never throws, fields stay null on failure).
await populatePrices(result);
return result;
}
// ─────────────────────── PORTFOLIO (aggregate всех 5 сетей) ─────────
export interface ChainPortfolio extends BalanceResult {
/** Сумма usdValue по native + всем токенам chain'а. `null` если все цены недоступны. */
totalUsd: number | null;
/** true = данные из KeyDB cache (RPC chain'а упал в этом запросе). */
stale: boolean;
/** Unix ms когда данные были обновлены (fresh fetch). */
lastUpdated: number;
/** Причина почему stale (только если stale=true). */
error?: string;
}
export interface PortfolioResult {
/** Grand sum по всем сетям. Округлено до 8 знаков. */
totalUsd: number;
/** true если хотя бы одна сеть в stale/error состоянии. */
hasErrors: boolean;
/** Per-chain breakdown. `null` для chain'а если ни fresh ни cache нет. */
perChain: Record<ChainCode, ChainPortfolio | null>;
}
const PORTFOLIO_CACHE_TTL_SEC = 3600; // 1 час stale-fallback
const PORTFOLIO_CACHE_PREFIX = 'portfolio:'; // ключ: portfolio:{userId}:{chain}
function computeChainTotalUsd(b: BalanceResult): number | null {
let total = 0;
let anyValid = false;
const add = (amt: FormattedAmount | undefined): void => {
const v = amt?.usdValue;
if (typeof v === 'number' && Number.isFinite(v)) {
total += v;
anyValid = true;
}
};
add(b.native);
for (const a of Object.values(b.tokens ?? {})) add(a);
return anyValid ? roundUsd(total) : null;
}
/**
* Aggregate balance по всем 5 сетям. Параллельно дёргает `getBalance(chain, address)` для каждой,
* сохраняет успешные ответы в KeyDB (TTL 1 час). При сбое RPC отдельной сети — возвращает
* последний кэшированный snapshot с пометкой `stale:true`, чтобы UI никогда не показывал 0.
*
* Не throws — даже при полной недоступности всех сетей вернёт totalUsd=0 + hasErrors=true.
*/
export async function getPortfolio(
userId: string,
addresses: Record<ChainCode, string>,
): Promise<PortfolioResult> {
const chains: ChainCode[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL'];
const settled = await Promise.allSettled(
chains.map((c) => {
const addr = addresses[c];
if (!addr) return Promise.reject(new Error(`No ${c} address for user`));
return getBalance(c, addr);
}),
);
let redis: ReturnType<typeof getRedis> | null = null;
try { redis = getRedis(); } catch { redis = null; }
const perChain: Record<string, ChainPortfolio | null> = {};
let totalUsd = 0;
let hasErrors = false;
const now = Date.now();
for (let i = 0; i < chains.length; i++) {
const chain = chains[i];
const res = settled[i];
const cacheKey = `${PORTFOLIO_CACHE_PREFIX}${userId}:${chain}`;
if (res.status === 'fulfilled') {
const balance = res.value;
const chainTotal = computeChainTotalUsd(balance);
const entry: ChainPortfolio = {
...balance,
totalUsd: chainTotal,
stale: false,
lastUpdated: now,
};
perChain[chain] = entry;
if (typeof chainTotal === 'number') totalUsd += chainTotal;
// Cache fire-and-forget
if (redis) {
redis
.set(cacheKey, JSON.stringify(entry), 'EX', PORTFOLIO_CACHE_TTL_SEC)
.catch((err) => logger.warn(`portfolio cache write ${chain} failed: ${err?.message}`));
}
} else {
hasErrors = true;
const reason = String((res.reason as any)?.message || 'unknown');
// Попробуем cached fallback
let cached: ChainPortfolio | null = null;
if (redis) {
try {
const raw = await redis.get(cacheKey);
if (raw) cached = JSON.parse(raw) as ChainPortfolio;
} catch (err: any) {
logger.warn(`portfolio cache read ${chain} failed: ${err?.message}`);
}
}
if (cached) {
perChain[chain] = { ...cached, stale: true, error: reason };
if (typeof cached.totalUsd === 'number') totalUsd += cached.totalUsd;
} else {
perChain[chain] = null;
}
}
}
return {
totalUsd: roundUsd(totalUsd) ?? 0,
hasErrors,
perChain: perChain as Record<ChainCode, ChainPortfolio | null>,
};
} }
async function btcBalance(address: string): Promise<string> { async function btcBalance(address: string): Promise<string> {
@@ -177,10 +381,11 @@ async function evmBalance(
tokens: { symbol: string; addr: string }[], tokens: { symbol: string; addr: string }[],
): Promise<{ native: string; tokens: Record<string, string> }> { ): Promise<{ native: string; tokens: Record<string, string> }> {
const provider = new ethers.providers.StaticJsonRpcProvider(rpc); const provider = new ethers.providers.StaticJsonRpcProvider(rpc);
const native = await withTimeout(provider.getBalance(address), TIMEOUT_MS, 'EVM balance timeout'); // H52 — Promise.allSettled: одна сбоящая RPC subcall не валит весь endpoint в 502
const nativeP = withTimeout(provider.getBalance(address), TIMEOUT_MS, 'EVM balance timeout').then(b => b.toString()).catch(() => '0');
const tokenBalances: Record<string, string> = {}; const tokenBalances: Record<string, string> = {};
await Promise.all( await Promise.allSettled(
tokens.map(async ({ symbol, addr }) => { tokens.map(async ({ symbol, addr }) => {
try { try {
const c = new ethers.Contract(addr, ERC20_ABI, provider); const c = new ethers.Contract(addr, ERC20_ABI, provider);
@@ -192,7 +397,8 @@ async function evmBalance(
}), }),
); );
return { native: native.toString(), tokens: tokenBalances }; const native = await nativeP;
return { native, tokens: tokenBalances };
} }
async function solBalance(address: string): Promise<{ native: string; tokens: Record<string, string> }> { async function solBalance(address: string): Promise<{ native: string; tokens: Record<string, string> }> {
@@ -316,18 +522,42 @@ async function scanTransactions(
async function btcTransactions(address: string, limit: number): Promise<TxItem[]> { async function btcTransactions(address: string, limit: number): Promise<TxItem[]> {
const txs = await fetchJson(`${BLOCKSTREAM}/address/${address}/txs`); const txs = await fetchJson(`${BLOCKSTREAM}/address/${address}/txs`);
return (txs as any[]).slice(0, limit).map((tx) => { return (txs as any[]).slice(0, limit).map((tx) => {
const inSelf = tx.vin.some((v: any) => v.prevout?.scriptpubkey_address === address); const vin = Array.isArray(tx.vin) ? tx.vin : [];
const outSelf = tx.vout.some((v: any) => v.scriptpubkey_address === address); const vout = Array.isArray(tx.vout) ? tx.vout : [];
const direction: TxItem['direction'] = inSelf && outSelf ? 'self' : inSelf ? 'out' : 'in'; const inSelf = vin.some((v: any) => v.prevout?.scriptpubkey_address === address);
const allOutsSelf = vout.length > 0 && vout.every((v: any) => v.scriptpubkey_address === address);
const anyOutsExternal = vout.some((v: any) => v.scriptpubkey_address && v.scriptpubkey_address !== address);
// H49 — корректная direction logic:
// self = consolidation/tx where ВСЕ outs идут обратно к нам (rare; обычно нет change)
// out = мы spend'им (inSelf=true) И есть external recipient
// in = мы получаем (НЕ inSelf, есть out к нам)
let direction: TxItem['direction'];
if (inSelf && allOutsSelf) {
direction = 'self';
} else if (inSelf && anyOutsExternal) {
direction = 'out';
} else {
direction = 'in';
}
// amount: для 'out' — sum external outs; для 'in' — sum outs к нам; для 'self' — 0
let amountSat = 0n;
if (direction === 'in') {
amountSat = vout
.filter((v: any) => v.scriptpubkey_address === address)
.reduce((s: bigint, v: any) => s + BigInt(v.value || 0), 0n);
} else if (direction === 'out') {
amountSat = vout
.filter((v: any) => v.scriptpubkey_address && v.scriptpubkey_address !== address)
.reduce((s: bigint, v: any) => s + BigInt(v.value || 0), 0n);
}
return { return {
txid: tx.txid, txid: tx.txid,
timestamp: tx.status?.block_time ?? null, timestamp: tx.status?.block_time ?? null,
direction, direction,
amount: String( amount: String(amountSat),
tx.vout
.filter((v: any) => (direction === 'in' ? v.scriptpubkey_address === address : v.scriptpubkey_address !== address))
.reduce((s: bigint, v: any) => s + BigInt(v.value), 0n),
),
}; };
}); });
} }
@@ -358,7 +588,7 @@ async function trxTransactions(address: string, limit: number): Promise<TxItem[]
} }
async function solTransactions(address: string, limit: number): Promise<TxItem[]> { async function solTransactions(address: string, limit: number): Promise<TxItem[]> {
const res = await fetchJson(SOL_RPC, { const sigsRes = await fetchJson(SOL_RPC, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -368,11 +598,56 @@ async function solTransactions(address: string, limit: number): Promise<TxItem[]
params: [address, { limit }], params: [address, { limit }],
}), }),
}); });
return ((res.result as any[]) || []).map((sig) => ({ // H50 — H18: filter out failed txs + deep-parse selected (limit small concurrency).
const allSigs = ((sigsRes.result as any[]) || []).filter((s) => s.err === null);
// Fetch tx details для balance deltas — batch parallel но небольшим limit'ом
const results: TxItem[] = [];
for (const sig of allSigs.slice(0, limit)) {
try {
const txRes = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getTransaction',
params: [sig.signature, { maxSupportedTransactionVersion: 0, encoding: 'json' }],
}),
});
const tx = txRes.result;
const accountKeys = tx?.transaction?.message?.accountKeys || [];
const idx = accountKeys.indexOf(address);
const pre = tx?.meta?.preBalances?.[idx];
const post = tx?.meta?.postBalances?.[idx];
let direction: TxItem['direction'] = 'self';
let amount: string | undefined;
if (typeof pre === 'number' && typeof post === 'number') {
const delta = post - pre;
if (delta < 0) {
direction = 'out';
amount = String(-delta);
} else if (delta > 0) {
direction = 'in';
amount = String(delta);
}
}
results.push({
txid: sig.signature, txid: sig.signature,
timestamp: sig.blockTime ?? null, timestamp: sig.blockTime ?? null,
direction: 'self' as const, // без deep parsing — направление неизвестно direction,
})); amount,
});
} catch {
// Если getTransaction fails — fallback на minimal entry
results.push({
txid: sig.signature,
timestamp: sig.blockTime ?? null,
direction: 'self',
});
}
}
return results;
} }
// ─────────────────────── HELPERS ─────────────────────── // ─────────────────────── HELPERS ───────────────────────
@@ -584,10 +859,52 @@ function tronAddressToHex(address: string): string {
return hex.slice(2, 42); // 20 bytes без префикса 0x41 return hex.slice(2, 42); // 20 bytes без префикса 0x41
} }
/**
* Encode TRON address: 20-byte hex (без 0x41 prefix) → base58check 'T...' string.
* H51 — фронтенд получал raw hex `41...` instead of readable `T...`. Fix.
*/
function hexToTron(hex: string): string { function hexToTron(hex: string): string {
// Формат TronGrid: 41xxxx (20 bytes after prefix). Это base58check. if (!hex) return '';
// Для простоты возвращаем как есть hex (можно улучшить на full base58check если нужно). // Принимаем hex с или без префикса 0x41
let bytesHex = hex.toLowerCase();
if (bytesHex.startsWith('0x')) bytesHex = bytesHex.slice(2);
// Если уже 21 byte (с prefix) — оставляем; если 20 — добавляем 0x41
if (bytesHex.length === 40) {
bytesHex = '41' + bytesHex;
} else if (bytesHex.length !== 42) {
// Unknown length — fail-safe return raw input для backward compat
return hex; return hex;
}
if (!/^[0-9a-f]+$/.test(bytesHex)) return hex;
const payload = Buffer.from(bytesHex, 'hex');
// SHA256d checksum (4 bytes)
const h1 = createHash('sha256').update(payload).digest();
const h2 = createHash('sha256').update(h1).digest();
const fullBytes = Buffer.concat([payload, h2.subarray(0, 4)]);
// base58 encode
return base58Encode(fullBytes);
}
const BASE58_ALPHABET_OPS = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function base58Encode(bytes: Buffer): string {
let num = 0n;
for (const b of bytes) {
num = (num << 8n) + BigInt(b);
}
let s = '';
while (num > 0n) {
s = BASE58_ALPHABET_OPS[Number(num % 58n)] + s;
num /= 58n;
}
// Leading zero bytes → leading '1's
for (const b of bytes) {
if (b === 0) s = '1' + s;
else break;
}
return s;
} }
async function fetchJson(url: string, init?: RequestInit): Promise<any> { async function fetchJson(url: string, init?: RequestInit): Promise<any> {

View File

@@ -0,0 +1,859 @@
/**
* Bridge-specific signers/helpers — отдельный файл чтобы не разрастать `wallet-signer.service.ts`.
*
* Чем отличается от обычного signAndBroadcast:
* - EVM `signAndBroadcastEvmApprove` — ERC20.approve(spender, amount) для bridge router'а;
* включает wait 1 conf чтобы next tx видел свежий allowance.
* - `readErc20Allowance` — direct view call (без подписи) для pre-check.
* - TRX/BTC — bridge-specific path для unsigned tx от Relay/LiFi.
*/
import { ethers } from 'ethers';
import { createHash } from 'crypto';
import * as bip39 from 'bip39';
import { BIP32Factory } from 'bip32';
import * as ecc from 'tiny-secp256k1';
import * as bitcoin from 'bitcoinjs-lib';
import { env } from '../config/env';
import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service';
import { pickProxiedEvmProvider } from '../lib/outbound-proxy';
import { logger } from '../lib/logger';
const bip32 = BIP32Factory(ecc);
const ETH_RPCS = [
'https://ethereum-rpc.publicnode.com',
'https://eth.llamarpc.com',
'https://rpc.ankr.com/eth',
];
const BSC_RPCS = [
'https://bsc-dataseed.binance.org',
'https://bsc-dataseed1.binance.org',
'https://bsc-dataseed2.binance.org',
'https://bsc.publicnode.com',
];
const TRONGRID = 'https://api.trongrid.io';
const BLOCKSTREAM = 'https://blockstream.info/api';
const HTTP_TIMEOUT_MS = 20_000;
const APPROVE_GAS_LIMIT = 80_000; // EIP-2 approve ~50k базовая + overhead
const MAX_GAS_PRICE_GWEI = 500;
const ERC20_ABI = [
'function approve(address spender, uint256 amount) returns (bool)',
'function allowance(address owner, address spender) view returns (uint256)',
'function balanceOf(address owner) view returns (uint256)',
];
/**
* Структурированная ошибка для balance pre-check. Controller'ы маппят `code === 'INSUFFICIENT_BALANCE'`
* в HTTP 400 с human-readable message.
*/
export class InsufficientBalanceError extends Error {
code = 'INSUFFICIENT_BALANCE' as const;
constructor(message: string) {
super(message);
this.name = 'InsufficientBalanceError';
}
}
/**
* Структурированная ошибка для pre-broadcast simulation revert. Controller'ы маппят
* `code === 'SIMULATION_FAILED'` в HTTP 400. Поскольку simulation НЕ broadcast'ит — fees
* пользователя не сгорают.
*/
export class BridgeSimulationError extends Error {
code = 'SIMULATION_FAILED' as const;
constructor(message: string) {
super(message);
this.name = 'BridgeSimulationError';
}
}
// ─── EVM helpers ──────────────────────────────────────────────────────
export interface SignEvmApproveParams {
chain: 'ETH' | 'BSC';
mnemonic: string;
expectedFromAddress: string;
spender: string; // bridge router address (LiFi diamond / Relay router)
token: string; // ERC20 contract address
amount: string; // exact approve amount (smallest units, decimal string)
}
/**
* Sign + broadcast ERC20.approve(spender, amount). Waits 1 confirmation
* перед return — bridge tx сразу следующий видит свежий allowance.
*/
export async function signAndBroadcastEvmApprove(p: SignEvmApproveParams): Promise<{ txid: string }> {
const expectedChainId = p.chain === 'ETH' ? 1 : 56;
const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
if (wallet.address.toLowerCase() !== p.expectedFromAddress.toLowerCase()) {
throw new Error(`Derived ${p.chain} address ${wallet.address} ≠ stored ${p.expectedFromAddress}`);
}
const provider = await pickProxiedEvmProvider(rpcs, expectedChainId);
const signer = wallet.connect(provider);
const token = new ethers.Contract(p.token, ERC20_ABI, signer);
// Fee tier: используем provider.getFeeData() — это OK для approve (low priority).
const feeData = await provider.getFeeData();
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
let maxFeePerGas = feeData.maxFeePerGas ?? ethers.utils.parseUnits('30', 'gwei');
let maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.utils.parseUnits('1', 'gwei');
if (maxFeePerGas.gt(capWei)) maxFeePerGas = capWei;
if (maxPriorityFeePerGas.gt(maxFeePerGas)) maxPriorityFeePerGas = maxFeePerGas;
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
const data = token.interface.encodeFunctionData('approve', [p.spender, ethers.BigNumber.from(p.amount)]);
const sent = await signer.sendTransaction({
to: p.token,
data,
value: 0,
chainId: expectedChainId,
nonce,
gasLimit: APPROVE_GAS_LIMIT,
type: 2,
maxFeePerGas,
maxPriorityFeePerGas,
});
// Wait 1 conf чтобы bridge tx (next) видел updated allowance
await Promise.race([
sent.wait(1),
new Promise((_, reject) => setTimeout(() => reject(new Error('approve confirm timeout')), 60_000)),
]);
logger.info(`EVM approve confirmed: chain=${p.chain} token=${p.token} spender=${p.spender} amount=${p.amount} txid=${sent.hash}`);
return { txid: sent.hash };
}
export interface ReadErc20AllowanceParams {
chain: 'ETH' | 'BSC';
token: string;
owner: string;
spender: string;
}
export async function readErc20Allowance(p: ReadErc20AllowanceParams): Promise<bigint> {
const expectedChainId = p.chain === 'ETH' ? 1 : 56;
const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
const provider = await pickProxiedEvmProvider(rpcs, expectedChainId);
const token = new ethers.Contract(p.token, ERC20_ABI, provider);
const res: ethers.BigNumber = await token.allowance(p.owner, p.spender);
return BigInt(res.toString());
}
/**
* Read EVM native balance (BNB / ETH) for an address. Smallest units (wei) as bigint.
*/
export async function readEvmNativeBalance(chain: 'ETH' | 'BSC', address: string): Promise<bigint> {
const chainId = chain === 'ETH' ? 1 : 56;
const rpcs = chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
const provider = await pickProxiedEvmProvider(rpcs, chainId);
const bal: ethers.BigNumber = await provider.getBalance(address);
return BigInt(bal.toString());
}
/**
* Read ERC20 token balance (USDT / USDC / etc.) for an address. Smallest units as bigint.
*/
export async function readErc20Balance(chain: 'ETH' | 'BSC', token: string, owner: string): Promise<bigint> {
const chainId = chain === 'ETH' ? 1 : 56;
const rpcs = chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
const provider = await pickProxiedEvmProvider(rpcs, chainId);
const c = new ethers.Contract(token, ERC20_ABI, provider);
const res: ethers.BigNumber = await c.balanceOf(owner);
return BigInt(res.toString());
}
// ─── SOL balance helpers ──────────────────────────────────────────────
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
/**
* Read SOL native balance in lamports. Используется для bridge-execute pre-check
* чтобы сразу отвергать "insufficient lamports" simulation errors с человеческим message.
*/
export async function readSolBalance(address: string): Promise<bigint> {
const body = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getBalance',
params: [address],
});
const res = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
const lamports = res?.result?.value;
if (typeof lamports !== 'number') {
throw new Error(`SOL balance read failed: ${JSON.stringify(res).slice(0, 200)}`);
}
return BigInt(lamports);
}
/**
* Read SPL token balance (USDC/USDT/...) для SOL owner. Returns smallest units.
* Если token account не существует (юзер ни разу не получал token) — возвращает 0n.
*/
export async function readSplTokenBalance(owner: string, mint: string): Promise<bigint> {
const body = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getTokenAccountsByOwner',
params: [owner, { mint }, { encoding: 'jsonParsed' }],
});
const res = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
const accounts = res?.result?.value || [];
let total = 0n;
for (const acc of accounts) {
const raw = acc?.account?.data?.parsed?.info?.tokenAmount?.amount;
if (typeof raw === 'string') total += BigInt(raw);
}
return total;
}
// ─── TRX bridge helpers ───────────────────────────────────────────────
export interface SignRawTronParams {
mnemonic: string;
expectedFromAddress: string;
contractAddress: string; // TRC20 token / LiFi router (T...base58)
callData: string; // hex calldata (без 0x ИЛИ с 0x — нормализуем)
callValue: bigint; // TRX amount в sun (0 для most contract calls)
feeLimit: number; // максимум sun сжигается на energy/bandwidth (typical 30-150 TRX)
}
/**
* Sign + broadcast arbitrary Tron smart-contract call. Используется для bridge'а
* через LiFi/Jumper (которые возвращают raw contract call для TRC20 token approve / bridge).
*
* Flow (HTTP-only через TronGrid, no tronweb lib):
* 1. POST /wallet/triggersmartcontract (build unsigned tx)
* 2. MITM check: recompute txID, verify expiration/timestamp bounds, verify owner/contract
* 3. Sign (ECDSA secp256k1, same as EVM signing с recoveryParam append)
* 4. POST /wallet/broadcasttransaction
*/
export async function signAndBroadcastRawTron(p: SignRawTronParams): Promise<{ txid: string }> {
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX);
const fromTronAddr = ethAddressToTron(wallet.address);
if (fromTronAddr !== p.expectedFromAddress) {
throw new Error(`Derived TRX address ${fromTronAddr} ≠ stored ${p.expectedFromAddress}`);
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
// Normalize calldata. triggersmartcontract API принимает либо:
// - function_selector (canonical string "transfer(address,uint256)") + parameter (hex args)
// → TronGrid keccak'ит selector NAME и prepend'ит к parameter
// - data (full hex calldata = selector + params) → используется как-есть
//
// Если у нас неизвестный selector (LiFi bridge calls, custom routers) — мы НЕ можем
// передать "0x..." как function_selector (TronGrid keccak'нёт строку и получит
// полностью другие 4 байта → contract revert). Используем `data` напрямую.
const data = p.callData.startsWith('0x') ? p.callData.slice(2) : p.callData;
if (data.length < 8) throw new Error('TRX call data too short (need >= 4-byte selector)');
const selector8 = data.slice(0, 8);
const knownCanonical = lookupKnownSelector(selector8);
const callBody: any = {
owner_address: fromTronAddr,
contract_address: p.contractAddress,
fee_limit: p.feeLimit,
call_value: p.callValue > 0n ? Number(p.callValue) : 0,
visible: true,
};
if (knownCanonical) {
callBody.function_selector = knownCanonical;
callBody.parameter = data.slice(8);
} else {
// Unknown selector (LiFi bridge call) — pass full calldata as-is
callBody.data = data;
}
// ── Fix 2: pre-simulation guard ──
// Dry-run через triggerconstantcontract (read-only) ДО build+broadcast'а.
// Если контракт revert'нёт на simulation — НЕ broadcast'им, fees не сгорают.
// Это catches LiFi/Relay stale quotes + bad calldata + insufficient allowance + др.
try {
const simRes = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
method: 'POST',
headers,
body: JSON.stringify(callBody),
});
const simOk = simRes?.result?.result === true;
if (!simOk) {
const rawMsg = simRes?.result?.message;
const msgDecoded = rawMsg
? Buffer.from(rawMsg, 'hex')
.toString()
.split('')
.map((ch) => (ch.charCodeAt(0) < 32 ? ' ' : ch))
.join('')
.trim()
: '';
const reason =
msgDecoded ||
simRes?.result?.code ||
JSON.stringify(simRes?.result || {}).slice(0, 200);
throw new BridgeSimulationError(
`TRX bridge simulation reverted at ${p.contractAddress}: ${reason}. NOT broadcast (fees would burn). Re-quote and retry.`,
);
}
} catch (err: any) {
if (err instanceof BridgeSimulationError) throw err;
// TronGrid simulation API down → degraded mode: log warning, proceed (risk of burn'нутых fees,
// но user не блокируется на upstream outage).
logger.warn(`TRX pre-simulation failed (TronGrid down?), proceeding to broadcast: ${err?.message}`);
}
const built = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, {
method: 'POST',
headers,
body: JSON.stringify(callBody),
});
const txBody = built?.transaction;
if (!txBody?.txID || !txBody?.raw_data_hex || !txBody?.raw_data) {
throw new Error(`TRX bridge tx build failed: ${JSON.stringify(built).slice(0, 200)}`);
}
// MITM defense (як в sendTrx): recompute txID + bounds + owner/contract.
const expectedTxId = createHash('sha256').update(Buffer.from(txBody.raw_data_hex, 'hex')).digest('hex');
if (expectedTxId !== txBody.txID) {
throw new Error('TRX bridge txID mismatch — possible MITM');
}
const nowMs = Date.now();
const expiration = Number(txBody.raw_data.expiration);
const timestamp = Number(txBody.raw_data.timestamp);
if (!Number.isFinite(expiration) || expiration - nowMs > 90_000 || expiration <= nowMs) {
throw new Error(`TRX expiration out of bounds: ${expiration - nowMs}ms`);
}
if (!Number.isFinite(timestamp) || Math.abs(timestamp - nowMs) > 30_000) {
throw new Error(`TRX timestamp drift: ${timestamp - nowMs}ms`);
}
const contract0 = txBody.raw_data.contract?.[0];
if (contract0?.type !== 'TriggerSmartContract') {
throw new Error(`TRX bridge contract type unexpected: ${contract0?.type}`);
}
const cv = contract0.parameter?.value;
if (cv?.owner_address !== fromTronAddr) {
throw new Error(`TRX bridge owner mismatch: ${cv?.owner_address}`);
}
if (cv?.contract_address !== p.contractAddress) {
throw new Error(`TRX bridge contract mismatch: expected ${p.contractAddress}, got ${cv?.contract_address}`);
}
// Sign
const sk = new ethers.utils.SigningKey(wallet.privateKey);
const sig = sk.signDigest('0x' + txBody.txID);
if (sig.recoveryParam !== 0 && sig.recoveryParam !== 1) {
throw new Error(`TRX bridge sig recoveryParam invalid: ${sig.recoveryParam}`);
}
const sigHex = sig.r.slice(2) + sig.s.slice(2) + sig.recoveryParam.toString(16).padStart(2, '0');
const cleanTxBody = {
txID: txBody.txID,
raw_data: txBody.raw_data,
raw_data_hex: txBody.raw_data_hex,
signature: [sigHex],
visible: true,
};
const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, {
method: 'POST',
headers,
body: JSON.stringify(cleanTxBody),
});
if (!broadcast?.result) {
const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown';
const code = broadcast?.code || 'NO_CODE';
throw new Error(`TRX bridge broadcast failed [${code}]: ${msg.slice(0, 200)}`);
}
return { txid: txBody.txID };
}
export interface SignTrc20ApproveParams {
mnemonic: string;
expectedFromAddress: string;
spender: string; // bridge router T...
token: string; // TRC20 contract T...
amount: string; // exact approve amount (decimal string)
}
export async function signAndBroadcastTrc20Approve(p: SignTrc20ApproveParams): Promise<{ txid: string }> {
// approve(address spender, uint256 amount) — function selector 0x095ea7b3
const spenderHex = tronBase58ToHex(p.spender).padStart(64, '0');
const amountHex = BigInt(p.amount).toString(16).padStart(64, '0');
const callData = '095ea7b3' + spenderHex + amountHex;
return signAndBroadcastRawTron({
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
contractAddress: p.token,
callData,
callValue: 0n,
feeLimit: 30_000_000,
});
}
export interface SignTronPrebuiltParams {
mnemonic: string;
expectedFromAddress: string;
/** Pre-built protobuf-encoded raw_data_hex от LiFi/Relay (transactionRequest.data) */
rawDataHex: string;
}
/**
* Sign + broadcast a PRE-BUILT Tron tx (LiFi/Relay bridges возвращают уже-готовый
* raw_data_hex в `transactionRequest.data` — НЕ EVM-style selector+params).
*
* **Не строит новый tx.** Просто:
* 1. Compute txID = SHA256(raw_data_hex)
* 2. Verify raw_data_hex содержит наш owner address (lightweight MITM check)
* 3. Sign txID
* 4. Broadcast {txID, raw_data_hex, signature[]}
*
* Этот helper НЕЛЬЗЯ использовать для locally-built tx (TRC20 approve и т.п.) —
* для них используем `signAndBroadcastRawTron` / `signAndBroadcastTrc20Approve`,
* которые сами строят tx через triggersmartcontract.
*
* Trust model: мы доверяем LiFi/Relay что raw_data корректен (они sim'или его на
* их стороне). MITM defense ограничена substring-проверкой нашего owner_address
* в protobuf bytes — без полного protobuf decoder.
*/
export async function signAndBroadcastTronPrebuiltTx(p: SignTronPrebuiltParams): Promise<{ txid: string }> {
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX);
const fromTronAddr = ethAddressToTron(wallet.address);
if (fromTronAddr !== p.expectedFromAddress) {
throw new Error(`Derived TRX address ${fromTronAddr} ≠ stored ${p.expectedFromAddress}`);
}
// Normalize input
const rawDataHex = p.rawDataHex.startsWith('0x') ? p.rawDataHex.slice(2) : p.rawDataHex;
if (rawDataHex.length === 0 || !/^[0-9a-f]+$/i.test(rawDataHex)) {
throw new Error('TRX prebuilt raw_data_hex is empty or not valid hex');
}
// MITM defense (lightweight, без protobuf decoder):
// Tron addresses в protobuf encoded as 21 bytes = 0x41 + 20-byte hex payload.
// Если наш owner address не в raw_data — это не наша tx → reject.
const ownerPayloadHex = tronBase58ToHex(fromTronAddr); // 20 bytes, без 0x41 prefix
// Полный on-chain owner = '41' + ownerPayloadHex (21 bytes hex)
const fullOwnerHex = '41' + ownerPayloadHex;
if (!rawDataHex.toLowerCase().includes(fullOwnerHex.toLowerCase())) {
throw new Error(
`TRX prebuilt tx does not contain our owner address ${fromTronAddr} (${fullOwnerHex}) in raw_data — possible MITM or wrong wallet`,
);
}
// Compute txID (это и есть подписываемый digest)
const txID = createHash('sha256').update(Buffer.from(rawDataHex, 'hex')).digest('hex');
// Sign
const sk = new ethers.utils.SigningKey(wallet.privateKey);
const sig = sk.signDigest('0x' + txID);
if (sig.recoveryParam !== 0 && sig.recoveryParam !== 1) {
throw new Error(`TRX prebuilt sig recoveryParam invalid: ${sig.recoveryParam}`);
}
const sigHex = sig.r.slice(2) + sig.s.slice(2) + sig.recoveryParam.toString(16).padStart(2, '0');
// Broadcast
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
const broadcastBody = {
txID,
raw_data_hex: rawDataHex,
signature: [sigHex],
visible: false,
};
const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasthex`, {
method: 'POST',
headers,
// /wallet/broadcasthex — accepts hex transaction directly. Альтернатива
// /wallet/broadcasttransaction (требует full object + raw_data field decoded).
// broadcasthex проще: ему достаточно raw_data_hex + signature.
body: JSON.stringify({
transaction: encodeTronBroadcastHex(rawDataHex, sigHex),
}),
}).catch(async (firstErr: any) => {
// Fallback на broadcasttransaction если broadcasthex не работает
logger.warn(`broadcasthex failed (${firstErr?.message}), trying broadcasttransaction`);
return await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, {
method: 'POST',
headers,
body: JSON.stringify(broadcastBody),
});
});
if (!broadcast?.result && broadcast?.code !== 'SUCCESS') {
const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown';
const code = broadcast?.code || 'NO_CODE';
throw new Error(`TRX prebuilt broadcast failed [${code}]: ${msg.slice(0, 200)}`);
}
logger.info(`TRX prebuilt tx broadcast OK: from=${fromTronAddr} txID=${txID}`);
return { txid: txID };
}
/**
* Encode signed tx как single hex string для /wallet/broadcasthex.
* Format: full Transaction protobuf = raw_data + signature.
*
* Тут мы делаем небольшой protobuf-сборщик:
* Transaction { bytes raw_data = 1 (length-prefixed); repeated bytes signature = 2 }
* Field 1 (raw_data), wire type 2 (length-delimited): tag = 0x0a
* Field 2 (signature), wire type 2 (length-delimited): tag = 0x12
*/
function encodeTronBroadcastHex(rawDataHex: string, sigHex: string): string {
const rawDataBuf = Buffer.from(rawDataHex, 'hex');
const sigBuf = Buffer.from(sigHex, 'hex');
const parts: number[] = [];
// Field 1: raw_data
parts.push(0x0a);
appendVarint(parts, rawDataBuf.length);
for (const b of rawDataBuf) parts.push(b);
// Field 2: signature
parts.push(0x12);
appendVarint(parts, sigBuf.length);
for (const b of sigBuf) parts.push(b);
return Buffer.from(parts).toString('hex');
}
function appendVarint(out: number[], n: number): void {
while (n > 0x7f) {
out.push((n & 0x7f) | 0x80);
n >>>= 7;
}
out.push(n & 0x7f);
}
export interface ReadTrc20AllowanceParams {
token: string;
owner: string;
spender: string;
}
export async function readTrc20Allowance(p: ReadTrc20AllowanceParams): Promise<bigint> {
// allowance(address owner, address spender) → uint256. Selector = 0xdd62ed3e
const ownerHex = tronBase58ToHex(p.owner).padStart(64, '0');
const spenderHex = tronBase58ToHex(p.spender).padStart(64, '0');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
const res = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
method: 'POST',
headers,
body: JSON.stringify({
owner_address: p.owner,
contract_address: p.token,
function_selector: 'allowance(address,address)',
parameter: ownerHex + spenderHex,
visible: true,
}),
});
const result = res?.constant_result?.[0];
if (!result) return 0n;
return BigInt('0x' + result);
}
/**
* Read native TRX balance в sun. 1 TRX = 1_000_000 sun.
*/
export async function readTrxBalance(address: string): Promise<bigint> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
const res = await fetchJson(`${TRONGRID}/wallet/getaccount`, {
method: 'POST',
headers,
body: JSON.stringify({ address, visible: true }),
});
const bal = res?.balance;
if (bal === undefined || bal === null) return 0n; // empty/uninitialized account
return BigInt(bal);
}
/**
* Read TRC20 token balance для owner. Returns smallest units.
*/
export async function readTrc20Balance(token: string, owner: string): Promise<bigint> {
const ownerHex = tronBase58ToHex(owner).padStart(64, '0');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
const res = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
method: 'POST',
headers,
body: JSON.stringify({
owner_address: owner,
contract_address: token,
function_selector: 'balanceOf(address)',
parameter: ownerHex,
visible: true,
}),
});
const result = res?.constant_result?.[0];
if (!result) return 0n;
return BigInt('0x' + result);
}
// ─── BTC bridge helpers ──────────────────────────────────────────────
/**
* Sum confirmed UTXOs (satoshis) для BTC address — = доступный к spending balance.
* Unconfirmed UTXOs игнорируются (matches sendBtc / signAndBroadcastBtcDeposit behavior).
*/
export async function readBtcConfirmedBalance(address: string): Promise<bigint> {
const utxosRes = await fetchJson(`${BLOCKSTREAM}/address/${address}/utxo`);
const utxos = (utxosRes as any[]) || [];
let total = 0n;
for (const u of utxos) {
if (u.status?.confirmed) total += BigInt(u.value);
}
return total;
}
export interface SignBtcDepositParams {
mnemonic: string;
expectedFromAddress: string;
depositAddress: string; // куда Relay просит отправить BTC (bridge solver address)
amountSat: bigint; // сколько satoshis
feeRateSatPerVb?: number; // optional override
}
/**
* Build P2WPKH (segwit bc1...) tx с одним recipient = depositAddress + change.
* Sign все inputs + broadcast через blockstream.info.
*
* Re-uses bitcoinjs-lib patterns из существующего sendBtc — но без token check
* и с custom recipient (вместо p.to).
*/
export async function signAndBroadcastBtcDeposit(p: SignBtcDepositParams): Promise<{ txid: string }> {
const seed = await bip39.mnemonicToSeed(p.mnemonic);
const root = bip32.fromSeed(seed);
const child = root.derivePath(DERIVATION_PATHS.BTC);
if (!child.publicKey) throw new Error('BTC derivation failed');
const network = bitcoin.networks.bitcoin;
const pubkeyBuf = Buffer.from(child.publicKey);
const payment = bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, network });
if (!payment.address || !payment.output) throw new Error('BTC payment build failed');
if (payment.address !== p.expectedFromAddress) {
throw new Error(`Derived BTC address ${payment.address} ≠ stored ${p.expectedFromAddress}`);
}
// Bech32 sanity for deposit address (mainnet bc1... only)
if (!/^bc1[ac-hj-np-z02-9]{6,}$/.test(p.depositAddress)) {
throw new Error(`BTC deposit address malformed: ${p.depositAddress}`);
}
const [utxosRes, feesRes, tipHeightRes] = await Promise.all([
fetchJson(`${BLOCKSTREAM}/address/${payment.address}/utxo`),
fetchJson(`${BLOCKSTREAM}/fee-estimates`),
fetchJson(`${BLOCKSTREAM}/blocks/tip/height`).catch(() => null),
]);
const utxos = ((utxosRes as any[]) || []).filter((u) => u.status?.confirmed);
if (!utxos.length) throw new Error('No confirmed BTC UTXOs to spend');
const feeMap = feesRes as Record<string, number>;
const rawCandidate = feeMap['6'] ?? feeMap['3'] ?? feeMap['1'];
const rawNum = typeof rawCandidate === 'number' && Number.isFinite(rawCandidate) && rawCandidate > 0
? rawCandidate
: 2;
const feeRate = Math.min(Math.max(Math.ceil(p.feeRateSatPerVb ?? rawNum), 2), 200); // floor 2 / ceil 200
if (p.amountSat <= 0n || p.amountSat > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error('BTC amount out of safe range');
}
// Sort UTXOs largest-first для минимизации количества inputs (меньше fee).
utxos.sort((a, b) => b.value - a.value);
const psbt = new bitcoin.Psbt({ network });
if (typeof tipHeightRes === 'number' && tipHeightRes > 0) {
psbt.setLocktime(tipHeightRes); // anti fee-sniping
}
const feeFor = (ins: number, outs: number) =>
BigInt(Math.ceil((ins * 68 + outs * 31 + 11) * feeRate * 1.1));
let totalIn = 0n;
const selected: typeof utxos = [];
for (const u of utxos) {
selected.push(u);
totalIn += BigInt(u.value);
if (totalIn >= p.amountSat + feeFor(selected.length, 2)) break;
}
const fee = feeFor(selected.length, 2);
if (totalIn < p.amountSat + fee) {
throw new Error(`Insufficient BTC balance: have=${totalIn} sat, need=${p.amountSat + fee} sat`);
}
for (const u of selected) {
psbt.addInput({
hash: u.txid,
index: u.vout,
sequence: 0xfffffffd, // RBF enabled (BIP125)
witnessUtxo: { script: payment.output, value: u.value },
});
}
psbt.addOutput({ address: p.depositAddress, value: Number(p.amountSat) });
const change = totalIn - p.amountSat - fee;
const DUST_THRESHOLD = 294n;
if (change < 0n) {
throw new Error('BTC change negative (math bug)');
}
if (change > DUST_THRESHOLD) {
psbt.addOutput({ address: payment.address, value: Number(change) });
} else if (change > 0n) {
throw new Error(
`BTC change ${change} sat below dust threshold. Reduce amount by ${change} sat to consolidate.`,
);
}
for (let i = 0; i < selected.length; i++) {
psbt.signInput(i, {
publicKey: pubkeyBuf,
sign: (hash: Buffer) => Buffer.from(child.sign(hash)),
});
}
psbt.finalizeAllInputs();
const txHex = psbt.extractTransaction().toHex();
const broadcastController = new AbortController();
const tBroadcast = setTimeout(() => broadcastController.abort(), HTTP_TIMEOUT_MS);
let resp: Response;
try {
resp = await fetch(`${BLOCKSTREAM}/tx`, {
method: 'POST',
body: txHex,
headers: { 'Content-Type': 'text/plain' },
signal: broadcastController.signal,
});
} finally {
clearTimeout(tBroadcast);
}
if (!resp.ok) {
const body = await resp.text().catch(() => '');
throw new Error(`BTC bridge broadcast failed (${resp.status}): ${body.slice(0, 200)}`);
}
const txid = (await resp.text()).trim();
logger.info(`BTC deposit broadcast OK: from=${payment.address} to=${p.depositAddress} sat=${p.amountSat} txid=${txid}`);
return { txid };
}
// ─── HTTP helper (local copy of `fetchJson` for testability) ─────────
/**
* HTTP JSON fetcher с retry на 429 (rate limit) и 503 (transient overload).
*
* Backoff sequence: 0ms → 1500ms → 4000ms → 8000ms (≈13s worst-case до final fail).
* Это покрывает TronGrid free-tier 3 req/sec limit (suspended 5s после превышения)
* и blockstream.info burst limits.
*
* AbortController per-attempt. 4xx (кроме 429) и 5xx (кроме 503) — no retry, throws immediately.
*/
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
const RETRY_DELAYS = [0, 1500, 4000, 8000];
let lastErrMsg = '';
for (let attempt = 0; attempt < RETRY_DELAYS.length; attempt++) {
if (RETRY_DELAYS[attempt] > 0) {
await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt]));
}
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
try {
const res = await fetch(url, { ...init, signal: controller.signal });
if (res.ok) {
return await res.json();
}
const body = await res.text().catch(() => '');
lastErrMsg = `Upstream ${res.status}: ${body.slice(0, 200)}`;
// Retry на 429 (rate limit) и 503 (transient)
if (res.status === 429 || res.status === 503) {
logger.warn(`fetchJson ${res.status} on ${url} (attempt ${attempt + 1}/${RETRY_DELAYS.length}), backing off`);
continue;
}
throw new Error(lastErrMsg);
} catch (err: any) {
if (err?.name === 'AbortError') {
lastErrMsg = `HTTP timeout (${HTTP_TIMEOUT_MS}ms)`;
// Не retry на timeout — может быть upstream сильно загружен, escalate
throw new Error(lastErrMsg);
}
if (attempt < RETRY_DELAYS.length - 1 && /Upstream (429|503)/.test(String(err?.message))) {
continue;
}
throw err;
} finally {
clearTimeout(t);
}
}
throw new Error(`fetchJson exhausted retries: ${lastErrMsg}`);
}
// ─── TRON base58check decoder (local copy from wallet-signer.service.ts) ───
// Decode base58check TRON address (T...) → 20-byte hex (без 0x41 prefix, без checksum).
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function tronBase58ToHex(address: string): string {
if (typeof address !== 'string' || address.length === 0) {
throw new Error('Invalid TRON address: empty');
}
let num = 0n;
for (const ch of address) {
const i = BASE58_ALPHABET.indexOf(ch);
if (i === -1) throw new Error('Invalid base58 character in TRON address');
num = num * 58n + BigInt(i);
}
let hex = num.toString(16);
if (hex.length % 2 !== 0) hex = '0' + hex;
let bytes = Buffer.from(hex, 'hex');
let leadingOnes = 0;
for (const ch of address) {
if (ch === '1') leadingOnes++;
else break;
}
if (leadingOnes > 0) {
bytes = Buffer.concat([Buffer.alloc(leadingOnes, 0), bytes]);
}
if (bytes.length !== 25) {
throw new Error(`Invalid TRON address length: ${bytes.length} bytes (expected 25)`);
}
if (bytes[0] !== 0x41) {
throw new Error(`Invalid TRON address prefix: 0x${bytes[0].toString(16)} (expected 0x41)`);
}
const payload = bytes.subarray(0, 21);
const checksum = bytes.subarray(21, 25);
const dblSha = createHash('sha256').update(createHash('sha256').update(payload).digest()).digest();
if (!dblSha.subarray(0, 4).equals(checksum)) {
throw new Error('TRON address checksum mismatch');
}
return payload.subarray(1).toString('hex');
}
// Selector hex (8 chars) → canonical name (e.g. '095ea7b3' → 'approve(address,uint256)').
// Returns `null` если selector неизвестен — в этом случае caller должен использовать
// `data` field вместо `function_selector + parameter` (TronGrid keccak'ит function_selector
// как имя функции; для произвольного selector "0xXXXXXXXX" это даст НЕВЕРНЫЕ 4 байта,
// → contract revert + потеря fees).
function lookupKnownSelector(selector8: string): string | null {
const map: Record<string, string> = {
'a9059cbb': 'transfer(address,uint256)',
'095ea7b3': 'approve(address,uint256)',
'23b872dd': 'transferFrom(address,address,uint256)',
'dd62ed3e': 'allowance(address,address)',
'70a08231': 'balanceOf(address)',
};
return map[selector8.toLowerCase()] || null;
}

View File

@@ -14,11 +14,27 @@ import * as bip39 from 'bip39';
import { BIP32Factory } from 'bip32'; import { BIP32Factory } from 'bip32';
import * as ecc from 'tiny-secp256k1'; import * as ecc from 'tiny-secp256k1';
import * as bitcoin from 'bitcoinjs-lib'; import * as bitcoin from 'bitcoinjs-lib';
import { Keypair, Connection, PublicKey, SystemProgram, Transaction, ComputeBudgetProgram } from '@solana/web3.js'; import {
Keypair, Connection, PublicKey, SystemProgram, Transaction, ComputeBudgetProgram,
VersionedTransaction, TransactionMessage, AddressLookupTableAccount, TransactionInstruction,
} from '@solana/web3.js';
import {
getAssociatedTokenAddressSync,
createAssociatedTokenAccountIdempotentInstruction,
createTransferCheckedInstruction,
TOKEN_PROGRAM_ID,
} from '@solana/spl-token';
import { derivePath } from 'ed25519-hd-key'; import { derivePath } from 'ed25519-hd-key';
import { env } from '../config/env'; import { env } from '../config/env';
import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service'; import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service';
import { getEvmFeeForTier, type FeeTier } from './gas-oracle.service'; import { getEvmFeeForTier, type FeeTier } from './gas-oracle.service';
// Bridge-only proxy helpers: signAndBroadcastRawEvm + Solana bridge sign идут через
// OUTBOUND_PROXY_URL. sendEvm/sendSol/sendBtc/sendTrx остаются на direct connection.
import {
pickProxiedEvmProvider,
getProxiedSolConnection,
} from '../lib/outbound-proxy';
import { getTokenInfo } from '../lib/token-registry';
import type { ChainCode } from '../lib/address-validators'; import type { ChainCode } from '../lib/address-validators';
const bip32 = BIP32Factory(ecc); const bip32 = BIP32Factory(ecc);
@@ -109,8 +125,8 @@ export interface RawEvmSignParams {
export async function signAndBroadcast(p: SendParams): Promise<{ txid: string }> { export async function signAndBroadcast(p: SendParams): Promise<{ txid: string }> {
switch (p.chain) { switch (p.chain) {
case 'ETH': return sendEvm(p, ETH_RPC, 1, USDT_ERC20); case 'ETH': return sendEvm(p, ETH_RPC, 1);
case 'BSC': return sendEvm(p, BSC_RPC, 56, USDT_BEP20); case 'BSC': return sendEvm(p, BSC_RPC, 56);
case 'BTC': return sendBtc(p); case 'BTC': return sendBtc(p);
case 'TRX': return sendTrx(p); case 'TRX': return sendTrx(p);
case 'SOL': return sendSol(p); case 'SOL': return sendSol(p);
@@ -138,8 +154,9 @@ export async function signAndBroadcastRawEvm(p: RawEvmSignParams): Promise<{ txi
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH); const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain); assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain);
// H29 — RPC failover // H29 — RPC failover. BRIDGE path → через OUTBOUND_PROXY_URL если задан (Jupiter/Relay
const provider = await pickProvider(rpcs, expectedChainId); // часто rate-limit'ят cloud-IP'ы). Fallback на direct если env пустой.
const provider = await pickProxiedEvmProvider(rpcs, expectedChainId);
const signer = wallet.connect(provider); const signer = wallet.connect(provider);
// Cap fees против rogue/poisoned quote с insanely-high maxFeePerGas. // Cap fees против rogue/poisoned quote с insanely-high maxFeePerGas.
@@ -200,6 +217,98 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
]); ]);
} }
/**
* EVM off-chain app fee 0.7%. Sign + broadcast + wait 1 conf — отдельно от main tx.
*
* Generalized для ETH и BSC (раньше было только BSC). Используется в:
* - `signRawEvmTx` controller: при bridge (Relay/Jumper) с `bridgeAmount` → fee tx ПЕРЕД main tx
* - `bridge-execute.service.ts:executeEvm`: atomic fee для всех EVM bridges
*
* Если fee tx revert'нёт → throw → main tx не broadcast'ится → safe.
*
* @returns { feeTxid, nextNonce, feeAmount } — nextNonce юзер может передать в main tx
*/
import { getAppFeeWallet, computeAppFee } from '../lib/app-fee';
export async function signAndBroadcastEvmFeeTx(p: {
chain: 'ETH' | 'BSC';
mnemonic: string;
expectedFromAddress: string;
bridgeAmount: string;
bridgeToken?: string | null; // null = native (ETH/BNB)
feeTier?: FeeTier;
}): Promise<{ feeTxid: string; nextNonce: number; feeAmount: string }> {
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain);
const chainId = p.chain === 'ETH' ? 1 : 56;
const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
const provider = await pickProxiedEvmProvider(rpcs, chainId);
const signer = wallet.connect(provider);
const feeAmountBig = computeAppFee(p.bridgeAmount);
if (feeAmountBig <= 0n) {
throw new Error('bridgeAmount too small — fee = 0');
}
const tier: FeeTier = p.feeTier ?? 'normal';
const fee = await getEvmFeeForTier(p.chain, tier);
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
const maxFeePerGas = ethers.BigNumber.from(fee.maxFeePerGas);
const maxPriorityFeePerGas = ethers.BigNumber.from(fee.maxPriorityFeePerGas);
if (maxFeePerGas.gt(capWei) || maxPriorityFeePerGas.gt(maxFeePerGas)) {
throw new Error(`${p.chain} fee gas invariant violated`);
}
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
const feeWallet = getAppFeeWallet(p.chain);
const isNative = !p.bridgeToken;
const feeTx: ethers.providers.TransactionRequest = isNative
? {
to: feeWallet,
value: ethers.BigNumber.from(feeAmountBig.toString()),
chainId,
nonce,
gasLimit: ethers.BigNumber.from(21_000),
type: 2,
maxFeePerGas,
maxPriorityFeePerGas,
}
: {
to: p.bridgeToken as string,
data: new ethers.utils.Interface(['function transfer(address,uint256) returns (bool)'])
.encodeFunctionData('transfer', [feeWallet, feeAmountBig.toString()]),
value: 0,
chainId,
nonce,
gasLimit: ethers.BigNumber.from(65_000),
type: 2,
maxFeePerGas,
maxPriorityFeePerGas,
};
const sent = await withTimeout(signer.sendTransaction(feeTx), HTTP_TIMEOUT_MS, `${p.chain} fee tx broadcast timed out`);
// Wait 1 conf (~3s на BSC, ~15s на ETH) — иначе main tx может revert'ить из-за nonce/state.
await withTimeout(sent.wait(1), 60_000, `${p.chain} fee tx confirmation timed out`);
return { feeTxid: sent.hash, nextNonce: nonce + 1, feeAmount: feeAmountBig.toString() };
}
/**
* Backwards-compat alias — старые callers ожидают `signAndBroadcastBscFeeTx`.
* Хардкодит `chain: 'BSC'`.
*/
export async function signAndBroadcastBscFeeTx(p: {
mnemonic: string;
expectedFromAddress: string;
bridgeAmount: string;
bridgeToken?: string | null;
feeTier?: FeeTier;
}): Promise<{ feeTxid: string; nextNonce: number; feeAmount: string }> {
return signAndBroadcastEvmFeeTx({ chain: 'BSC', ...p });
}
function assertAddressMatch(derived: string, expected: string, chain: ChainCode): void { function assertAddressMatch(derived: string, expected: string, chain: ChainCode): void {
const norm = (s: string) => const norm = (s: string) =>
chain === 'ETH' || chain === 'BSC' ? s.toLowerCase() : s; chain === 'ETH' || chain === 'BSC' ? s.toLowerCase() : s;
@@ -210,7 +319,7 @@ function assertAddressMatch(derived: string, expected: string, chain: ChainCode)
// ─── EVM (ETH / BSC) ─── // ─── EVM (ETH / BSC) ───
async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: string): Promise<{ txid: string }> { async function sendEvm(p: SendParams, rpc: string, chainId: number): Promise<{ txid: string }> {
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH); const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain); assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain);
// H29 — RPC failover (выбираем working RPC из списка для chain) // H29 — RPC failover (выбираем working RPC из списка для chain)
@@ -264,25 +373,29 @@ async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: st
throw new Error('Insufficient balance (value + gas)'); throw new Error('Insufficient balance (value + gas)');
} }
tx = { to: p.to, value, chainId, nonce, gasLimit: estGas, ...feeFields }; tx = { to: p.to, value, chainId, nonce, gasLimit: estGas, ...feeFields };
} else if (p.token.toUpperCase() === 'USDT') { } else {
// Generic ERC20/BEP20: lookup в token-registry. Поддерживаются все токены из registry.
const tokenInfo = getTokenInfo(evmChain, p.token);
if (!tokenInfo) {
throw new Error(`Token ${p.token} not in registry for chain ${evmChain}`);
}
const iface = new ethers.utils.Interface([ const iface = new ethers.utils.Interface([
...ERC20_ABI, ...ERC20_ABI,
'function balanceOf(address) view returns (uint256)', 'function balanceOf(address) view returns (uint256)',
]); ]);
const erc20 = new ethers.Contract(usdtAddr, iface, provider); const erc20 = new ethers.Contract(tokenInfo.address, iface, provider);
const tokenBal: ethers.BigNumber = await erc20.balanceOf(wallet.address); const tokenBal: ethers.BigNumber = await erc20.balanceOf(wallet.address);
if (tokenBal.lt(ethers.BigNumber.from(p.amount))) { if (tokenBal.lt(ethers.BigNumber.from(p.amount))) {
throw new Error('Insufficient token balance'); throw new Error('Insufficient token balance');
} }
const nativeBal = await provider.getBalance(wallet.address); const nativeBal = await provider.getBalance(wallet.address);
const data = iface.encodeFunctionData('transfer', [p.to, p.amount]); const data = iface.encodeFunctionData('transfer', [p.to, p.amount]);
// H10 — actual estimateGas + 20% safety. Hardcoded 80000 was too low for cold // H10 — actual estimateGas + 20% safety. Cold storage slots (first transfer to fresh
// storage slots (first transfer to recipient SSTORE costs 81-90k → OOG burn). // recipient) cost 81-90k due to SSTORE; floor 60k, ceiling 200k для sanity.
let estGas: ethers.BigNumber; let estGas: ethers.BigNumber;
try { try {
const estimated = await provider.estimateGas({ from: wallet.address, to: usdtAddr, data, value: 0 }); const estimated = await provider.estimateGas({ from: wallet.address, to: tokenInfo.address, data, value: 0 });
estGas = estimated.mul(120).div(100); // +20% estGas = estimated.mul(120).div(100); // +20%
// Floor 60k (minimum realistic), ceiling 200k (sanity)
const minGas = ethers.BigNumber.from(60000); const minGas = ethers.BigNumber.from(60000);
const maxGas = ethers.BigNumber.from(200000); const maxGas = ethers.BigNumber.from(200000);
if (estGas.lt(minGas)) estGas = minGas; if (estGas.lt(minGas)) estGas = minGas;
@@ -293,9 +406,7 @@ async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: st
if (nativeBal.lt(effectiveGasPrice.mul(estGas))) { if (nativeBal.lt(effectiveGasPrice.mul(estGas))) {
throw new Error('Insufficient native balance for gas'); throw new Error('Insufficient native balance for gas');
} }
tx = { to: usdtAddr, data, value: 0, chainId, nonce, gasLimit: estGas, ...feeFields }; tx = { to: tokenInfo.address, data, value: 0, chainId, nonce, gasLimit: estGas, ...feeFields };
} else {
throw new Error(`Token ${p.token} not supported on chainId ${chainId}`);
} }
// H25 — explicit timeout, иначе slow RPC stalls Express worker indefinitely // H25 — explicit timeout, иначе slow RPC stalls Express worker indefinitely
@@ -306,10 +417,6 @@ async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: st
// ─── SOLANA ─── // ─── SOLANA ───
async function sendSol(p: SendParams): Promise<{ txid: string }> { async function sendSol(p: SendParams): Promise<{ txid: string }> {
if (p.token) {
throw new Error('SOL SPL-token signing не реализовано (только native SOL)');
}
const seed = await bip39.mnemonicToSeed(p.mnemonic); const seed = await bip39.mnemonicToSeed(p.mnemonic);
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex')); const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
if (!key || key.length !== 32) { if (!key || key.length !== 32) {
@@ -318,64 +425,82 @@ async function sendSol(p: SendParams): Promise<{ txid: string }> {
const keypair = Keypair.fromSeed(key); const keypair = Keypair.fromSeed(key);
assertAddressMatch(keypair.publicKey.toBase58(), p.expectedFromAddress, 'SOL'); assertAddressMatch(keypair.publicKey.toBase58(), p.expectedFromAddress, 'SOL');
// C10 — lamports precision: @solana/web3.js converts BigInt → Number internally // Precision: @solana/web3.js конвертит BigInt → Number внутренне (u64 layout).
// (u64 layout). Above 2^53 lamports = silent truncation. Reject early. const amountBig = BigInt(p.amount);
const lamports = BigInt(p.amount);
const MAX_SAFE_LAMPORTS = BigInt(Number.MAX_SAFE_INTEGER); const MAX_SAFE_LAMPORTS = BigInt(Number.MAX_SAFE_INTEGER);
if (lamports > MAX_SAFE_LAMPORTS) { if (amountBig > MAX_SAFE_LAMPORTS) {
throw new Error(`SOL amount ${p.amount} lamports exceeds Number precision (max ${MAX_SAFE_LAMPORTS}); split into multiple sends`); throw new Error(`SOL amount ${p.amount} exceeds Number precision (max ${MAX_SAFE_LAMPORTS}); split into multiple sends`);
} }
if (lamports <= 0n) { if (amountBig <= 0n) {
throw new Error('SOL amount must be positive'); throw new Error('SOL amount must be positive');
} }
// H41 — singleton Connection (per-call new() leaks WebSocket subscriptions)
const conn = getSolConnection(); const conn = getSolConnection();
const toPk = new PublicKey(p.to); const toPk = new PublicKey(p.to);
// C11 — rent-exempt minimum check. Если recipient — fresh account и amount меньше
// rent-exempt минимума, tx fails ПОСЛЕ broadcast (5000 lamports fee burned, no transfer).
// Pre-check сохраняет fee + user-facing error.
try {
const accountInfo = await conn.getAccountInfo(toPk);
if (accountInfo === null) {
const rentMin = BigInt(await conn.getMinimumBalanceForRentExemption(0));
if (lamports < rentMin) {
throw new Error(`SOL recipient is fresh account; amount ${lamports} lamports < rent-exempt minimum ${rentMin}. Send at least ${rentMin} lamports to create account.`);
}
}
} catch (preErr: any) {
// Network error checking — proceed (broadcast will surface real error)
if (!preErr.message?.includes('rent-exempt')) {
// только network/RPC failures, не наш own throw
} else {
throw preErr;
}
}
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash(); const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
const tx = new Transaction({ feePayer: keypair.publicKey, blockhash, lastValidBlockHeight });
const tx = new Transaction({ // H40 — compute-unit price (priority fee)
feePayer: keypair.publicKey,
blockhash,
lastValidBlockHeight,
});
// H40 — compute-unit price для priority fee (tiers slow/normal/fast).
// Без этого tx может dropped в congestion. Default 'normal' = 1_000 microLamports.
const tier = p.feeTier ?? 'normal'; const tier = p.feeTier ?? 'normal';
const cuPrice = tier === 'fast' ? 10_000n : tier === 'slow' ? 0n : 1_000n; const cuPrice = tier === 'fast' ? 10_000n : tier === 'slow' ? 0n : 1_000n;
if (cuPrice > 0n) { if (cuPrice > 0n) {
tx.add(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: cuPrice })); tx.add(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: cuPrice }));
} }
tx.add(
SystemProgram.transfer({ if (!p.token) {
// ── Native SOL transfer ──
// C11 — rent-exempt check для fresh recipient
try {
const accountInfo = await conn.getAccountInfo(toPk);
if (accountInfo === null) {
const rentMin = BigInt(await conn.getMinimumBalanceForRentExemption(0));
if (amountBig < rentMin) {
throw new Error(`SOL recipient is fresh account; amount ${amountBig} lamports < rent-exempt minimum ${rentMin}. Send at least ${rentMin} lamports to create account.`);
}
}
} catch (preErr: any) {
if (preErr.message?.includes('rent-exempt')) throw preErr;
// Network error checking — proceed (broadcast surfaces real error)
}
tx.add(SystemProgram.transfer({
fromPubkey: keypair.publicKey, fromPubkey: keypair.publicKey,
toPubkey: toPk, toPubkey: toPk,
lamports, lamports: amountBig,
}), }));
); } else {
tx.sign(keypair); // ── SPL token transfer ──
// Generic SPL: lookup mint в token-registry. Поддерживает USDT/USDC/PUMP/JUP/... (15 mints)
const tokenInfo = getTokenInfo('SOL', p.token);
if (!tokenInfo) {
throw new Error(`Token ${p.token} not in registry for chain SOL`);
}
const mint = new PublicKey(tokenInfo.address);
const sourceAta = getAssociatedTokenAddressSync(mint, keypair.publicKey);
const destAta = getAssociatedTokenAddressSync(mint, toPk);
// Idempotent ATA creation — safe to always include. Если ATA уже есть, instruction skip'нется.
// Recipient'у которому никогда не отправляли этот mint — мы создадим ATA (~0.002 SOL rent).
tx.add(createAssociatedTokenAccountIdempotentInstruction(
keypair.publicKey, // payer (мы платим rent если ATA создаётся)
destAta,
toPk,
mint,
TOKEN_PROGRAM_ID,
));
// CheckedTransfer защищает от decimals mismatch (RPC ложит → token loss)
tx.add(createTransferCheckedInstruction(
sourceAta,
mint,
destAta,
keypair.publicKey,
amountBig,
tokenInfo.decimals,
));
}
tx.sign(keypair);
const sig = await conn.sendRawTransaction(tx.serialize()); const sig = await conn.sendRawTransaction(tx.serialize());
// H37 — distinguished error categories // H37 — distinguished error categories
@@ -396,6 +521,7 @@ async function sendSol(p: SendParams): Promise<{ txid: string }> {
} }
// H41 — singleton SOL Connection. Reusing avoids WebSocket leak under load. // H41 — singleton SOL Connection. Reusing avoids WebSocket leak under load.
// Используется в sendSol (basic /send) — НЕ через proxy.
let _solConnection: Connection | null = null; let _solConnection: Connection | null = null;
function getSolConnection(): Connection { function getSolConnection(): Connection {
if (!_solConnection) { if (!_solConnection) {
@@ -404,6 +530,206 @@ function getSolConnection(): Connection {
return _solConnection; return _solConnection;
} }
/** Bridge-side SOL connection — через OUTBOUND_PROXY_URL если задан.
* Используется в signAndBroadcastSolanaTx / signAndBroadcastSolanaInstructions. */
let _solConnectionBridge: Connection | null = null;
function getBridgeSolConnection(): Connection {
if (!_solConnectionBridge) {
_solConnectionBridge = getProxiedSolConnection(SOL_RPC, 'confirmed');
}
return _solConnectionBridge;
}
// ─── SOL custodial sign-and-broadcast (для Relay bridge SOL-side) ─────
export interface SignSolanaTxParams {
mnemonic: string;
expectedFromAddress: string;
serializedTransaction: string; // base64-encoded VersionedTransaction
}
/**
* Подписать произвольную serialized Solana VersionedTransaction custodially.
* Используется когда Relay /execute или Jupiter возвращают unsigned tx — клиент шлёт base64,
* сервер deserialize → verify feePayer === user's pubkey → partial-sign → broadcast.
*
* Security:
* - feePayer (staticAccountKeys[0]) ДОЛЖЕН совпадать с user's SOL pubkey
* - Tx size limit 8KB (Solana network max — 1232 bytes раз; base64 ~1.65k chars)
* - assertAddressMatch — derived address vs DB
*/
export async function signAndBroadcastSolanaTx(p: SignSolanaTxParams): Promise<{ signature: string }> {
const seed = await bip39.mnemonicToSeed(p.mnemonic);
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
if (!key || key.length !== 32) {
throw new Error('SOL derivation produced invalid seed length');
}
const keypair = Keypair.fromSeed(key);
assertAddressMatch(keypair.publicKey.toBase58(), p.expectedFromAddress, 'SOL');
let txBytes: Buffer;
try {
txBytes = Buffer.from(p.serializedTransaction, 'base64');
} catch {
throw new Error('Invalid base64 transaction');
}
if (txBytes.length === 0 || txBytes.length > 1500) {
throw new Error(`Invalid tx size: ${txBytes.length} bytes (expected 1-1500)`);
}
let tx: VersionedTransaction;
try {
tx = VersionedTransaction.deserialize(txBytes);
} catch (err: any) {
throw new Error(`Failed to deserialize VersionedTransaction: ${err.message}`);
}
// Critical: verify feePayer === our pubkey. Без этого attacker может подсунуть tx
// с другим feePayer, мы подписали бы fee-deduct из их wallet'а (бесплатно для нас).
const feePayer = tx.message.staticAccountKeys[0]?.toBase58();
if (feePayer !== keypair.publicKey.toBase58()) {
throw new Error(`feePayer mismatch: tx.feePayer=${feePayer} vs user.pubkey=${keypair.publicKey.toBase58()}`);
}
tx.sign([keypair]);
// Bridge path — через OUTBOUND_PROXY_URL если задан (Relay SOL-side / Jupiter serialized).
const conn = getBridgeSolConnection();
const sig = await conn.sendRawTransaction(tx.serialize());
try {
const latestBlock = await conn.getLatestBlockhash();
await conn.confirmTransaction({
signature: sig,
blockhash: latestBlock.blockhash,
lastValidBlockHeight: latestBlock.lastValidBlockHeight,
}, 'confirmed');
} catch (err: any) {
const name = err?.name || '';
if (name === 'TransactionExpiredBlockheightExceededError') {
throw new Error(`SOL tx EXPIRED (blockhash expired before confirm). sig=${sig}`);
}
if (name === 'TransactionExpiredTimeoutError') {
throw new Error(`SOL tx unconfirmed after timeout. sig=${sig}`);
}
throw new Error(`SOL confirm error (${name}): ${err.message}. sig=${sig}`);
}
return { signature: sig };
}
/** Relay-style SOL step body: instructions[] + addressLookupTableAddresses[].
*
* Когда Relay execute returns SOL bridge tx, format не serialized — а instructions array
* с program IDs, keys, и calldata. Серверу нужно:
* 1. Validate каждый isSigner=true key === user's SOL pubkey (мы можем подписать только за себя)
* 2. Resolve address lookup tables через SOL RPC (Relay не передаёт сами таблицы, только адреса)
* 3. Fetch latest blockhash
* 4. Build VersionedTransaction с feePayer=user
* 5. Sign + broadcast
*/
export interface SignSolanaInstructionsParams {
mnemonic: string;
expectedFromAddress: string;
instructions: Array<{
programId: string;
keys: Array<{ pubkey: string; isSigner: boolean; isWritable: boolean }>;
data: string; // hex (no 0x prefix) или base64 — autodetect
}>;
addressLookupTableAddresses?: string[];
}
export async function signAndBroadcastSolanaInstructions(
p: SignSolanaInstructionsParams,
): Promise<{ signature: string }> {
const seed = await bip39.mnemonicToSeed(p.mnemonic);
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
if (!key || key.length !== 32) {
throw new Error('SOL derivation produced invalid seed length');
}
const keypair = Keypair.fromSeed(key);
assertAddressMatch(keypair.publicKey.toBase58(), p.expectedFromAddress, 'SOL');
const userPubkey = keypair.publicKey;
if (!Array.isArray(p.instructions) || p.instructions.length === 0) {
throw new Error('SOL instructions: empty array');
}
if (p.instructions.length > 32) {
throw new Error('SOL instructions: too many (max 32)');
}
// ─── Build TransactionInstruction[] из Relay-style objects ───
const ixs: TransactionInstruction[] = [];
for (const raw of p.instructions) {
if (!raw || typeof raw !== 'object') throw new Error('SOL instruction: not an object');
let programId: PublicKey;
try { programId = new PublicKey(raw.programId); } catch { throw new Error(`SOL invalid programId: ${raw.programId}`); }
if (!Array.isArray(raw.keys)) throw new Error('SOL instruction.keys must be array');
const keys = raw.keys.map((k, idx) => {
let pubkey: PublicKey;
try { pubkey = new PublicKey(k.pubkey); } catch { throw new Error(`SOL invalid pubkey at keys[${idx}]: ${k.pubkey}`); }
// SECURITY: any signer-key must be userPubkey (мы можем подписать только за себя)
if (k.isSigner && !pubkey.equals(userPubkey)) {
throw new Error(`SOL instruction has signer key ${k.pubkey} ≠ user ${userPubkey.toBase58()}`);
}
return { pubkey, isSigner: Boolean(k.isSigner), isWritable: Boolean(k.isWritable) };
});
// data: hex или base64? Relay обычно отдаёт hex без префикса.
let data: Buffer;
const dStr = String(raw.data || '');
if (/^[0-9a-fA-F]*$/.test(dStr) && dStr.length % 2 === 0) {
data = Buffer.from(dStr, 'hex');
} else {
try { data = Buffer.from(dStr, 'base64'); } catch { throw new Error('SOL instruction.data: not hex or base64'); }
}
ixs.push(new TransactionInstruction({ programId, keys, data }));
}
// Bridge path — через OUTBOUND_PROXY_URL если задан (Relay SOL bridge instructions).
const conn = getBridgeSolConnection();
// ─── Resolve address lookup tables через RPC ───
const luts: AddressLookupTableAccount[] = [];
for (const lutAddr of (p.addressLookupTableAddresses ?? [])) {
let lutPk: PublicKey;
try { lutPk = new PublicKey(lutAddr); } catch { throw new Error(`SOL invalid LUT address: ${lutAddr}`); }
const acc = await conn.getAddressLookupTable(lutPk);
if (!acc.value) throw new Error(`SOL LUT not found on-chain: ${lutAddr}`);
luts.push(acc.value);
}
// ─── Compile VersionedTransaction ───
const latestBlock = await conn.getLatestBlockhash();
const msg = new TransactionMessage({
payerKey: userPubkey,
recentBlockhash: latestBlock.blockhash,
instructions: ixs,
}).compileToV0Message(luts);
const tx = new VersionedTransaction(msg);
tx.sign([keypair]);
// ─── Broadcast + confirm ───
const sig = await conn.sendRawTransaction(tx.serialize());
try {
await conn.confirmTransaction({
signature: sig,
blockhash: latestBlock.blockhash,
lastValidBlockHeight: latestBlock.lastValidBlockHeight,
}, 'confirmed');
} catch (err: any) {
const name = err?.name || '';
if (name === 'TransactionExpiredBlockheightExceededError') {
throw new Error(`SOL Relay-bridge tx EXPIRED. sig=${sig}`);
}
throw new Error(`SOL Relay-bridge confirm error (${name}): ${err.message}. sig=${sig}`);
}
return { signature: sig };
}
// ─── BITCOIN ─── // ─── BITCOIN ───
async function sendBtc(p: SendParams): Promise<{ txid: string }> { async function sendBtc(p: SendParams): Promise<{ txid: string }> {
@@ -577,7 +903,12 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
}), }),
}); });
txBody = built; txBody = built;
} else if (p.token.toUpperCase() === 'USDT') { } else {
// Generic TRC20: lookup в token-registry. Поддерживает USDT, USDC и др.
const tokenInfo = getTokenInfo('TRX', p.token);
if (!tokenInfo) {
throw new Error(`Token ${p.token} not in registry for chain TRX`);
}
const param = const param =
tronAddressToHex(p.to).padStart(64, '0') + tronAddressToHex(p.to).padStart(64, '0') +
BigInt(p.amount).toString(16).padStart(64, '0'); BigInt(p.amount).toString(16).padStart(64, '0');
@@ -586,19 +917,16 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
headers, headers,
body: JSON.stringify({ body: JSON.stringify({
owner_address: fromTronAddr, owner_address: fromTronAddr,
contract_address: USDT_TRC20, contract_address: tokenInfo.address,
function_selector: 'transfer(address,uint256)', function_selector: 'transfer(address,uint256)',
parameter: param, parameter: param,
// 30 TRX cap — реальный USDT transfer обычно жжёт 15-30 TRX без Energy, // 30 TRX cap — типичный TRC20 transfer жжёт 15-30 TRX без Energy.
// ~0 с Energy. Раньше было 100 TRX — это cap, не actual fee, но завышен.
fee_limit: 30_000_000, fee_limit: 30_000_000,
call_value: 0, call_value: 0,
visible: true, visible: true,
}), }),
}); });
txBody = built.transaction; txBody = built.transaction;
} else {
throw new Error(`Token ${p.token} not supported on TRX`);
} }
if (!txBody?.txID || !txBody?.raw_data_hex || !txBody?.raw_data) { if (!txBody?.txID || !txBody?.raw_data_hex || !txBody?.raw_data) {
@@ -659,8 +987,14 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
throw new Error(`TRX amount mismatch: expected ${p.amount}, got ${contractValue.amount}`); throw new Error(`TRX amount mismatch: expected ${p.amount}, got ${contractValue.amount}`);
} }
} else { } else {
if (contractValue.contract_address !== USDT_TRC20) { // MITM-check: contract_address должен совпадать с тем что lookup'ом из registry для нашего token symbol.
throw new Error(`TRX contract mismatch: expected ${USDT_TRC20}, got ${contractValue.contract_address}`); // Без этого RPC может вернуть legitimate-looking tx но с другим contract → attacker drain.
const expectedTokenInfo = getTokenInfo('TRX', p.token);
if (!expectedTokenInfo) {
throw new Error(`Token ${p.token} not in registry for chain TRX (MITM-check)`);
}
if (contractValue.contract_address !== expectedTokenInfo.address) {
throw new Error(`TRX contract mismatch: expected ${expectedTokenInfo.address}, got ${contractValue.contract_address}`);
} }
const data = String(contractValue.data || ''); const data = String(contractValue.data || '');
if (data.length !== 128 + 8) { if (data.length !== 128 + 8) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,23 @@
-- ╔══════════════════════════════════════════════════════════════════╗ -- ╔══════════════════════════════════════════════════════════════════╗
-- ║ CryptoWallet API — Production DB schema (idempotent, custodial) -- ║ CryptoWallet API — Production DB schema
-- ║ Применять: psql -h <host> -U postgres_user -d postgres -f ... -- ║
-- ║ Безопасно прогонять повторно на existing БД. -- ║ APPEND-ONLY / NON-DESTRUCTIVE:
-- ║ Безопасно прогонять повторно. Ничего не DROP'ает, не overwrite. ║
-- ║ Если оператор добавил кастомные таблицы / индексы / constraints ║
-- ║ вручную — они НЕ будут затронуты. ║
-- ║ ║
-- ║ Применять: psql -h <host> -U <user> -d <db> -f cryptowallet-schema.sql ║
-- ╚══════════════════════════════════════════════════════════════════╝ -- ╚══════════════════════════════════════════════════════════════════╝
-- NOTE: idempotency_keys и audit_log таблицы НЕ используются.
-- - idempotency_keys → KeyDB (Redis cache) — apps/api/src/config/redis.ts
-- - audit_log → stdout JSON-lines — apps/api/src/lib/audit-log.ts
-- Скрипт их НЕ дропает (чтобы re-run был non-destructive).
-- Если оператор хочет cleanup — manual one-time:
-- DROP TABLE IF EXISTS audit_log CASCADE;
-- DROP TABLE IF EXISTS idempotency_keys CASCADE;
-- ── USERS ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id VARCHAR(26) NOT NULL PRIMARY KEY, id VARCHAR(26) NOT NULL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE,
@@ -28,7 +42,7 @@ CREATE TABLE IF NOT EXISTS users (
encrypted_mnemonic TEXT encrypted_mnemonic TEXT
); );
-- Idempotent ALTERs для existing БД без extension-columns -- Idempotent ALTERs для existing БД у которой нет extension-columns (только ADD если нет колонки)
DO $$ DO $$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic') THEN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic') THEN
@@ -42,18 +56,16 @@ BEGIN
END IF; END IF;
END $$; END $$;
-- Sanity check на blob size. Floor 100 (worst-case 12-word всё-3char mnemonic): -- Constraint: blob size check (only ADDs if missing, никогда не DROP).
-- plaintext 47 bytes + IV(12) + tag(16) = 75 raw → 100 base64 -- Floor 100 (worst-case 12-word 3-char mnemonic = 100 base64 chars).
-- typical 12-word: 113 raw → 152 base64; 24-word: 240 raw → 320 base64 -- Если оператор изменил этот constraint вручную — наш script его НЕ перезатрёт.
-- (Раньше floor 140 отвергал ~4% валидных 12-word mnemonics — fixed.)
DO $$ DO $$
BEGIN BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size') THEN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size') THEN
ALTER TABLE users DROP CONSTRAINT users_encrypted_mnemonic_size;
END IF;
ALTER TABLE users ALTER TABLE users
ADD CONSTRAINT users_encrypted_mnemonic_size ADD CONSTRAINT users_encrypted_mnemonic_size
CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 100 AND 512)); CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 100 AND 512));
END IF;
END $$; END $$;
-- Case-insensitive email uniqueness (Alice@x.com ≠ alice@x.com → ACCOUNT HIJACKING fix) -- Case-insensitive email uniqueness (Alice@x.com ≠ alice@x.com → ACCOUNT HIJACKING fix)
@@ -90,7 +102,6 @@ END $$;
-- ── WALLETS ───────────────────────────────────────────────────────── -- ── WALLETS ─────────────────────────────────────────────────────────
-- ON DELETE RESTRICT: hard-delete user → запрос отвергнут пока есть wallets. -- ON DELETE RESTRICT: hard-delete user → запрос отвергнут пока есть wallets.
-- Это защита от unrecoverable fund loss при GDPR-wipe или admin удалении. -- Это защита от unrecoverable fund loss при GDPR-wipe или admin удалении.
-- Use is_deleted=true для soft-delete.
CREATE TABLE IF NOT EXISTS wallets ( CREATE TABLE IF NOT EXISTS wallets (
id VARCHAR(26) NOT NULL PRIMARY KEY, id VARCHAR(26) NOT NULL PRIMARY KEY,
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE RESTRICT, user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
@@ -105,51 +116,11 @@ CREATE TABLE IF NOT EXISTS wallets (
CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id); CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id);
CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address); CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address);
-- Idempotent FK migration: если raised на старой DB с CASCADE — поменять -- NOTE: если БД старая и wallets.user_id_fkey ON DELETE CASCADE (а нужен RESTRICT
DO $$ -- для защиты от fund loss при delete user), оператор делает manual ОДИН раз:
BEGIN --
IF EXISTS ( -- ALTER TABLE wallets DROP CONSTRAINT wallets_user_id_fkey;
SELECT 1 FROM information_schema.referential_constraints -- ALTER TABLE wallets ADD CONSTRAINT wallets_user_id_fkey
WHERE constraint_name LIKE 'wallets_user_id_fkey%' AND delete_rule = 'CASCADE' -- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;
) THEN --
ALTER TABLE wallets DROP CONSTRAINT IF EXISTS wallets_user_id_fkey; -- Этот script ничего не дропает — re-run полностью non-destructive.
ALTER TABLE wallets ADD CONSTRAINT wallets_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;
END IF;
END $$;
-- ── AUDIT_LOG (durable sink для критических custodial операций) ─────
-- Pre-mutation INSERT 'pending', post-mutation UPDATE 'completed' с txid.
-- Если INSERT fails — операция НЕ происходит (fail-secure).
CREATE TABLE IF NOT EXISTS audit_log (
id VARCHAR(26) NOT NULL PRIMARY KEY,
user_id VARCHAR(26) NOT NULL,
event VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'success', 'failure')),
error_code VARCHAR(64),
ip VARCHAR(64),
trace_id VARCHAR(64),
meta JSONB,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_event_created ON audit_log(event, created_at DESC);
-- ── IDEMPOTENCY_KEYS (защита от double-spend на retry) ──────────────
-- Client шлёт Idempotency-Key header. Pre-mutation INSERT row, post-mutation UPDATE с response.
-- На retry — возвращаем cached response без второго broadcast.
CREATE TABLE IF NOT EXISTS idempotency_keys (
user_id VARCHAR(26) NOT NULL,
key VARCHAR(128) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
response_status SMALLINT,
response_body TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, key)
);
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
-- Retention cleanup (run via cron): DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours';

View File

@@ -1,12 +1,47 @@
services: services:
keydb:
image: eqalpha/keydb
container_name: cryptowallet-keydb
restart: unless-stopped
expose:
- "6379"
volumes:
- keydb_data:/data
command:
- keydb-server
- --requirepass
- ${REDIS_PASSWORD}
- --dir
- /data
- --appendonly
- "yes"
- --appendfsync
- everysec
- --save
- "900"
- "1"
- --save
- "300"
- "10"
- --save
- "60"
- "10000"
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 2s
retries: 20
api: api:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: cryptowallet-api container_name: cryptowallet-api
restart: unless-stopped restart: unless-stopped
# Bind to loopback only — TLS termination + WAF на reverse proxy (Caddy / Nginx). depends_on:
# Для direct exposure в dev → поменяй на "3001:3001". keydb:
condition: service_healthy
# Production: port открыт на all interfaces. TLS/WAF обязательно на reverse proxy.
ports: ports:
- "3001:3001" - "3001:3001"
env_file: env_file:
@@ -14,7 +49,6 @@ services:
environment: environment:
API_PORT: "3001" API_PORT: "3001"
# Container hardening — post-RCE blast radius minimization. # Container hardening — post-RCE blast radius minimization.
# Audit-логи теперь идут в stdout (не файл), поэтому read_only OK без logs mount.
read_only: true read_only: true
tmpfs: tmpfs:
- /tmp - /tmp
@@ -36,3 +70,6 @@ services:
options: options:
max-size: "20m" max-size: "20m"
max-file: "5" max-file: "5"
volumes:
keydb_data:

271
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
apps/api: apps/api:
dependencies: dependencies:
'@solana/spl-token':
specifier: ^0.4.14
version: 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6)
'@solana/web3.js': '@solana/web3.js':
specifier: ^1.98.4 specifier: ^1.98.4
version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
@@ -56,6 +59,9 @@ importers:
helmet: helmet:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.1.0 version: 8.1.0
ioredis:
specifier: ^5.4.0
version: 5.10.1
jose: jose:
specifier: ^6.2.2 specifier: ^6.2.2
version: 6.2.2 version: 6.2.2
@@ -74,6 +80,9 @@ importers:
ulidx: ulidx:
specifier: ^2.4.1 specifier: ^2.4.1
version: 2.4.1 version: 2.4.1
undici:
specifier: ^6.21.0
version: 6.25.0
devDependencies: devDependencies:
'@types/cookie-parser': '@types/cookie-parser':
specifier: ^1.4.7 specifier: ^1.4.7
@@ -318,6 +327,9 @@ packages:
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
deprecated: Use @eslint/object-schema instead deprecated: Use @eslint/object-schema instead
'@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
'@jridgewell/resolve-uri@3.1.2': '@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@@ -354,22 +366,58 @@ packages:
'@scure/base@1.2.6': '@scure/base@1.2.6':
resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==}
'@solana/buffer-layout-utils@0.2.0':
resolution: {integrity: sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==}
engines: {node: '>= 10'}
'@solana/buffer-layout@4.0.1': '@solana/buffer-layout@4.0.1':
resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==}
engines: {node: '>=5.10'} engines: {node: '>=5.10'}
'@solana/codecs-core@2.0.0-rc.1':
resolution: {integrity: sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ==}
peerDependencies:
typescript: '>=5'
'@solana/codecs-core@2.3.0': '@solana/codecs-core@2.3.0':
resolution: {integrity: sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==} resolution: {integrity: sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==}
engines: {node: '>=20.18.0'} engines: {node: '>=20.18.0'}
peerDependencies: peerDependencies:
typescript: '>=5.3.3' typescript: '>=5.3.3'
'@solana/codecs-data-structures@2.0.0-rc.1':
resolution: {integrity: sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog==}
peerDependencies:
typescript: '>=5'
'@solana/codecs-numbers@2.0.0-rc.1':
resolution: {integrity: sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ==}
peerDependencies:
typescript: '>=5'
'@solana/codecs-numbers@2.3.0': '@solana/codecs-numbers@2.3.0':
resolution: {integrity: sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==} resolution: {integrity: sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==}
engines: {node: '>=20.18.0'} engines: {node: '>=20.18.0'}
peerDependencies: peerDependencies:
typescript: '>=5.3.3' typescript: '>=5.3.3'
'@solana/codecs-strings@2.0.0-rc.1':
resolution: {integrity: sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g==}
peerDependencies:
fastestsmallesttextencoderdecoder: ^1.0.22
typescript: '>=5'
'@solana/codecs@2.0.0-rc.1':
resolution: {integrity: sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ==}
peerDependencies:
typescript: '>=5'
'@solana/errors@2.0.0-rc.1':
resolution: {integrity: sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ==}
hasBin: true
peerDependencies:
typescript: '>=5'
'@solana/errors@2.3.0': '@solana/errors@2.3.0':
resolution: {integrity: sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==} resolution: {integrity: sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==}
engines: {node: '>=20.18.0'} engines: {node: '>=20.18.0'}
@@ -377,6 +425,29 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=5.3.3' typescript: '>=5.3.3'
'@solana/options@2.0.0-rc.1':
resolution: {integrity: sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA==}
peerDependencies:
typescript: '>=5'
'@solana/spl-token-group@0.0.7':
resolution: {integrity: sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug==}
engines: {node: '>=16'}
peerDependencies:
'@solana/web3.js': ^1.95.3
'@solana/spl-token-metadata@0.1.6':
resolution: {integrity: sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA==}
engines: {node: '>=16'}
peerDependencies:
'@solana/web3.js': ^1.95.3
'@solana/spl-token@0.4.14':
resolution: {integrity: sha512-u09zr96UBpX4U685MnvQsNzlvw9TiY005hk1vJmJr7gMJldoPG1eYU5/wNEyOA5lkMLiR/gOi9SFD4MefOYEsA==}
engines: {node: '>=16'}
peerDependencies:
'@solana/web3.js': ^1.95.5
'@solana/web3.js@1.98.4': '@solana/web3.js@1.98.4':
resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==}
@@ -593,10 +664,20 @@ packages:
bech32@2.0.0: bech32@2.0.0:
resolution: {integrity: sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==} resolution: {integrity: sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==}
bigint-buffer@1.1.5:
resolution: {integrity: sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==}
engines: {node: '>= 10.0.0'}
bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
binary-extensions@2.3.0: binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'} engines: {node: '>=8'}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bip174@2.1.1: bip174@2.1.1:
resolution: {integrity: sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==} resolution: {integrity: sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@@ -699,6 +780,10 @@ packages:
resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==} resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -713,6 +798,10 @@ packages:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'} engines: {node: '>=14'}
commander@12.1.0:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'}
commander@14.0.3: commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -793,6 +882,10 @@ packages:
resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==}
engines: {node: '>=10'} engines: {node: '>=10'}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
depd@2.0.0: depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -947,6 +1040,9 @@ packages:
fast-stable-stringify@1.0.0: fast-stable-stringify@1.0.0:
resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==}
fastestsmallesttextencoderdecoder@1.0.22:
resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==}
fastq@1.20.1: fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -954,6 +1050,9 @@ packages:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
fill-range@7.1.1: fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1108,6 +1207,10 @@ packages:
resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
ioredis@5.10.1:
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
engines: {node: '>=12.22.0'}
ip-address@10.1.0: ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
@@ -1231,6 +1334,12 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -1492,6 +1601,14 @@ packages:
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
engines: {node: '>= 10.13.0'} engines: {node: '>= 10.13.0'}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1605,6 +1722,9 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
statuses@2.0.2: statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -1801,6 +1921,10 @@ packages:
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@6.25.0:
resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==}
engines: {node: '>=18.17'}
unpipe@1.0.0: unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2392,6 +2516,8 @@ snapshots:
'@humanwhocodes/object-schema@2.0.3': {} '@humanwhocodes/object-schema@2.0.3': {}
'@ioredis/commands@1.5.1': {}
'@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/sourcemap-codec@1.5.5': {}
@@ -2423,27 +2549,124 @@ snapshots:
'@scure/base@1.2.6': {} '@scure/base@1.2.6': {}
'@solana/buffer-layout-utils@0.2.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)':
dependencies:
'@solana/buffer-layout': 4.0.1
'@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
bigint-buffer: 1.1.5
bignumber.js: 9.3.1
transitivePeerDependencies:
- bufferutil
- encoding
- typescript
- utf-8-validate
'@solana/buffer-layout@4.0.1': '@solana/buffer-layout@4.0.1':
dependencies: dependencies:
buffer: 6.0.3 buffer: 6.0.3
'@solana/codecs-core@2.0.0-rc.1(typescript@5.9.3)':
dependencies:
'@solana/errors': 2.0.0-rc.1(typescript@5.9.3)
typescript: 5.9.3
'@solana/codecs-core@2.3.0(typescript@5.9.3)': '@solana/codecs-core@2.3.0(typescript@5.9.3)':
dependencies: dependencies:
'@solana/errors': 2.3.0(typescript@5.9.3) '@solana/errors': 2.3.0(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
'@solana/codecs-data-structures@2.0.0-rc.1(typescript@5.9.3)':
dependencies:
'@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3)
'@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3)
'@solana/errors': 2.0.0-rc.1(typescript@5.9.3)
typescript: 5.9.3
'@solana/codecs-numbers@2.0.0-rc.1(typescript@5.9.3)':
dependencies:
'@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3)
'@solana/errors': 2.0.0-rc.1(typescript@5.9.3)
typescript: 5.9.3
'@solana/codecs-numbers@2.3.0(typescript@5.9.3)': '@solana/codecs-numbers@2.3.0(typescript@5.9.3)':
dependencies: dependencies:
'@solana/codecs-core': 2.3.0(typescript@5.9.3) '@solana/codecs-core': 2.3.0(typescript@5.9.3)
'@solana/errors': 2.3.0(typescript@5.9.3) '@solana/errors': 2.3.0(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
'@solana/codecs-strings@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
dependencies:
'@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3)
'@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3)
'@solana/errors': 2.0.0-rc.1(typescript@5.9.3)
fastestsmallesttextencoderdecoder: 1.0.22
typescript: 5.9.3
'@solana/codecs@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
dependencies:
'@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3)
'@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.3)
'@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3)
'@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
'@solana/options': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- fastestsmallesttextencoderdecoder
'@solana/errors@2.0.0-rc.1(typescript@5.9.3)':
dependencies:
chalk: 5.6.2
commander: 12.1.0
typescript: 5.9.3
'@solana/errors@2.3.0(typescript@5.9.3)': '@solana/errors@2.3.0(typescript@5.9.3)':
dependencies: dependencies:
chalk: 5.6.2 chalk: 5.6.2
commander: 14.0.3 commander: 14.0.3
typescript: 5.9.3 typescript: 5.9.3
'@solana/options@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
dependencies:
'@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3)
'@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.3)
'@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3)
'@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
'@solana/errors': 2.0.0-rc.1(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- fastestsmallesttextencoderdecoder
'@solana/spl-token-group@0.0.7(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
dependencies:
'@solana/codecs': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
'@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
transitivePeerDependencies:
- fastestsmallesttextencoderdecoder
- typescript
'@solana/spl-token-metadata@0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
dependencies:
'@solana/codecs': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
'@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
transitivePeerDependencies:
- fastestsmallesttextencoderdecoder
- typescript
'@solana/spl-token@0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6)':
dependencies:
'@solana/buffer-layout': 4.0.1
'@solana/buffer-layout-utils': 0.2.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
'@solana/spl-token-group': 0.0.7(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
'@solana/spl-token-metadata': 0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
'@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
buffer: 6.0.3
transitivePeerDependencies:
- bufferutil
- encoding
- fastestsmallesttextencoderdecoder
- typescript
- utf-8-validate
'@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)': '@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)':
dependencies: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
@@ -2699,8 +2922,18 @@ snapshots:
bech32@2.0.0: {} bech32@2.0.0: {}
bigint-buffer@1.1.5:
dependencies:
bindings: 1.5.0
bignumber.js@9.3.1: {}
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bip174@2.1.1: {} bip174@2.1.1: {}
bip32@4.0.0: bip32@4.0.0:
@@ -2846,6 +3079,8 @@ snapshots:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
to-buffer: 1.2.2 to-buffer: 1.2.2
cluster-key-slot@1.1.2: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -2856,6 +3091,8 @@ snapshots:
commander@10.0.1: {} commander@10.0.1: {}
commander@12.1.0: {}
commander@14.0.3: {} commander@14.0.3: {}
commander@2.20.3: {} commander@2.20.3: {}
@@ -2929,6 +3166,8 @@ snapshots:
delay@5.0.0: {} delay@5.0.0: {}
denque@2.1.0: {}
depd@2.0.0: {} depd@2.0.0: {}
destroy@1.2.0: {} destroy@1.2.0: {}
@@ -3173,6 +3412,8 @@ snapshots:
fast-stable-stringify@1.0.0: {} fast-stable-stringify@1.0.0: {}
fastestsmallesttextencoderdecoder@1.0.22: {}
fastq@1.20.1: fastq@1.20.1:
dependencies: dependencies:
reusify: 1.1.0 reusify: 1.1.0
@@ -3181,6 +3422,8 @@ snapshots:
dependencies: dependencies:
flat-cache: 3.2.0 flat-cache: 3.2.0
file-uri-to-path@1.0.0: {}
fill-range@7.1.1: fill-range@7.1.1:
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
@@ -3353,6 +3596,20 @@ snapshots:
interpret@2.2.0: {} interpret@2.2.0: {}
ioredis@5.10.1:
dependencies:
'@ioredis/commands': 1.5.1
cluster-key-slot: 1.1.2
debug: 4.3.4
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ip-address@10.1.0: {} ip-address@10.1.0: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
@@ -3461,6 +3718,10 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash@4.17.23: {} lodash@4.17.23: {}
@@ -3673,6 +3934,12 @@ snapshots:
dependencies: dependencies:
resolve: 1.22.11 resolve: 1.22.11
redis-errors@1.2.0: {}
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
resolve-from@5.0.0: {} resolve-from@5.0.0: {}
@@ -3814,6 +4081,8 @@ snapshots:
split2@4.2.0: {} split2@4.2.0: {}
standard-as-callback@2.1.0: {}
statuses@2.0.2: {} statuses@2.0.2: {}
stream-chain@2.2.5: {} stream-chain@2.2.5: {}
@@ -3988,6 +4257,8 @@ snapshots:
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici@6.25.0: {}
unpipe@1.0.0: {} unpipe@1.0.0: {}
uri-js@4.4.1: uri-js@4.4.1:

View File

@@ -29,7 +29,9 @@ fi
# NOTE: logs/ директория НЕ нужна — audit-логи теперь в stdout (Docker logs). # NOTE: logs/ директория НЕ нужна — audit-логи теперь в stdout (Docker logs).
# Контейнер работает с read_only: true (см. docker-compose.yml). # Контейнер работает с read_only: true (см. docker-compose.yml).
echo "[INFO] Building and starting container..." # Не используйте `docker compose down -v` — удалит keydb_data (кэш/idempotency).
# Не пересоздавайте keydb без бэкапа. Обновление кода: `docker compose build api && docker compose up -d api`.
echo "[INFO] Building and starting containers..."
docker compose up -d --build docker compose up -d --build
echo "[INFO] Waiting for API to become healthy..." echo "[INFO] Waiting for API to become healthy..."