Compare commits

..

2 Commits

Author SHA1 Message Date
2e2af07223 Merge branch 'main' of ssh://gitssh.elcsa.ru:22222/damanukyan/cryptowallet 2026-05-14 02:14:56 +03:00
9c07548762 hardcode 2026-05-14 02:14:45 +03:00
58 changed files with 1673 additions and 14041 deletions

View File

@@ -64,16 +64,3 @@ DB_PORT=5432
DB_USER= DB_USER=
DB_PASSWORD= DB_PASSWORD=
DB_NAME= DB_NAME=
# ── DeFi: staking (Lido/Solana) + Uniswap v3 LP ────────────────────
# Адреса опциональны (mainnet-дефолты в config/env.ts).
LIDO_STETH_ADDRESS=
LIDO_WITHDRAWAL_QUEUE_ADDRESS=
CURVE_STETH_POOL_ADDRESS=
UNISWAP_V3_NFPM_ADDRESS=
# Курируемые v3-пулы (csv). Дефолт — 3 пула: WETH/USDT, WBTC/WETH, XAUt/USDT.
UNISWAP_LP_POOLS=
# The Graph API-ключ для Uniswap v3 subgraph APR/TVL (пусто → APR=null).
THEGRAPH_API_KEY=
# Vote-адрес валидатора для нативного Solana-стейкинга. ОПЦИОНАЛЬНО (пусто → авто-выбор).
SOL_STAKE_VALIDATOR_VOTE=

3
.gitignore vendored
View File

@@ -1,12 +1,9 @@
.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,9 +25,7 @@ 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

139
README.md Normal file
View File

@@ -0,0 +1,139 @@
# CryptoWallet API — Deployment Bundle
Multi-chain **custodial** wallet API (ETH / BSC / BTC / TRX / SOL).
- Сервер генерит mnemonic, хранит зашифрованной (AES-256-GCM, master-key из HashiCorp Vault)
- Сервер сам подписывает tx по запросу юзера (юзер на клиенте жмёт "подтвердить")
Auth — JWT, выданный сервисом **bitok** (внешний). Секреты — HashiCorp Vault (AppRole).
## Pre-deploy setup (один раз)
```bash
# 1. Master-key в Vault
vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32)
# 2. CSRF secret в Vault
vault kv put dev-secrets/csrf secret_key=$(openssl rand -hex 32) salt=csrf-salt digest=sha256
# 3. DB schema — APPEND-ONLY / NON-DESTRUCTIVE
# Безопасно прогонять на existing БД. См. ниже "Schema is non-destructive".
psql -h <db-host> -U postgres_user -d postgres -f cryptowallet-schema.sql
# 4. bitok public key в Vault (для kid из JWT header)
vault kv put dev-secrets/jwt/kid active=<kid-from-bitok>
vault kv put dev-secrets/jwt/kids/<kid-from-bitok> \
algorithm=RS256 \
public_key="$(cat /path/to/bitok-public.pem)"
```
⚠️ **Master-key менять нельзя** — все existing `encrypted_mnemonic` станут нерасшифровываемыми. API на старте делает self-test: пытается декриптить любой существующий mnemonic и фейлится если ключ не подошёл.
## Deploy
```bash
# Залить bundle на сервер
scp -P 2222 -r deployserver/ server@<host>:~/cryptowallet/
# На сервере: заполнить .env, поднять
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 убран — плохая ликвидность)
## Schema is non-destructive
`cryptowallet-schema.sql` **append-only**. Re-run на боксе с уже настроенной БД = **zero DDL changes**. Если оператор добавил кастомные таблицы / индексы / constraints вручную — они **никогда** не будут перезаписаны или удалены.
Что делает script:
- `CREATE TABLE IF NOT EXISTS users` / `wallets`
- `ALTER TABLE users ADD COLUMN <X>` (только если колонки нет — `encrypted_mnemonic`, `erc20`, `passport_data`)
- `CREATE UNIQUE INDEX users_email_lower_unique` (если индекса нет)
- `CREATE INDEX idx_users_active` / `idx_wallets_*` (если индексов нет)
- `ADD CONSTRAINT` × 4 (только если данного constraint name нет)
Что script **НЕ делает**:
- ❌ Никогда не `DROP TABLE`
- ❌ Никогда не `DROP CONSTRAINT`
- ❌ Никогда не `DROP COLUMN`
- ❌ Никогда не перезаписывает существующие constraints / indexes
Legacy cleanup (audit_log, idempotency_keys, sessions от старых версий) — **manual one-time** операторская задача, не часть этого script'а:
```bash
psql ... -c "DROP TABLE IF EXISTS audit_log CASCADE;"
psql ... -c "DROP TABLE IF EXISTS idempotency_keys CASCADE;"
psql ... -c "DROP TABLE IF EXISTS sessions CASCADE;"
```
## Logs
Файловых логов **нет**. Весь код пишет в `process.stdout` (см. `apps/api/src/lib/logger.ts` и `lib/audit-log.ts`). Docker подбирает stdout через json-file driver и показывает через `docker compose logs`:
```bash
docker compose logs -f api # все логи (structured JSON)
docker compose logs api | grep '"level":"audit"' # только audit events
docker compose logs api | grep '"level":"ERROR"' # только ошибки
```
## Production hardening checklist (опционально)
- [ ] Vault server-mode (raft/file backend) с unseal flow
- [ ] TLS termination на reverse-proxy (Caddy / Nginx) перед `127.0.0.1:3001`
- [ ] Swagger UI скрыть за basic-auth (endpoints всё ещё доступны через `/api/docs/swagger.json`)
- [ ] Postgres backups (pg_dump → S3 по cron)
- [ ] Vault root token ротация
- [ ] Mnemonic-reveal endpoint — 2FA / time-based confirmation tokens
- [ ] Rate-limit tune под реальный трафик

View File

@@ -1,37 +0,0 @@
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

@@ -30,8 +30,7 @@
"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,18 +10,15 @@ 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 pricesRoutes from './routes/prices.routes';
import tokensRoutes from './routes/tokens.routes';
import bridgeRoutes from './routes/bridge.routes';
import stakingRoutes from './routes/staking.routes';
import lpRoutes from './routes/lp.routes';
import approvalsRoutes from './routes/approvals.routes';
import { metricsHandler, metricsMiddleware } from './lib/metrics';
const app = express(); const app = express();
@@ -29,29 +26,15 @@ 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: corsIsWildcard ? '*' : (corsOrigins.length > 0 ? corsOrigins : false), origin: env.cors.origins.length > 0 ? env.cors.origins : false,
// Wildcard incompatible с credentials per browser spec — force false при wildcard. credentials: env.cors.allowCredentials,
credentials: corsIsWildcard ? false : env.cors.allowCredentials,
exposedHeaders: ['X-CSRF-Token', 'X-Trace-ID'],
}), }),
); );
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS
app.use(cookieParser()); app.use(cookieParser());
app.use(traceMiddleware); app.use(traceMiddleware);
app.use(metricsMiddleware);
// ── PUBLIC endpoints ───────────────────────────────────────────────────────── // ── PUBLIC endpoints ─────────────────────────────────────────────────────────
// H11 — /api/health with DB probe (не возвращает OK если DB down) // H11 — /api/health with DB probe (не возвращает OK если DB down)
@@ -68,20 +51,6 @@ app.get('/api/health', async (_req, res) => {
} }
}); });
app.get(['/healthcheck', '/api/healthcheck'], async (_req, res) => {
try {
await Promise.race([
db.raw('select 1'),
new Promise((_, reject) => setTimeout(() => reject(new Error('db-timeout')), 1000)),
]);
res.json({ success: true, data: { status: 'ok' } });
} catch (err: any) {
res.status(503).json({ success: false, error: 'db_unavailable' });
}
});
app.get(['/metrics', '/api/metrics'], metricsHandler);
// ── Глобальный rate limit на /api/* — ДО docs чтобы не было unauthenticated DoS на swagger.json // ── Глобальный rate limit на /api/* — ДО docs чтобы не было unauthenticated DoS на swagger.json
app.use('/api', globalLimiter); app.use('/api', globalLimiter);
@@ -116,50 +85,22 @@ app.use('/api/docs', docsGate, swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// ── PROTECTED endpoints (JWT + CSRF) ───────────────────────────────────────── // ── PROTECTED endpoints (JWT + CSRF) ─────────────────────────────────────────
const protect = [authMiddleware, csrfMiddleware]; const protect = [authMiddleware, csrfMiddleware];
// Sensitive — самый строгий лимит. Каждый POST защищён JWT + CSRF. app.post('/api/wallets/create', sensitiveLimiter, WalletController.createWallet);
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);
// Sensitive money-flow — строжайший лимит ТОЛЬКО на execute-эндпоинты (точный путь через app.post).
// Read-only quote/positions/pools матчатся обычным mutateLimiter (через router mount ниже).
app.post('/api/wallets/:chain/stake', ...protect, sensitiveLimiter);
app.post('/api/wallets/:chain/unstake', ...protect, sensitiveLimiter);
app.post('/api/wallets/:chain/unstake/claim', ...protect, sensitiveLimiter);
app.post('/api/wallets/:chain/lp/add', ...protect, sensitiveLimiter);
app.post('/api/wallets/:chain/lp/remove', ...protect, sensitiveLimiter);
// Custodial revoke approval (approve(spender,0)) — money-safety op, строжайший лимит.
app.post('/api/wallets/:chain/approvals/revoke-all', ...protect, sensitiveLimiter);
app.post('/api/wallets/:chain/approvals/revoke', ...protect, sensitiveLimiter);
// Mutating (proxy + read endpoints) — повышенный лимит // Mutating (proxy + read endpoints) — повышенный лимит
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes); app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
// Staking (Lido ETH) — custodial. Тот же namespace; пути /:chain/stake* не пересекаются с walletRoutes.
app.use('/api/wallets', ...protect, mutateLimiter, stakingRoutes);
// Uniswap v3 LP — custodial. Пути /:chain/lp/* не пересекаются с wallet/staking routes.
app.use('/api/wallets', ...protect, mutateLimiter, lpRoutes);
// Approval scan/revoke — custodial. Пути /:chain/approvals* не пересекаются с wallet/staking/lp routes.
// Закрывает повисший unlimited-approval (drain-вектор) + read-only scan открытых дверей.
app.use('/api/wallets', ...protect, mutateLimiter, approvalsRoutes);
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);
// Legacy non-custodial swap proxies (/api/bsc/swap, /api/sol/swap, /api/tron/swap) app.use('/api/bsc/swap', ...protect, mutateLimiter, bscSwapProxyRoutes);
// УДАЛЕНЫ. Custodial 2-step swap живёт под /api/wallets/{chain}/swap{,/quote}.
// USD-цены (CoinGecko + KeyDB cache). GET-only, auth required, max 50 symbols. // USD-цены (CoinGecko + KeyDB cache). GET-only, auth required, max 50 symbols.
app.use('/api/prices', ...protect, mutateLimiter, pricesRoutes); 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) => {
res.status(404).json({ success: false, error: 'Not found' }); res.status(404).json({ success: false, error: 'Not found' });

View File

@@ -34,25 +34,20 @@ export let env = {
cryptoKeyPath: p.VAULT_CRYPTO_KEY_PATH || 'crypto/master', cryptoKeyPath: p.VAULT_CRYPTO_KEY_PATH || 'crypto/master',
}, },
cors: { cors: {
// CORS_ORIGINS: // Каждый деплой явно указывает CORS_ORIGINS, иначе пустой массив = нет cross-origin.
// - comma-separated list of origins → whitelist (recommended for prod) // Validate каждый origin через URL parse — отклоняем мусор. Default https-only для prod-safety.
// - "*" → wildcard, любой origin принят (для dev/staging) origins: (p.CORS_ORIGINS || '')
// - "" → cross-origin blocked (fail-secure default) .split(',')
// Wildcard incompatible с CORS_ALLOW_CREDENTIALS=true (browser spec). .map((o) => o.trim())
origins: (() => { .filter(Boolean)
const raw = (p.CORS_ORIGINS || '').split(',').map((o) => o.trim()).filter(Boolean); .filter((o) => {
// 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',
@@ -71,30 +66,6 @@ export let env = {
jupiterFeeBps: parseInt(p.JUPITER_FEE_BPS || '70'), jupiterFeeBps: parseInt(p.JUPITER_FEE_BPS || '70'),
etherscanApiKey: p.ETHERSCAN_API_KEY || null, etherscanApiKey: p.ETHERSCAN_API_KEY || null,
bscscanApiKey: p.BSCSCAN_API_KEY || null, bscscanApiKey: p.BSCSCAN_API_KEY || null,
// ── DeFi (staking + Uniswap v3 LP) ──────────────────────────────────
// Адреса хранятся lowercase; при использовании прогоняются через ethers.getAddress()
// → корректный checksum без риска ручной опечатки. Mainnet defaults.
defi: {
// Lido stETH (submit payable + ERC20 balanceOf)
lidoStEth: (p.LIDO_STETH_ADDRESS || '0xae7ab96520de3a18e5e111b5eaab095312d7fe84').toLowerCase(),
// Lido Withdrawal Queue (requestWithdrawals / claimWithdrawal) — для mode=queue
lidoWithdrawalQueue: (p.LIDO_WITHDRAWAL_QUEUE_ADDRESS || '0x889edc2edab5f40e902b864ad4d7abe3ce53ada8').toLowerCase(),
// Curve stETH/ETH pool — для мгновенного вывода (mode=swap) stETH→ETH (deepest liquidity)
curveStEthPool: (p.CURVE_STETH_POOL_ADDRESS || '0xdc24316b9ae028f1497c275eb9192a3ea0f67022').toLowerCase(),
// Uniswap v3 NonfungiblePositionManager (mint/increase/decrease/collect/burn)
uniswapV3Nfpm: (p.UNISWAP_V3_NFPM_ADDRESS || '0xc36442b4a4522e871399cd717abdd847ab11fe88').toLowerCase(),
// Курируемые v3-пулы (csv lowercased). Default — 3 пула (адреса подтверждены on-chain):
// WETH/USDT 0.3% 0x4e68ccd3e89f51c3074ca5072bbac773960dfa36
// WBTC/USDT 0.05% 0x56534741cd8b152df6d48adf7ac51f75169a83b2
// XAUt/USDT 0.05% 0x6546055f46e866a4b9a4a13e81273e3152bae5da
uniswapLpPools: (p.UNISWAP_LP_POOLS || '0x4e68ccd3e89f51c3074ca5072bbac773960dfa36,0x56534741cd8b152df6d48adf7ac51f75169a83b2,0x6546055f46e866a4b9a4a13e81273e3152bae5da')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
// The Graph API-ключ для Uniswap v3 subgraph (APR/TVL). null → APR недоступен (не блокирует операции)
theGraphApiKey: p.THEGRAPH_API_KEY || null,
// Vote-адрес валидатора для нативного Solana-стейкинга. ОПЦИОНАЛЬНО: пусто → авто-выбор
// хорошего валидатора (Stakewiz → RPC → fallback, см. lib/sol-validator.ts). Задай, чтобы закрепить.
solStakeValidatorVote: p.SOL_STAKE_VALIDATOR_VOTE || '',
},
}; };
let vaultToken: string | null = null; let vaultToken: string | null = null;

View File

@@ -1,405 +0,0 @@
/**
* Approval management — custodial scan + revoke + revoke-all («отвязка от всего») для EVM (ETH/BSC).
*
* Зачем: депозиты «утекают» через повисший unlimited-approval (напр. USDT → Relay Router v2 =
* MAX_UINT256): любой Relay-солвер может вывести каждое новое поступление токена с кошелька,
* заполняя свой ордер. Кошелёк custodial (ключ на сервере), поэтому отзыв `approve(spender, 0)`
* должен подписать сервер — revoke.cash не подходит (у пользователя нет приватного ключа).
*
* Безопасность:
* - revoke ВСЕГДА хардкодит `amount: '0'` → endpoint может ТОЛЬКО снизить allowance, никогда
* не выдать. Поэтому даже произвольные (но валидные) token/spender не являются attack surface.
* - JWT-bind кошелька chain + Idempotency-Key + auditLogStrict + decrypt mnemonic в памяти —
* тот же мутационный паттерн, что в bridge.routes.ts / staking.controller.ts.
* - scan — read-only `allowance` view-call (без подписи), только для видимости открытых дверей.
*
* Prevention НОВЫХ unlimited-approval сделана отдельно (lib/evm-tx-policy.ts cap 2^200);
* этот модуль закрывает УЖЕ повисшие approval-ы и даёт инструмент на будущее.
*/
import { Request, Response } from 'express';
import { ethers } from 'ethers';
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 { signAndBroadcastEvmApprove, readErc20Allowance } from '../services/wallet-signer-bridge';
import { getEvmTokens } from '../lib/token-registry';
import { WETH_ADDRESS } from '../lib/defi-contracts';
import { formatSmallestUnits } from '../lib/amount-units';
import { env } from '../config/env';
type EvmChain = 'ETH' | 'BSC';
/**
* Порог «бесконечного» allowance — совпадает с cap из evm-tx-policy.ts (2^200 ≫ любого реального
* supply токена, но ловит MAX_UINT256 и near-max сентинелы). allowance выше → флаг unlimited.
*/
const UNLIMITED_THRESHOLD = 2n ** 200n;
interface KnownSpender { name: string; address: string; }
interface KnownToken { symbol: string; address: string; decimals: number; }
/**
* Known спендеры, которым наш flow когда-либо выдаёт approve. Сканируем allowance кошелька против
* них, чтобы показать открытые двери. Relay-роутеры — из evm-tx-policy.ts (RELAY_ROUTERS);
* Permit2 — canonical (один адрес на всех chain); NFPM — из env (Uniswap v3 LP).
*/
function knownSpenders(chain: EvmChain): KnownSpender[] {
const common: KnownSpender[] = [
{ name: 'Relay Router v2', address: '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f' },
{ name: 'Relay Depository v1', address: '0xa5f565650890fba1824ee0f21ebbbf660a179934' },
{ name: 'Permit2', address: '0x000000000022d473030f116ddee9f6b43ac78ba3' },
];
if (chain === 'ETH') {
return [
...common,
{ name: 'Relay Router v1', address: '0xf70da97812cb96acdf810712aa562db8dfa3dbef' },
{ name: 'Uniswap v3 NFPM', address: env.defi.uniswapV3Nfpm },
];
}
return common;
}
/** Known токены, которые реально аппрувятся в нашем flow (из registry) + WETH (авто-wrap для LP). */
function knownTokens(chain: EvmChain): KnownToken[] {
const base: KnownToken[] = getEvmTokens(chain).map((t) => ({
symbol: t.symbol,
address: t.contractAddress,
decimals: t.decimals,
}));
if (chain === 'ETH') base.push({ symbol: 'WETH', address: WETH_ADDRESS, decimals: 18 });
return base;
}
function getUserId(req: Request): string | null {
return (req as any).auth?.userId || null;
}
/** ETH/BSC only. Возвращает нормализованный chain или null (+ ответ 400). */
function resolveEvmChain(req: Request, res: Response): EvmChain | null {
const chain = String(req.params.chain).toUpperCase();
if (chain === 'ETH' || chain === 'BSC') return chain;
res.status(400).json({ success: false, error: 'Approvals supported on ETH and BSC only' });
return null;
}
/** Одна открытая дверь (ненулевой allowance), найденная сканом. */
interface OpenApproval {
token: string;
tokenAddress: string;
spender: string;
spenderAddress: string;
allowance: string;
allowanceHuman: string;
unlimited: boolean;
}
/**
* Сканирует allowance кошелька по known-токенам × known-спендерам и возвращает только ОТКРЫТЫЕ
* (ненулевые) двери. Read-only (view-call, без подписи). Переиспользуется в `scanApprovals` (GET) и
* `revokeAllApprovals` (POST) — единый источник «какие двери открыты».
*/
async function scanOpenApprovals(chain: EvmChain, owner: string): Promise<OpenApproval[]> {
const tokens = knownTokens(chain);
const spenders = knownSpenders(chain);
const pairs: Array<{ token: KnownToken; spender: KnownSpender }> = [];
for (const token of tokens) for (const spender of spenders) pairs.push({ token, spender });
const results = await Promise.all(pairs.map(async ({ token, spender }) => {
try {
const allowance = await readErc20Allowance({
chain, token: token.address, owner, spender: spender.address,
});
if (allowance === 0n) return null; // только открытые двери
const unlimited = allowance > UNLIMITED_THRESHOLD;
return {
token: token.symbol,
tokenAddress: token.address,
spender: spender.name,
spenderAddress: spender.address,
allowance: allowance.toString(),
allowanceHuman: unlimited ? 'unlimited' : formatSmallestUnits(allowance.toString(), token.decimals),
unlimited,
} as OpenApproval;
} catch (err: any) {
logger.warn(`approvals scan: allowance read failed token=${token.symbol} spender=${spender.name}: ${err?.message}`);
return null;
}
}));
return results.filter((r): r is OpenApproval => r !== null);
}
// ── GET /:chain/approvals — read-only scan открытых allowance ──────────────
export async function scanApprovals(req: Request, res: Response): Promise<void> {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
const chain = resolveEvmChain(req, res);
if (!chain) return;
const wallet = await WalletModel.findByUserAndChain(userId, chain);
if (!wallet) {
res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` });
return;
}
const approvals = await scanOpenApprovals(chain, wallet.address);
res.json({ success: true, data: { chain, owner: wallet.address, approvals } });
}
// ── POST /:chain/approvals/revoke — custodial revoke (approve(spender, 0)) ──
export async function revokeApproval(req: Request, res: Response): Promise<void> {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
const chain = resolveEvmChain(req, res);
if (!chain) return;
const token = String(req.body?.token || '');
const spender = String(req.body?.spender || '');
if (!ethers.utils.isAddress(token)) {
res.status(400).json({ success: false, error: 'token must be a valid EVM contract address' });
return;
}
if (!ethers.utils.isAddress(spender)) {
res.status(400).json({ success: false, error: 'spender must be a valid EVM address' });
return;
}
const wallet = await WalletModel.findByUserAndChain(userId, chain);
if (!wallet) {
res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` });
return;
}
// Pre-check: если allowance уже 0 — не тратим газ, отвечаем сразу (read-only, без подписи).
let current: bigint | null = null;
try {
current = await readErc20Allowance({ chain, token, owner: wallet.address, spender });
} catch (err: any) {
logger.warn(`approvals revoke pre-check failed (proceeding): ${err?.message}`);
}
if (current === 0n) {
res.json({
success: true,
data: { chain, token, spender, txid: null, alreadyZero: true, message: 'Allowance already 0 — nothing to revoke' },
});
return;
}
// ── Idempotency claim ──
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
if (idempKey) {
try {
const claim = await claimIdempotency(userId, idempKey, req.body || {});
if (!claim.fresh && claim.cached) {
res.status(claim.cached.status).type('application/json').send(claim.cached.body);
return;
}
} catch (err: any) {
res.status(409).json({ success: false, error: err.message });
return;
}
}
// ── Audit row BEFORE broadcast (strict) ──
let auditId: string;
try {
auditId = await auditLogStrict({ event: 'approval.revoke', userId, ip: req.ip || null, meta: { chain, token, spender } });
} catch (auditErr: any) {
logger.error(`Audit INSERT must succeed for approval.revoke: ${auditErr.message}`);
res.status(503).json({ success: false, error: 'Audit service unavailable' });
return;
}
// ── 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 {
await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED');
res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' });
return;
}
try {
// amount '0' ЗАХАРДКОЖЕН — endpoint может только отозвать, не выдать approval.
const { txid } = await signAndBroadcastEvmApprove({
chain,
mnemonic,
expectedFromAddress: wallet.address,
spender,
token,
amount: '0',
});
auditLog({
event: 'approval.revoke.broadcast', userId, ip: req.ip || null, result: 'success',
meta: { chain, token, spender, txid },
}).catch(() => {});
await completeAudit(auditId, 'success');
const data = { chain, token, spender, txid, previousAllowance: current?.toString() ?? null };
const respBody = { success: true, data };
if (idempKey) {
try { await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(respBody)); } catch { /* ignore */ }
}
res.status(200).json(respBody);
} catch (err: any) {
await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80));
logger.warn(`Approval revoke failed: chain=${chain} token=${token} spender=${spender}${err?.message}`);
const respBody = { success: false, error: err?.message || 'revoke failed', code: err?.code };
if (idempKey) {
try { await saveIdempotencyResponse(userId, idempKey, 502, JSON.stringify(respBody)); } catch { /* ignore */ }
}
res.status(502).json(respBody);
} finally {
mnemonic = '';
}
}
/** Похоже ли на «не хватает газа/нативки» — тогда дальнейшие revoke тоже упадут, прерываемся рано. */
function isInsufficientFunds(err: any): boolean {
const code = String(err?.code || '');
const msg = String(err?.message || '').toLowerCase();
return code === 'INSUFFICIENT_FUNDS' || msg.includes('insufficient funds');
}
// ── POST /:chain/approvals/revoke-all — «отвязка от всего»: отозвать ВСЕ открытые двери ──
export async function revokeAllApprovals(req: Request, res: Response): Promise<void> {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
const chain = resolveEvmChain(req, res);
if (!chain) return;
// По умолчанию отзываем ВСЕ ненулевые approval («от всего»). onlyUnlimited:true — только unlimited.
const onlyUnlimited = req.body?.onlyUnlimited === true;
const wallet = await WalletModel.findByUserAndChain(userId, chain);
if (!wallet) {
res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` });
return;
}
// ── Серверный скан (не доверяем клиентскому списку) → какие двери открыты ──
let open: OpenApproval[];
try {
open = await scanOpenApprovals(chain, wallet.address);
} catch (err: any) {
res.status(502).json({ success: false, error: `approvals scan failed: ${err?.message || 'unknown'}` });
return;
}
const targets = onlyUnlimited ? open.filter((o) => o.unlimited) : open;
// Нет открытых дверей → ничего не подписываем (без decrypt/tx/газа).
if (targets.length === 0) {
res.json({
success: true,
data: { chain, owner: wallet.address, revoked: [], failed: [], skipped: [], total: 0, message: 'no open approvals — nothing to revoke' },
});
return;
}
// ── Idempotency claim ──
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
if (idempKey) {
try {
const claim = await claimIdempotency(userId, idempKey, req.body || {});
if (!claim.fresh && claim.cached) {
res.status(claim.cached.status).type('application/json').send(claim.cached.body);
return;
}
} catch (err: any) {
res.status(409).json({ success: false, error: err.message });
return;
}
}
// ── Audit BEFORE broadcast (strict) ──
let auditId: string;
try {
auditId = await auditLogStrict({
event: 'approval.revoke-all', userId, ip: req.ip || null,
meta: { chain, onlyUnlimited, count: targets.length },
});
} catch (auditErr: any) {
logger.error(`Audit INSERT must succeed for approval.revoke-all: ${auditErr.message}`);
res.status(503).json({ success: false, error: 'Audit service unavailable' });
return;
}
// ── Decrypt mnemonic ОДИН раз для всех revoke ──
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 {
await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED');
res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' });
return;
}
const revoked: Array<{ token: string; spender: string; tokenAddress: string; spenderAddress: string; txid: string; previousAllowance: string }> = [];
const failed: Array<{ token: string; spender: string; tokenAddress: string; spenderAddress: string; error: string }> = [];
const skipped: Array<{ token: string; spender: string; tokenAddress: string; spenderAddress: string; reason: string }> = [];
try {
// ПОСЛЕДОВАТЕЛЬНО: signAndBroadcastEvmApprove ждёт 1 conf → следующий nonce корректен.
for (let i = 0; i < targets.length; i++) {
const t = targets[i];
try {
// amount '0' ЗАХАРДКОЖЕН — отзыв, не выдача.
const { txid } = await signAndBroadcastEvmApprove({
chain, mnemonic, expectedFromAddress: wallet.address,
spender: t.spenderAddress, token: t.tokenAddress, amount: '0',
});
revoked.push({
token: t.token, spender: t.spender, tokenAddress: t.tokenAddress, spenderAddress: t.spenderAddress,
txid, previousAllowance: t.allowance,
});
} catch (err: any) {
// Кончился газ/нативка → остальные тоже упадут: помечаем остаток skipped и выходим.
if (isInsufficientFunds(err)) {
failed.push({ token: t.token, spender: t.spender, tokenAddress: t.tokenAddress, spenderAddress: t.spenderAddress, error: 'insufficient funds for gas' });
for (let j = i + 1; j < targets.length; j++) {
const s = targets[j];
skipped.push({ token: s.token, spender: s.spender, tokenAddress: s.tokenAddress, spenderAddress: s.spenderAddress, reason: 'stopped: insufficient gas — top up native and retry' });
}
break;
}
failed.push({ token: t.token, spender: t.spender, tokenAddress: t.tokenAddress, spenderAddress: t.spenderAddress, error: err?.message?.slice(0, 160) || 'revoke failed' });
}
}
auditLog({
event: 'approval.revoke-all.broadcast', userId, ip: req.ip || null, result: 'success',
meta: { chain, revoked: revoked.length, failed: failed.length, skipped: skipped.length },
}).catch(() => {});
await completeAudit(auditId, 'success');
const data = { chain, owner: wallet.address, revoked, failed, skipped, total: targets.length };
const respBody = { success: true, data };
if (idempKey) {
try { await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(respBody)); } catch { /* ignore */ }
}
res.status(200).json(respBody);
} catch (err: any) {
await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80));
logger.warn(`Approval revoke-all fatal: chain=${chain}${err?.message}`);
res.status(502).json({ success: false, error: err?.message || 'revoke-all failed' });
} finally {
mnemonic = '';
}
}

View File

@@ -1,195 +0,0 @@
/**
* Uniswap v3 LP endpoints — custodial (ETH). Пути /:chain/lp/*.
* Безопасность: JWT-bind ETH-кошелька, Idempotency-Key, auditLogStrict, decrypt mnemonic в памяти.
*/
import { Request, Response } 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 { auditLogStrict, completeAudit } from '../lib/audit-log';
import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency';
import { StakingError } from '../services/staking.service';
import {
getPools,
quoteAdd,
executeAddLiquidity,
executeRemoveLiquidity,
readLpPositions,
} from '../services/uniswap-lp.service';
function getUserId(req: Request): string | null {
return (req as any).auth?.userId || null;
}
function assertEth(req: Request, res: Response): boolean {
if (String(req.params.chain).toUpperCase() !== 'ETH') {
res.status(400).json({ success: false, error: 'Uniswap v3 LP supported on ETH only' });
return false;
}
return true;
}
function validAmount(v: unknown): v is string {
return typeof v === 'string' && /^\d+$/.test(v);
}
interface Ctx { auditId: string; mnemonic: string; wallet: { address: string }; idempKey: string | null; zero: () => void; }
async function beginMutation(req: Request, res: Response, userId: string, event: string, meta: Record<string, unknown>): Promise<Ctx | null> {
const wallet = await WalletModel.findByUserAndChain(userId, 'ETH');
if (!wallet) { res.status(403).json({ success: false, error: 'No ETH wallet for user — create wallet first' }); return null; }
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
if (idempKey) {
try {
const claim = await claimIdempotency(userId, idempKey, req.body || {});
if (!claim.fresh && claim.cached) { res.status(claim.cached.status).type('application/json').send(claim.cached.body); return null; }
} catch (err: any) { res.status(409).json({ success: false, error: err.message }); return null; }
}
let auditId: string;
try {
auditId = await auditLogStrict({ event, userId, ip: req.ip || null, meta });
} catch (auditErr: any) {
logger.error(`Audit INSERT must succeed for ${event}: ${auditErr.message}`);
res.status(503).json({ success: false, error: 'Audit service unavailable' });
return null;
}
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 null; }
let mnemonic: string;
try { mnemonic = decryptMnemonic(blob); } catch { await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED'); res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' }); return null; }
const ctx: Ctx = { auditId, mnemonic, wallet, idempKey, zero: () => { ctx.mnemonic = ''; } };
return ctx;
}
async function finish(res: Response, userId: string, idempKey: string | null, auditId: string, data: unknown): Promise<void> {
await completeAudit(auditId, 'success');
const body = { success: true, data };
if (idempKey) { try { await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(body)); } catch { /* ignore */ } }
res.status(200).json(body);
}
async function fail(res: Response, userId: string, idempKey: string | null, auditId: string, err: any, label: string): Promise<void> {
const code = err instanceof StakingError ? err.httpStatus : 502;
await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80));
logger.warn(`LP ${label} failed: ${err?.message}`);
const body = { success: false, error: err?.message || `${label} failed`, code: err?.code };
if (idempKey) { try { await saveIdempotencyResponse(userId, idempKey, code, JSON.stringify(body)); } catch { /* ignore */ } }
res.status(code).json(body);
}
// ── GET /:chain/lp/pools ───────────────────────────────────────────────
export async function lpPools(req: Request, res: Response): Promise<void> {
if (!getUserId(req)) { res.status(401).json({ success: false, error: 'auth required' }); return; }
if (!assertEth(req, res)) return;
try {
res.json({ success: true, data: await getPools() });
} catch (err: any) {
res.status(502).json({ success: false, error: err?.message || 'pools read failed' });
}
}
// ── POST /:chain/lp/quote ──────────────────────────────────────────────
export async function lpQuote(req: Request, res: Response): Promise<void> {
if (!getUserId(req)) { res.status(401).json({ success: false, error: 'auth required' }); return; }
if (!assertEth(req, res)) return;
const userId = getUserId(req)!;
const b = req.body || {};
if (!b.pool || !validAmount(String(b.amount0Desired ?? '')) || !validAmount(String(b.amount1Desired ?? ''))) {
res.status(400).json({ success: false, error: 'pool, amount0Desired, amount1Desired (integer strings) required' });
return;
}
try {
// Кошелёк опционален: если есть — добавим проверку баланса token0/token1/gas (read-only, без 403).
const wallet = await WalletModel.findByUserAndChain(userId, 'ETH');
const quote = await quoteAdd({
poolAddress: String(b.pool),
priceLower: Number(b.priceLower),
priceUpper: Number(b.priceUpper),
amount0Desired: String(b.amount0Desired),
amount1Desired: String(b.amount1Desired),
slippageBps: b.slippageBps !== undefined ? Number(b.slippageBps) : undefined,
ownerAddress: wallet?.address,
useNativeEth: b.useNativeEth === true || b.useNativeEth === 'true',
});
res.json({ success: true, data: quote });
} catch (err: any) {
const status = err instanceof StakingError ? err.httpStatus : 502;
res.status(status).json({ success: false, error: err?.message || 'quote failed', code: err?.code });
}
}
// ── POST /:chain/lp/add ────────────────────────────────────────────────
export async function lpAdd(req: Request, res: Response): Promise<void> {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
if (!assertEth(req, res)) return;
const b = req.body || {};
if (!b.pool || !validAmount(String(b.amount0Desired ?? '')) || !validAmount(String(b.amount1Desired ?? ''))) {
res.status(400).json({ success: false, error: 'pool, amount0Desired, amount1Desired (integer strings) required' });
return;
}
const ctx = await beginMutation(req, res, userId, 'lp.add', { pool: String(b.pool) });
if (!ctx) return;
const { auditId, mnemonic, wallet, idempKey } = ctx;
try {
const result = await executeAddLiquidity({
mnemonic, expectedFromAddress: wallet.address,
poolAddress: String(b.pool),
priceLower: Number(b.priceLower),
priceUpper: Number(b.priceUpper),
amount0Desired: String(b.amount0Desired),
amount1Desired: String(b.amount1Desired),
slippageBps: b.slippageBps !== undefined ? Number(b.slippageBps) : undefined,
useNativeEth: b.useNativeEth === true || b.useNativeEth === 'true',
});
await finish(res, userId, idempKey, auditId, result);
} catch (err: any) {
await fail(res, userId, idempKey, auditId, err, 'add');
} finally {
ctx.zero();
}
}
// ── POST /:chain/lp/remove ─────────────────────────────────────────────
export async function lpRemove(req: Request, res: Response): Promise<void> {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
if (!assertEth(req, res)) return;
const b = req.body || {};
const tokenId = String(b.tokenId ?? '');
const percent = Number(b.percent ?? 100);
if (!/^\d+$/.test(tokenId)) {
res.status(400).json({ success: false, error: 'tokenId (integer string) required' });
return;
}
const ctx = await beginMutation(req, res, userId, 'lp.remove', { tokenId, percent });
if (!ctx) return;
const { auditId, mnemonic, wallet, idempKey } = ctx;
try {
const result = await executeRemoveLiquidity({ mnemonic, expectedFromAddress: wallet.address, tokenId, percent });
await finish(res, userId, idempKey, auditId, result);
} catch (err: any) {
await fail(res, userId, idempKey, auditId, err, 'remove');
} finally {
ctx.zero();
}
}
// ── GET /:chain/lp/positions ───────────────────────────────────────────
export async function lpPositions(req: Request, res: Response): Promise<void> {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
if (!assertEth(req, res)) return;
const wallet = await WalletModel.findByUserAndChain(userId, 'ETH');
if (!wallet) { res.status(403).json({ success: false, error: 'No ETH wallet for user — create wallet first' }); return; }
try {
res.json({ success: true, data: await readLpPositions(wallet.address) });
} catch (err: any) {
res.status(502).json({ success: false, error: err?.message || 'positions read failed' });
}
}

View File

@@ -11,7 +11,6 @@ import { Request, Response } from 'express';
import { getCoingeckoId } from '../lib/token-registry'; import { getCoingeckoId } from '../lib/token-registry';
import { ALL_CHAINS } from '../services/wallet-generator.service'; import { ALL_CHAINS } from '../services/wallet-generator.service';
import { getPricesBySymbols } from '../services/price-oracle.service'; import { getPricesBySymbols } from '../services/price-oracle.service';
// getPricesWithChangeByIds импортируется dynamic'но в getDynamics handler ниже.
import type { ChainCode } from '../lib/address-validators'; import type { ChainCode } from '../lib/address-validators';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
@@ -136,87 +135,4 @@ export const PricesController = {
res.status(502).json({ success: false, error: 'Upstream price oracle error' }); 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' });
}
},
}; };

View File

@@ -1,294 +0,0 @@
/**
* Staking endpoints — custodial. ETH → Lido, SOL → native (StakeProgram).
*
* Безопасность как в bridge.routes.ts: JWT-bind (адрес ≡ кошелёк юзера для chain),
* Idempotency-Key (анти-дабл-спенд), auditLogStrict до broadcast, decrypt mnemonic в памяти.
* Quote/positions читают из чейна (без записи). Пути: /:chain/stake*, /:chain/unstake*.
*/
import { Request, Response } 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 {
quoteLidoStake,
executeLidoStake,
readLidoPositions,
unstakeLidoViaSwap,
unstakeLidoViaQueue,
claimLidoWithdrawal,
StakingError,
} from '../services/staking.service';
import {
quoteSolStake,
executeSolStake,
executeSolUnstake,
readSolStakePositions,
} from '../services/sol-stake.service';
type StakeChain = 'ETH' | 'SOL';
function getUserId(req: Request): string | null {
return (req as any).auth?.userId || null;
}
/** Разрешённые chains для стейкинга. Возвращает нормализованный chain или null (+ ответ 400). */
function resolveStakeChain(req: Request, res: Response): StakeChain | null {
const chain = String(req.params.chain).toUpperCase();
if (chain === 'ETH' || chain === 'SOL') return chain;
res.status(400).json({ success: false, error: 'Staking supported on ETH (Lido) and SOL (native) only' });
return null;
}
function validAmount(v: unknown): v is string {
return typeof v === 'string' && /^\d+$/.test(v) && v !== '0';
}
interface MutationCtx {
auditId: string;
mnemonic: string;
wallet: { address: string };
idempKey: string | null;
zero: () => void;
}
/**
* Общая преамбула мутации: JWT-bind кошелька chain + Idempotency-Key + auditLogStrict +
* decrypt mnemonic. На любой неуспех сам отвечает в res и возвращает null.
*/
async function beginMutation(
req: Request,
res: Response,
userId: string,
chain: StakeChain,
event: string,
meta: Record<string, unknown>,
): Promise<MutationCtx | null> {
const wallet = await WalletModel.findByUserAndChain(userId, chain);
if (!wallet) {
res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` });
return null;
}
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
if (idempKey) {
try {
const claim = await claimIdempotency(userId, idempKey, req.body || {});
if (!claim.fresh && claim.cached) {
res.status(claim.cached.status).type('application/json').send(claim.cached.body);
return null;
}
} catch (err: any) {
res.status(409).json({ success: false, error: err.message });
return null;
}
}
let auditId: string;
try {
auditId = await auditLogStrict({ event, userId, ip: req.ip || null, meta });
} catch (auditErr: any) {
logger.error(`Audit INSERT must succeed for ${event}: ${auditErr.message}`);
res.status(503).json({ success: false, error: 'Audit service unavailable' });
return null;
}
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 null;
}
let mnemonic: string;
try {
mnemonic = decryptMnemonic(blob);
} catch {
await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED');
res.status(500).json({ success: false, error: 'Mnemonic decrypt failed' });
return null;
}
const ctx: MutationCtx = { auditId, mnemonic, wallet, idempKey, zero: () => { ctx.mnemonic = ''; } };
return ctx;
}
async function finishMutation(
res: Response, userId: string, idempKey: string | null, auditId: string, data: unknown,
): Promise<void> {
await completeAudit(auditId, 'success');
const respBody = { success: true, data };
if (idempKey) {
try { await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(respBody)); } catch { /* ignore */ }
}
res.status(200).json(respBody);
}
async function failMutation(
res: Response, userId: string, idempKey: string | null, auditId: string, err: any, label: string,
): Promise<void> {
const code = err instanceof StakingError ? err.httpStatus : 502;
await completeAudit(auditId, 'failure', undefined, err?.code || err?.message?.slice(0, 80));
logger.warn(`Stake ${label} failed: ${err?.message}`);
const respBody = { success: false, error: err?.message || `${label} failed`, code: err?.code };
if (idempKey) {
try { await saveIdempotencyResponse(userId, idempKey, code, JSON.stringify(respBody)); } catch { /* ignore */ }
}
res.status(code).json(respBody);
}
// ── POST /:chain/stake/quote ───────────────────────────────────────────
export async function stakeQuote(req: Request, res: Response): Promise<void> {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
const chain = resolveStakeChain(req, res);
if (!chain) return;
const amount = String(req.body?.amount || '');
if (!validAmount(amount)) {
res.status(400).json({ success: false, error: 'amount must be a positive integer string (ETH=wei, SOL=lamports)' });
return;
}
try {
const quote = chain === 'ETH' ? await quoteLidoStake(amount) : await quoteSolStake(amount);
res.json({ success: true, data: quote });
} catch (err: any) {
const status = err instanceof StakingError ? err.httpStatus : 502;
res.status(status).json({ success: false, error: err?.message || 'quote failed', code: err?.code });
}
}
// ── POST /:chain/stake ─────────────────────────────────────────────────
export async function stakeExecute(req: Request, res: Response): Promise<void> {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
const chain = resolveStakeChain(req, res);
if (!chain) return;
const amount = String(req.body?.amount || '');
if (!validAmount(amount)) {
res.status(400).json({ success: false, error: 'amount must be a positive integer string (ETH=wei, SOL=lamports)' });
return;
}
const ctx = await beginMutation(req, res, userId, chain, 'stake.execute', { chain, amount });
if (!ctx) return;
const { auditId, mnemonic, wallet, idempKey } = ctx;
try {
const result = chain === 'ETH'
? await executeLidoStake({ mnemonic, expectedFromAddress: wallet.address, amountWei: amount })
: await executeSolStake({ mnemonic, expectedFromAddress: wallet.address, amountLamports: amount });
auditLog({ event: 'stake.broadcast', userId, ip: req.ip || null, result: 'success', meta: { chain } }).catch(() => {});
await finishMutation(res, userId, idempKey, auditId, result);
} catch (err: any) {
await failMutation(res, userId, idempKey, auditId, err, 'stake');
} finally {
ctx.zero();
}
}
// ── POST /:chain/unstake ───────────────────────────────────────────────
export async function unstakeExecute(req: Request, res: Response): Promise<void> {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
const chain = resolveStakeChain(req, res);
if (!chain) return;
// Валидация тела зависит от chain
let amount = '';
let mode = 'swap';
let stakeAccount = '';
if (chain === 'ETH') {
amount = String(req.body?.amount || '');
if (!validAmount(amount)) {
res.status(400).json({ success: false, error: 'amount must be a positive integer string (stETH wei)' });
return;
}
mode = String(req.body?.mode || 'swap');
if (mode !== 'swap' && mode !== 'queue') {
res.status(400).json({ success: false, error: 'mode must be "swap" (default, instant) or "queue"' });
return;
}
// slippage на выводе задаётся сервером автоматически (маленький фикс) — клиент не управляет.
} else {
stakeAccount = String(req.body?.stakeAccount || '');
if (!stakeAccount) {
res.status(400).json({ success: false, error: 'stakeAccount required (SOL stake account address)' });
return;
}
}
const meta = chain === 'ETH' ? { chain, mode, amount } : { chain, stakeAccount };
const ctx = await beginMutation(req, res, userId, chain, 'unstake.execute', meta);
if (!ctx) return;
const { auditId, mnemonic, wallet, idempKey } = ctx;
try {
let result: unknown;
if (chain === 'ETH') {
result = mode === 'queue'
? await unstakeLidoViaQueue({ mnemonic, expectedFromAddress: wallet.address, amountStEthWei: amount })
: await unstakeLidoViaSwap({ mnemonic, expectedFromAddress: wallet.address, amountStEthWei: amount });
} else {
result = await executeSolUnstake({ mnemonic, expectedFromAddress: wallet.address, stakeAccount });
}
await finishMutation(res, userId, idempKey, auditId, result);
} catch (err: any) {
await failMutation(res, userId, idempKey, auditId, err, 'unstake');
} finally {
ctx.zero();
}
}
// ── POST /:chain/unstake/claim (ETH/Lido queue only) ───────────────────
export async function unstakeClaim(req: Request, res: Response): Promise<void> {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
const chain = resolveStakeChain(req, res);
if (!chain) return;
if (chain !== 'ETH') {
res.status(400).json({ success: false, error: 'claim only applies to ETH Lido queue withdrawals' });
return;
}
const requestId = String(req.body?.requestId || '');
if (!/^\d+$/.test(requestId)) {
res.status(400).json({ success: false, error: 'requestId must be a positive integer string' });
return;
}
const ctx = await beginMutation(req, res, userId, 'ETH', 'unstake.lido.claim', { requestId });
if (!ctx) return;
const { auditId, mnemonic, wallet, idempKey } = ctx;
try {
const result = await claimLidoWithdrawal({ mnemonic, expectedFromAddress: wallet.address, requestId });
await finishMutation(res, userId, idempKey, auditId, result);
} catch (err: any) {
await failMutation(res, userId, idempKey, auditId, err, 'claim');
} finally {
ctx.zero();
}
}
// ── GET /:chain/stake/positions ────────────────────────────────────────
export async function stakePositions(req: Request, res: Response): Promise<void> {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ success: false, error: 'auth required' }); return; }
const chain = resolveStakeChain(req, res);
if (!chain) return;
const wallet = await WalletModel.findByUserAndChain(userId, chain);
if (!wallet) {
res.status(403).json({ success: false, error: `No ${chain} wallet for user — create wallet first` });
return;
}
try {
const positions = chain === 'ETH'
? await readLidoPositions(wallet.address)
: await readSolStakePositions(wallet.address);
res.json({ success: true, data: positions });
} catch (err: any) {
res.status(502).json({ success: false, error: err?.message || 'positions read failed' });
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,222 +0,0 @@
/**
* 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

@@ -1,86 +0,0 @@
/**
* 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 Taproot (bech32m P2TR, bc1p). Клиентские кошельки остаются SegWit — они платят сюда.
* Требует initEccLib(ecc) (см. lib/btc-ecc.ts), иначе построение выхода на bc1p падает. */
export const APP_FEE_WALLET_BTC = 'bc1pgak39caltz9j3r3wk9c0sy89a29hax9navxvdvaj3nmwuxpgp6fq2rxm4m';
/** 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,26 +0,0 @@
/**
* 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

@@ -1,20 +0,0 @@
/**
* Side-effect модуль: инициализирует ECC-бэкенд bitcoinjs-lib один раз для taproot.
*
* Зачем: на bitcoinjs-lib ≥6 построение/валидация выхода на Taproot-адрес (bech32m `bc1p…`) требует
* `initEccLib(ecc)` — без него `address.toOutputScript`/`psbt.addOutput({address: 'bc1p…'})` бросают
* `No ECC Library provided`. Нам это нужно, чтобы платить app-комиссию на Taproot fee-кошелёк
* (`APP_FEE_WALLET_BTC = bc1p…`) с обычных SegWit-кошельков клиентов.
*
* Чисто аддитивно: задействуется ТОЛЬКО в taproot-путях bitcoinjs. p2wpkh-генерация (клиентские bc1q),
* ECDSA-подпись входов и валидация/отправка на bc1q/legacy — без изменений. Идемпотентно.
*
* Импортировать ПЕРВЫМ (до bitcoinjs-операций) в модулях, которые строят BTC-выходы:
* `wallet-signer.service.ts` (sendBtc), `wallet-signer-bridge.ts` (signAndBroadcastBtcDeposit),
* `wallet-generator.service.ts`.
*/
import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from 'tiny-secp256k1';
bitcoin.initEccLib(ecc);

View File

@@ -1,137 +0,0 @@
/**
* DeFi contract constants + scoped EVM allowlist для нового custodial-функционала
* (Lido staking + вывод, Uniswap v3 LP).
*
* В отличие от `evm-tx-policy.ts` (который защищает КЛИЕНТСКИЙ /sign-raw-evm-tx с произвольным
* calldata), здесь calldata строится НА СЕРВЕРЕ из фиксированных ABI + адресов из env — как в
* bridge-execute.service.ts. Поэтому Relay-политику не трогаем. Этот allowlist — defense-in-depth:
* даже server-built tx обязана иметь `to` из явного множества, иначе подпись отклоняется.
*/
import { ethers } from 'ethers';
import { env } from '../config/env';
/** ETH mainnet RPC failover-набор (read + provider для server-built tx чтения). */
export const ETH_RPCS = [
'https://ethereum-rpc.publicnode.com',
'https://eth.llamarpc.com',
'https://rpc.ankr.com/eth',
];
/** Checksummed-адреса (env хранит lowercase, getAddress даёт корректный EIP-55). */
export function lidoStEthAddress(): string {
return ethers.utils.getAddress(env.defi.lidoStEth);
}
export function lidoWithdrawalQueueAddress(): string {
return ethers.utils.getAddress(env.defi.lidoWithdrawalQueue);
}
export function curveStEthPoolAddress(): string {
return ethers.utils.getAddress(env.defi.curveStEthPool);
}
export function uniswapNfpmAddress(): string {
return ethers.utils.getAddress(env.defi.uniswapV3Nfpm);
}
/** Курируемые Uniswap v3 пулы (lowercase). */
export function uniswapLpPools(): string[] {
return env.defi.uniswapLpPools;
}
export function isCuratedPool(poolAddress: string): boolean {
return env.defi.uniswapLpPools.includes(poolAddress.toLowerCase());
}
/**
* Множество разрешённых `to`-адресов для server-built DeFi-tx (основные вызовы через
* signAndBroadcastRawEvm). approve идёт через signAndBroadcastEvmApprove (отдельный helper).
* Pool token-адреса (для approve→NFPM) передаются динамически через `extraAllowed`.
*/
function baseAllowed(): Set<string> {
return new Set<string>([
env.defi.lidoStEth.toLowerCase(),
env.defi.lidoWithdrawalQueue.toLowerCase(),
env.defi.curveStEthPool.toLowerCase(),
env.defi.uniswapV3Nfpm.toLowerCase(),
]);
}
/**
* Защита: каждый server-built EVM-таргет ДОЛЖЕН быть в allowlist перед подписью.
* @param to целевой контракт
* @param extraAllowed доп. адреса (напр. token0/token1 пула)
*/
export function assertDefiEvmTarget(to: string, extraAllowed: string[] = []): void {
const allowed = baseAllowed();
for (const a of extraAllowed) allowed.add(a.toLowerCase());
if (!allowed.has(to.toLowerCase())) {
throw new Error(`DeFi policy: target ${to} not in allowlist`);
}
}
// ─── ABIs ──────────────────────────────────────────────────────────────
/** Lido stETH. */
export const LIDO_ABI = [
'function submit(address _referral) payable returns (uint256)',
'function balanceOf(address owner) view returns (uint256)',
'function approve(address spender, uint256 amount) returns (bool)',
'function getPooledEthByShares(uint256 _sharesAmount) view returns (uint256)',
'function getSharesByPooledEth(uint256 _ethAmount) view returns (uint256)',
];
/** Lido Withdrawal Queue (ERC721). */
export const LIDO_WQ_ABI = [
'function requestWithdrawals(uint256[] _amounts, address _owner) returns (uint256[] requestIds)',
'function claimWithdrawal(uint256 _requestId)',
'function getWithdrawalRequests(address _owner) view returns (uint256[] requestsIds)',
'function getWithdrawalStatus(uint256[] _requestIds) view returns (tuple(uint256 amountOfStETH, uint256 amountOfShares, address owner, uint256 timestamp, bool isFinalized, bool isClaimed)[] statuses)',
'function MIN_STETH_WITHDRAWAL_AMOUNT() view returns (uint256)',
'function MAX_STETH_WITHDRAWAL_AMOUNT() view returns (uint256)',
];
/** Curve stETH/ETH StableSwap pool. i=0 ETH, i=1 stETH. */
export const CURVE_STETH_ABI = [
// НЕ payable: Curve забирает stETH через transferFrom (нужен approve), msg.value не используется.
'function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) returns (uint256)',
'function get_dy(int128 i, int128 j, uint256 dx) view returns (uint256)',
];
/** Uniswap v3 NonfungiblePositionManager. */
export const NFPM_ABI = [
'function balanceOf(address owner) view returns (uint256)',
'function ownerOf(uint256 tokenId) view returns (address)',
'function tokenOfOwnerByIndex(address owner, uint256 index) view returns (uint256)',
'function positions(uint256 tokenId) view returns (uint96 nonce, address operator, address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint128 tokensOwed0, uint128 tokensOwed1)',
'function mint(tuple(address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint256 amount0Desired, uint256 amount1Desired, uint256 amount0Min, uint256 amount1Min, address recipient, uint256 deadline) params) payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1)',
'function decreaseLiquidity(tuple(uint256 tokenId, uint128 liquidity, uint256 amount0Min, uint256 amount1Min, uint256 deadline) params) payable returns (uint256 amount0, uint256 amount1)',
'function collect(tuple(uint256 tokenId, address recipient, uint128 amount0Max, uint128 amount1Max) params) payable returns (uint256 amount0, uint256 amount1)',
'function burn(uint256 tokenId) payable',
'function multicall(bytes[] data) payable returns (bytes[] results)',
];
/** Uniswap v3 Pool (read-only нужное). */
export const UNIV3_POOL_ABI = [
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
'function liquidity() view returns (uint128)',
'function token0() view returns (address)',
'function token1() view returns (address)',
'function fee() view returns (uint24)',
'function tickSpacing() view returns (int24)',
];
/** Минимальный ERC20. */
export const ERC20_META_ABI = [
'function decimals() view returns (uint8)',
'function symbol() view returns (string)',
'function balanceOf(address owner) view returns (uint256)',
'function allowance(address owner, address spender) view returns (uint256)',
];
/** Canonical WETH9 (mainnet) — для авто-wrap нативного ETH в LP-депозите. */
export const WETH_ADDRESS = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
export const WETH_ABI = [
'function deposit() payable',
'function withdraw(uint256 wad)',
];
export function isWeth(addr: string): boolean {
return typeof addr === 'string' && addr.toLowerCase() === WETH_ADDRESS;
}

View File

@@ -18,26 +18,16 @@
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 (cross-chain bridge lock) '0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 ETH
'0xf70da97812cb96acdf810712aa562db8dfa3dbef', // Relay Router v1 (legacy intra-chain entry point) '0xf70da97812cb96acdf810712aa562db8dfa3dbef', // Relay Router (legacy)
'0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', // Relay Router v2 (current intra-chain swap entry point, since ~2025)
]), ]),
// BSC mainnet (Relay использует тот же deterministic-deployed address для router v2) // BSC mainnet
56: new Set([ 56: new Set([
'0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 '0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 BSC
'0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', // Relay Router v2 (deterministic deploy)
]), ]),
}; };
@@ -59,13 +49,6 @@ 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>;
} }
/** /**
@@ -84,84 +67,32 @@ 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; flowKind: 'router-call' | 'approve-to-relay' } { export function applyEvmTxPolicy(p: PolicyParams): { routerName?: string; selectorName?: string } {
// 1. `to` validation — должен быть в Relay router allowlist для этого chainId
const toLower = p.to.toLowerCase(); const toLower = p.to.toLowerCase();
const staticRouters = RELAY_ROUTERS[p.chainId]; const routers = RELAY_ROUTERS[p.chainId];
if (!staticRouters) { if (!routers) {
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`);
} }
// Combined trust set = static whitelist dynamic cache (from /relay/execute responses) if (!routers.has(toLower)) {
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}`);
}
// ── Anti-unlimited-approval (CRITICAL) ──
// НИКОГДА не подписываем «бесконечный» approval (MAX_UINT256 и near-max сентинелы): открытый
// unlimited-approval позволяет солверу/роутеру вывести ЛЮБЫЕ будущие поступления токена с кошелька.
// amount = второй 32-byte параметр (hex 74..138). Cap 2^200 ≫ любого реального количества токенов
// (макс realistic ~2^128), но ловит unlimited. Клиент обязан аппрувить ТОЧНУЮ сумму свопа.
const approveAmount = ethers.BigNumber.from('0x' + p.data.slice(74, 138));
const MAX_REASONABLE_APPROVE = ethers.BigNumber.from(2).pow(200);
if (approveAmount.gt(MAX_REASONABLE_APPROVE)) {
throw new Error('Sign-raw policy: unlimited/oversized approval rejected — approve the exact swap amount, not MAX (открытый approval позволил бы вывести будущие поступления).');
}
// `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 —
// потому что для approve у нас отдельный (более строгий) check на spender выше. // 2. Selector blacklist — `approve()` etc. никогда не подписывается
if (selector && FORBIDDEN_SELECTORS[selector]) { let selectorName: string | undefined;
if (p.data.length >= 10) {
const selector = p.data.slice(0, 10).toLowerCase();
if (FORBIDDEN_SELECTORS[selector]) {
throw new Error(`Sign-raw policy: forbidden selector ${selector} (${FORBIDDEN_SELECTORS[selector]}) — drain vector`); throw new Error(`Sign-raw policy: forbidden selector ${selector} (${FORBIDDEN_SELECTORS[selector]}) — drain vector`);
} }
selectorName = selector ? `selector ${selector}` : undefined;
} }
// ─── Common caps (для обоих путей) ─── // 3. gas 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()}`);
@@ -171,14 +102,15 @@ 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: flowKind === 'router-call' ? `relay-router-${p.chainId}` : `relay-approve-${p.chainId}`, routerName: routers.has(toLower) ? `relay-router-${p.chainId}` : undefined,
selectorName, selectorName,
flowKind,
}; };
} }

View File

@@ -1,122 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { db } from '../config/database';
type Labels = Record<string, string>;
const SERVICE = 'cryptowallet';
const buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
const httpRequests = new Map<string, number>();
const httpLatencySum = new Map<string, number>();
const httpLatencyCount = new Map<string, number>();
const httpLatencyBuckets = new Map<string, number>();
function key(parts: string[]): string {
return parts.join('\u0000');
}
function inc(map: Map<string, number>, parts: string[], by = 1): void {
const k = key(parts);
map.set(k, (map.get(k) || 0) + by);
}
function labels(values: Labels): string {
return Object.entries(values)
.map(([k, v]) => `${k}="${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`)
.join(',');
}
function sample(name: string, value: number, values?: Labels): string {
return values ? `${name}{${labels(values)}} ${value}` : `${name} ${value}`;
}
function routeName(req: Request): string {
return req.route?.path ? String(req.baseUrl || '') + String(req.route.path) : req.path;
}
export function metricsMiddleware(req: Request, res: Response, next: NextFunction): void {
if (req.path === '/metrics' || req.path === '/api/metrics') {
next();
return;
}
const started = process.hrtime.bigint();
res.on('finish', () => {
const elapsed = Number(process.hrtime.bigint() - started) / 1_000_000_000;
const route = routeName(req);
const method = req.method;
const statusCode = String(res.statusCode);
inc(httpRequests, [method, route, statusCode]);
inc(httpLatencySum, [method, route], elapsed);
inc(httpLatencyCount, [method, route]);
for (const bucket of buckets) {
if (elapsed <= bucket) {
inc(httpLatencyBuckets, [method, route, String(bucket)]);
}
}
});
next();
}
async function walletMetrics(): Promise<string[]> {
const lines: string[] = [];
const totalRow = await db('wallets').count<{ count: string }[]>('* as count').first();
lines.push(sample('cryptowallet_wallets_total', Number(totalRow?.count || 0), { service: SERVICE }));
const byChain = await db('wallets').select('chain').count<{ chain: string; count: string }[]>('* as count').groupBy('chain');
for (const row of byChain) {
lines.push(sample('cryptowallet_wallets_by_chain_total', Number(row.count || 0), { service: SERVICE, chain: row.chain }));
}
const windows: [string, string | null][] = [
['all', null],
['30d', "now() - interval '30 days'"],
['7d', "now() - interval '7 days'"],
['24h', "now() - interval '24 hours'"],
['1h', "now() - interval '1 hour'"],
];
for (const [window, threshold] of windows) {
const query = db('wallets').count<{ count: string }[]>('* as count').first();
if (threshold) {
query.whereRaw(`created_at >= ${threshold}`);
}
const row = await query;
lines.push(sample('cryptowallet_wallets_created_total', Number(row?.count || 0), { service: SERVICE, window }));
}
return lines;
}
function httpMetrics(): string[] {
const lines: string[] = ['# TYPE cryptowallet_http_requests_total counter'];
for (const [k, value] of [...httpRequests.entries()].sort()) {
const [method, route, statusCode] = k.split('\u0000');
lines.push(sample('cryptowallet_http_requests_total', value, { service: SERVICE, method, route, status_code: statusCode }));
}
lines.push('# TYPE cryptowallet_http_request_duration_seconds histogram');
for (const [k, count] of [...httpLatencyCount.entries()].sort()) {
const [method, route] = k.split('\u0000');
for (const bucket of buckets) {
lines.push(sample('cryptowallet_http_request_duration_seconds_bucket', httpLatencyBuckets.get(key([method, route, String(bucket)])) || 0, { service: SERVICE, method, route, le: String(bucket) }));
}
lines.push(sample('cryptowallet_http_request_duration_seconds_bucket', count, { service: SERVICE, method, route, le: '+Inf' }));
lines.push(sample('cryptowallet_http_request_duration_seconds_sum', httpLatencySum.get(k) || 0, { service: SERVICE, method, route }));
lines.push(sample('cryptowallet_http_request_duration_seconds_count', count, { service: SERVICE, method, route }));
}
return lines;
}
export async function metricsHandler(_req: Request, res: Response): Promise<void> {
const lines = [
'# TYPE cryptowallet_wallets_total gauge',
'# TYPE cryptowallet_wallets_by_chain_total gauge',
'# TYPE cryptowallet_wallets_created_total gauge',
];
try {
lines.push(...await walletMetrics());
lines.push(sample('cryptowallet_metrics_scrape_success', 1, { service: SERVICE }));
} catch {
lines.push(sample('cryptowallet_metrics_scrape_success', 0, { service: SERVICE }));
}
lines.push(...httpMetrics());
res.type('text/plain; version=0.0.4; charset=utf-8').send(`${lines.join('\n')}\n`);
}

View File

@@ -1,309 +0,0 @@
/**
* 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

@@ -1,169 +0,0 @@
/**
* 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.
try {
return (await undiciFetch(input as any, {
...(init as any),
dispatcher: agent,
})) as unknown as Response;
} catch (err: any) {
// Таймаут/отмена (AbortSignal) — это НЕ отказ прокси, fallback не нужен (сохраняем 504-семантику).
if (err?.name === 'AbortError') throw err;
// Прокси недоступен (connect ECONNREFUSED / timeout до прокси) → самовосстановление: пробуем
// прямой выход. Relay/Jumper/Jupiter — публичные API, прямой fetch их достаёт, если у сервера
// есть direct egress. Срабатывает ТОЛЬКО при throw (HTTP-ошибка ОТ прокси не бросает — это
// обычный Response с !ok). Идемпотентно: при живом прокси сюда не заходим, поведение прежнее.
const reason = err?.cause?.code ?? err?.code ?? err?.name ?? 'unknown';
logger.warn(`Outbound proxy failed (${reason}) — fallback на прямой fetch`);
return fetch(input as any, init as any);
}
}
/**
* 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

@@ -1,96 +0,0 @@
/**
* 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

@@ -1,132 +0,0 @@
/**
* Авто-выбор «хорошего» Solana-валидатора для нативного стейкинга — чтобы работало из коробки
* без операторской настройки. Каскад с кэшем (KeyDB 24ч):
* 1. env override SOL_STAKE_VALIDATOR_VOTE (оператор может закрепить).
* 2. Stakewiz API (без ключа): фильтр delinquent/commission/skip_rate/uptime, сорт по wiz_score.
* 3. RPC getVoteAccounts: фильтр epochVoteAccount/commission/stake, сорт по (commission, credit growth).
* 4. Hard-coded last-resort (проверенный активный валидатор, 0% комиссия).
*
* Менять валидатор на новых стейках безопасно: stake-аккаунт запоминает свой voter в delegation,
* unstake/withdraw читают аккаунт, а не этот резолвер.
*/
import { Connection, PublicKey } from '@solana/web3.js';
import { env } from '../config/env';
import { getRedis } from '../config/redis';
import { logger } from './logger';
/** Last-resort fallback — Yurbason (0% commission, давний, проверен активным). */
const FALLBACK_VOTE = '9gANMngbGUmAaLXL1RC3JdiaLjRowJXNbzCTh53ht7mq';
const CACHE_KEY = 'sol:validator:default';
const CACHE_TTL_SEC = 24 * 60 * 60; // 24h
const STAKEWIZ_URL = 'https://api.stakewiz.com/validators';
const HTTP_TIMEOUT_MS = 6000;
let _conn: Connection | null = null;
function defaultConn(): Connection {
if (!_conn) _conn = new Connection('https://api.mainnet-beta.solana.com', 'confirmed');
return _conn;
}
function isValidVote(addr: unknown): addr is string {
if (typeof addr !== 'string' || !addr) return false;
try { new PublicKey(addr); return true; } catch { return false; }
}
/**
* Резолвит vote-адрес валидатора. Никогда не throws — деградирует до hard-coded fallback.
*/
export async function resolveStakeValidator(conn?: Connection): Promise<string> {
// 1) env override
if (isValidVote(env.defi.solStakeValidatorVote)) {
return env.defi.solStakeValidatorVote;
}
// cache
try {
const cached = await getRedis().get(CACHE_KEY);
if (cached && isValidVote(cached)) return cached;
} catch { /* ignore */ }
let chosen: string | null = null;
// 2) Stakewiz (внешний, untrusted) — выбор кросс-проверяется против on-chain перед доверием
chosen = await fromStakewiz().catch(() => null);
if (chosen) {
const ok = await verifyOnChain(conn ?? defaultConn(), chosen).catch(() => false);
if (!ok) {
logger.warn(`SOL validator: Stakewiz pick ${chosen} failed on-chain verification — falling back to RPC`);
chosen = null;
}
}
// 3) RPC fallback (on-chain authoritative)
if (!chosen) {
chosen = await fromRpc(conn ?? defaultConn()).catch(() => null);
}
// 4) last-resort
if (!chosen || !isValidVote(chosen)) {
logger.warn('SOL validator auto-select failed (Stakewiz + RPC) — using hard-coded fallback');
chosen = FALLBACK_VOTE;
}
try {
await getRedis().set(CACHE_KEY, chosen, 'EX', CACHE_TTL_SEC);
} catch { /* ignore */ }
return chosen;
}
async function fromStakewiz(): Promise<string | null> {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
try {
const res = await fetch(STAKEWIZ_URL, { headers: { Accept: 'application/json' }, signal: controller.signal });
if (!res.ok) return null;
const list = (await res.json()) as any[];
if (!Array.isArray(list)) return null;
const candidates = list
.filter((v) => v && v.delinquent === false)
.filter((v) => Number(v.commission) <= 2)
.filter((v) => Number(v.skip_rate) < 1)
.filter((v) => Number(v.uptime) >= 99.8)
.filter((v) => isValidVote(v.vote_identity))
.sort((a, b) => Number(b.wiz_score) - Number(a.wiz_score));
return candidates.length ? String(candidates[0].vote_identity) : null;
} catch {
return null;
} finally {
clearTimeout(t);
}
}
/**
* Кросс-проверка: vote-адрес (напр. от Stakewiz) ДОЛЖЕН существовать on-chain в current[],
* не быть delinquent, и иметь commission ≤ 10%. Защита от спуфнутого/устаревшего внешнего ответа.
*/
async function verifyOnChain(conn: Connection, vote: string): Promise<boolean> {
const accounts = await conn.getVoteAccounts();
if (accounts.delinquent.some((v: any) => v.votePubkey === vote)) return false;
const cur = accounts.current.find((v: any) => v.votePubkey === vote);
return !!cur && Number(cur.commission) <= 10;
}
async function fromRpc(conn: Connection): Promise<string | null> {
const accounts = await conn.getVoteAccounts();
const delinquent = new Set(accounts.delinquent.map((v: any) => v.votePubkey));
const candidates = accounts.current
.filter((v: any) => v.epochVoteAccount === true)
.filter((v: any) => Number(v.commission) <= 5)
.filter((v: any) => !delinquent.has(v.votePubkey))
.filter((v: any) => Number(v.activatedStake) > 100_000_000)
.map((v: any) => {
const ec = Array.isArray(v.epochCredits) ? v.epochCredits : [];
const last = ec[ec.length - 1];
const prev = ec[ec.length - 2];
const creditGrowth = last && prev ? Number(last[1]) - Number(prev[1]) : 0;
return { votePubkey: v.votePubkey as string, commission: Number(v.commission), creditGrowth };
})
.sort((a, b) => (a.commission !== b.commission ? a.commission - b.commission : b.creditGrowth - a.creditGrowth));
return candidates.length && isValidVote(candidates[0].votePubkey) ? candidates[0].votePubkey : null;
}

View File

@@ -1,146 +0,0 @@
/**
* 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,7 +10,6 @@ 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; coingeckoId?: string;
@@ -18,7 +17,6 @@ export interface EvmToken {
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; coingeckoId?: string;
@@ -26,49 +24,11 @@ export interface TrxToken {
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; 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. * CoinGecko coin IDs для native монет каждой chain.
* Используется в `price-oracle.service.ts` для USD-цен в `/balance`. * Используется в `price-oracle.service.ts` для USD-цен в `/balance`.
@@ -81,156 +41,45 @@ export const NATIVE_COINGECKO_IDS: Record<ChainCode, string> = {
SOL: 'solana', 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', name: 'Tether USD', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6, coingeckoId: 'tether' }, { symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6, coingeckoId: 'tether' },
{ symbol: 'USDC', name: 'USD Coin', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, coingeckoId: 'usd-coin' }, { symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, coingeckoId: 'usd-coin' },
{ symbol: 'DAI', name: 'Dai Stablecoin', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18, coingeckoId: 'dai' }, { symbol: 'DAI', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18, coingeckoId: 'dai' },
{ symbol: 'WBTC', name: 'Wrapped Bitcoin', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8, coingeckoId: 'wrapped-bitcoin' }, { symbol: 'WBTC', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8, coingeckoId: 'wrapped-bitcoin' },
{ symbol: 'LINK', name: 'Chainlink', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18, coingeckoId: 'chainlink' }, { symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18, coingeckoId: 'chainlink' },
{ symbol: 'UNI', name: 'Uniswap', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18, coingeckoId: 'uniswap' }, { symbol: 'UNI', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18, coingeckoId: 'uniswap' },
]; ];
export const BSC_TOKENS: EvmToken[] = [ export const BSC_TOKENS: EvmToken[] = [
{ symbol: 'USDT', name: 'Tether USD', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18, coingeckoId: 'tether' }, { symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18, coingeckoId: 'tether' },
{ symbol: 'USDC', name: 'USD Coin', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, coingeckoId: 'usd-coin' }, { symbol: 'USDC', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, coingeckoId: 'usd-coin' },
{ symbol: 'DOGE', name: 'Dogecoin', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8, coingeckoId: 'dogecoin' }, { symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8, coingeckoId: 'dogecoin' },
{ symbol: 'WBNB', name: 'Wrapped BNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18, coingeckoId: 'wbnb' }, { symbol: 'WBNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18, coingeckoId: 'wbnb' },
{ symbol: 'BUSD', name: 'Binance USD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18, coingeckoId: 'binance-usd' }, { symbol: 'BUSD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18, coingeckoId: 'binance-usd' },
]; ];
export const TRX_TOKENS: TrxToken[] = [ export const TRX_TOKENS: TrxToken[] = [
{ symbol: 'USDT', name: 'Tether USD', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6, coingeckoId: 'tether' }, { symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6, coingeckoId: 'tether' },
{ symbol: 'USDC', contractAddress: 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', decimals: 6, coingeckoId: 'usd-coin' },
]; ];
export const SOL_TOKENS: SolToken[] = [ export const SOL_TOKENS: SolToken[] = [
{ symbol: 'USDT', name: 'Tether USD', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6, coingeckoId: 'tether' }, { symbol: 'USDT', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6, coingeckoId: 'tether' },
{ symbol: 'USDC', name: 'USD Coin', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6, coingeckoId: 'usd-coin' }, { symbol: 'USDC', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6, coingeckoId: 'usd-coin' },
{ symbol: 'PUMP', name: 'Pump.fun', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6, coingeckoId: 'pump-fun' }, { symbol: 'PUMP', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6, coingeckoId: 'pump-fun' },
{ symbol: 'JUP', name: 'Jupiter', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6, coingeckoId: 'jupiter-exchange-solana' }, { symbol: 'JUP', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6, coingeckoId: 'jupiter-exchange-solana' },
{ symbol: 'WIF', name: 'dogwifhat', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6, coingeckoId: 'dogwifcoin' }, { symbol: 'WIF', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6, coingeckoId: 'dogwifcoin' },
{ symbol: 'POPCAT', name: 'Popcat', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9, coingeckoId: 'popcat' }, { symbol: 'POPCAT', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9, coingeckoId: 'popcat' },
{ symbol: 'TRUMP', name: 'Official Trump', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6, coingeckoId: 'official-trump' }, { symbol: 'TRUMP', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6, coingeckoId: 'official-trump' },
{ symbol: 'PYTH', name: 'Pyth Network', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6, coingeckoId: 'pyth-network' }, { symbol: 'PYTH', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6, coingeckoId: 'pyth-network' },
{ symbol: 'JTO', name: 'Jito', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9, coingeckoId: 'jito-governance-token' }, { symbol: 'JTO', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9, coingeckoId: 'jito-governance-token' },
{ symbol: 'W', name: 'Wormhole', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6, coingeckoId: 'wormhole' }, { symbol: 'W', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6, coingeckoId: 'wormhole' },
{ symbol: 'BONK', name: 'Bonk', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5, coingeckoId: 'bonk' }, { symbol: 'BONK', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5, coingeckoId: 'bonk' },
{ symbol: 'ORCA', name: 'Orca', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6, coingeckoId: 'orca' }, { symbol: 'ORCA', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6, coingeckoId: 'orca' },
{ symbol: 'PENGU', name: 'Pudgy Penguins', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6, coingeckoId: 'pudgy-penguins' }, { symbol: 'PENGU', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6, coingeckoId: 'pudgy-penguins' },
{ symbol: 'RAY', name: 'Raydium', mint: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6, coingeckoId: 'raydium' }, { symbol: 'RAY', 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;
@@ -293,10 +142,8 @@ export function getCoingeckoId(chain: ChainCode, symbol: string): string | null
const upper = String(symbol || '').toUpperCase(); const upper = String(symbol || '').toUpperCase();
if (!upper) return null; if (!upper) return null;
// Native — symbol === chain code (BTC/ETH/SOL/TRX) ИЛИ символ нативной монеты. // Native — symbol === chain code (BTC, ETH, ...).
// На BSC нативка = BNB (символ ≠ chain code 'BSC') — без этой ветки цена BNB не резолвилась if (upper === chain) return NATIVE_COINGECKO_IDS[chain] ?? null;
// (→ сетевая/0.7% комиссия в BNB приходила без USD). NATIVE_SYMBOLS[BSC]='BNB'.
if (upper === chain || upper === NATIVE_SYMBOLS[chain]) return NATIVE_COINGECKO_IDS[chain] ?? null;
if (chain === 'ETH' || chain === 'BSC') { if (chain === 'ETH' || chain === 'BSC') {
const t = getEvmTokens(chain).find((x) => x.symbol.toUpperCase() === upper); const t = getEvmTokens(chain).find((x) => x.symbol.toUpperCase() === upper);

View File

@@ -1,160 +0,0 @@
/**
* Uniswap v3 pool stats — TVL / 24h volume / 24h fees / APR.
*
* Источник: GeckoTerminal API (бесплатный, БЕЗ ключа — та же on-chain data network что у Uniswap).
* Раньше использовался The Graph gateway, но он требует платный THEGRAPH_API_KEY (hosted service
* закрыт) → без ключа возвращал null. GeckoTerminal не требует авторизации.
*
* fees24h выводится из volume24h × feeTier (не полагаемся на отдельное поле GT).
* APR = fees24h / TVL × 365 × 100.
*
* Fallback (если GeckoTerminal недоступен): on-chain TVL = balanceOf(token0/token1) × USD-цена из
* price-oracle (CoinGecko). Объём/комиссии/APR в фолбэке остаются null (on-chain недоступны дёшево).
*
* Только чтение, НИКОГДА не влияет на суммы транзакций. KeyDB-кэш 5 мин. Всё graceful → null-поля.
*/
import { ethers } from 'ethers';
import { getRedis } from '../config/redis';
import { logger } from './logger';
import { ETH_RPCS, UNIV3_POOL_ABI, ERC20_META_ABI } from './defi-contracts';
import { pickProxiedEvmProvider } from './outbound-proxy';
import { getPricesBySymbols } from '../services/price-oracle.service';
const GECKOTERMINAL_POOL_URL = (addr: string) =>
`https://api.geckoterminal.com/api/v2/networks/eth/pools/${addr}`;
const CACHE_TTL_SEC = 300;
const HTTP_TIMEOUT_MS = 8000;
export interface PoolStats {
poolAddress: string;
totalValueLockedUSD: number | null;
volumeUSD24h: number | null;
feesUSD24h: number | null;
aprPercent: number | null;
}
/**
* Статистика пула (с кэшем). null-поля если источники недоступны.
* @param feeTier uint24 fee пула (напр. 3000 = 0.3%) — нужен для расчёта fees/APR. Если не задан,
* fees/APR останутся null.
*/
export async function getPoolStats(poolAddress: string, feeTier?: number): Promise<PoolStats> {
const id = poolAddress.toLowerCase();
const cacheKey = `univ3:poolstats:${id}`;
try {
const cached = await getRedis().get(cacheKey);
if (cached) return JSON.parse(cached) as PoolStats;
} catch { /* ignore cache errors */ }
const empty: PoolStats = {
poolAddress: id,
totalValueLockedUSD: null,
volumeUSD24h: null,
feesUSD24h: null,
aprPercent: null,
};
let stats = empty;
// ── Primary: GeckoTerminal ──
const gt = await fetchGeckoTerminal(id, feeTier);
if (gt) {
stats = gt;
} else {
// ── Fallback: on-chain TVL (balances × prices) ──
const onchainTvl = await fetchOnChainTvl(id).catch(() => null);
if (onchainTvl !== null) {
stats = { ...empty, totalValueLockedUSD: onchainTvl };
}
}
try {
await getRedis().set(cacheKey, JSON.stringify(stats), 'EX', CACHE_TTL_SEC);
} catch { /* ignore */ }
return stats;
}
async function fetchGeckoTerminal(id: string, feeTier?: number): Promise<PoolStats | null> {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
try {
const res = await fetch(GECKOTERMINAL_POOL_URL(id), {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!res.ok) {
logger.warn(`GeckoTerminal HTTP ${res.status} for pool ${id}`);
return null;
}
const j: any = await res.json();
const attr = j?.data?.attributes;
if (!attr) return null;
const tvl = num(attr.reserve_in_usd);
const vol24h = num(attr.volume_usd?.h24);
// fees = volume × feeRate. feeTier uint24: 3000 = 0.3% = 3000/1e6.
const fees24h = vol24h !== null && feeTier ? vol24h * (feeTier / 1_000_000) : null;
const apr = fees24h !== null && tvl && tvl > 0 ? (fees24h / tvl) * 365 * 100 : null;
return {
poolAddress: id,
totalValueLockedUSD: tvl,
volumeUSD24h: vol24h,
feesUSD24h: fees24h,
aprPercent: apr,
};
} catch (err: any) {
logger.warn(`GeckoTerminal fetch failed for ${id}: ${err?.message}`);
return null;
} finally {
clearTimeout(t);
}
}
/** Best-effort on-chain TVL: balanceOf(token0)+balanceOf(token1) × USD-цена по символу. */
async function fetchOnChainTvl(poolAddr: string): Promise<number | null> {
const provider = await pickProxiedEvmProvider(ETH_RPCS, 1);
const pool = new ethers.Contract(poolAddr, UNIV3_POOL_ABI, provider);
const [token0, token1] = await Promise.all([
pool.token0() as Promise<string>,
pool.token1() as Promise<string>,
]);
const t0 = new ethers.Contract(token0, ERC20_META_ABI, provider);
const t1 = new ethers.Contract(token1, ERC20_META_ABI, provider);
const [dec0, dec1, sym0, sym1, bal0, bal1] = await Promise.all([
t0.decimals() as Promise<number>,
t1.decimals() as Promise<number>,
t0.symbol().catch(() => '') as Promise<string>,
t1.symbol().catch(() => '') as Promise<string>,
t0.balanceOf(poolAddr) as Promise<ethers.BigNumber>,
t1.balanceOf(poolAddr) as Promise<ethers.BigNumber>,
]);
const prices = await getPricesBySymbols([
{ chain: 'ETH', symbol: normSym(sym0) },
{ chain: 'ETH', symbol: normSym(sym1) },
]);
const p0 = prices.get(`ETH:${normSym(sym0)}`);
const p1 = prices.get(`ETH:${normSym(sym1)}`);
if (p0 == null && p1 == null) return null;
const human = (bal: ethers.BigNumber, dec: number) => Number(ethers.utils.formatUnits(bal, dec));
let tvl = 0;
if (p0 != null) tvl += human(bal0, Number(dec0)) * p0;
if (p1 != null) tvl += human(bal1, Number(dec1)) * p1;
return Number.isFinite(tvl) && tvl > 0 ? tvl : null;
}
/** WETH→ETH для резолва цены через token-registry (WETH часто не зарегистрирован отдельно). */
function normSym(sym: string): string {
const s = (sym || '').toUpperCase();
return s === 'WETH' ? 'ETH' : s;
}
function num(v: unknown): number | null {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}

View File

@@ -1,133 +0,0 @@
/**
* Uniswap v3 concentrated-liquidity математика на BigInt (без внешнего SDK).
*
* - getSqrtRatioAtTick — каноничный TickMath (точный, magic constants как в v3-core).
* - priceToTick — человеческая цена (token1/token0) → tick (приблизительно, затем align).
* - getLiquidityForAmounts / getAmountsForLiquidity — каноничный LiquidityAmounts.
*/
export const Q96 = 2n ** 96n;
export const MIN_TICK = -887272;
export const MAX_TICK = 887272;
/**
* TickMath.getSqrtRatioAtTick — sqrtPriceX96 = floor(sqrt(1.0001^tick) * 2^96).
* Точная битовая реализация (как в Uniswap v3-core).
*/
export function getSqrtRatioAtTick(tick: number): bigint {
if (tick < MIN_TICK || tick > MAX_TICK) throw new Error(`tick ${tick} out of range`);
const absTick = BigInt(tick < 0 ? -tick : tick);
let ratio = (absTick & 0x1n) !== 0n
? 0xfffcb933bd6fad37aa2d162d1a594001n
: 0x100000000000000000000000000000000n;
const m = (c: bigint) => { ratio = (ratio * c) >> 128n; };
if ((absTick & 0x2n) !== 0n) m(0xfff97272373d413259a46990580e213an);
if ((absTick & 0x4n) !== 0n) m(0xfff2e50f5f656932ef12357cf3c7fdccn);
if ((absTick & 0x8n) !== 0n) m(0xffe5caca7e10e4e61c3624eaa0941cd0n);
if ((absTick & 0x10n) !== 0n) m(0xffcb9843d60f6159c9db58835c926644n);
if ((absTick & 0x20n) !== 0n) m(0xff973b41fa98c081472e6896dfb254c0n);
if ((absTick & 0x40n) !== 0n) m(0xff2ea16466c96a3843ec78b326b52861n);
if ((absTick & 0x80n) !== 0n) m(0xfe5dee046a99a2a811c461f1969c3053n);
if ((absTick & 0x100n) !== 0n) m(0xfcbe86c7900a88aedcffc83b479aa3a4n);
if ((absTick & 0x200n) !== 0n) m(0xf987a7253ac413176f2b074cf7815e54n);
if ((absTick & 0x400n) !== 0n) m(0xf3392b0822b70005940c7a398e4b70f3n);
if ((absTick & 0x800n) !== 0n) m(0xe7159475a2c29b7443b29c7fa6e889d9n);
if ((absTick & 0x1000n) !== 0n) m(0xd097f3bdfd2022b8845ad8f792aa5825n);
if ((absTick & 0x2000n) !== 0n) m(0xa9f746462d870fdf8a65dc1f90e061e5n);
if ((absTick & 0x4000n) !== 0n) m(0x70d869a156d2a1b890bb3df62baf32f7n);
if ((absTick & 0x8000n) !== 0n) m(0x31be135f97d08fd981231505542fcfa6n);
if ((absTick & 0x10000n) !== 0n) m(0x9aa508b5b7a84e1c677de54f3e99bc9n);
if ((absTick & 0x20000n) !== 0n) m(0x5d6af8dedb81196699c329225ee604n);
if ((absTick & 0x40000n) !== 0n) m(0x2216e584f5fa1ea926041bedfe98n);
if ((absTick & 0x80000n) !== 0n) m(0x48a170391f7dc42444e8fa2n);
if (tick > 0) ratio = ((2n ** 256n) - 1n) / ratio;
// ratio is Q128.128 → sqrtPriceX96 (Q96): shift right 32, round up
const shifted = ratio >> 32n;
return (ratio & ((1n << 32n) - 1n)) === 0n ? shifted : shifted + 1n;
}
/** Человеческая цена token1/token0 → tick (приблизительно). Затем выравнивай alignTick. */
export function priceToTick(humanPrice: number, dec0: number, dec1: number): number {
if (!(humanPrice > 0)) throw new Error('price must be > 0');
const priceRaw = humanPrice * Math.pow(10, dec1 - dec0);
const tick = Math.round(Math.log(priceRaw) / Math.log(1.0001));
return Math.min(Math.max(tick, MIN_TICK), MAX_TICK);
}
/** Tick → человеческая цена token1/token0 (для отображения). */
export function tickToPrice(tick: number, dec0: number, dec1: number): number {
const priceRaw = Math.pow(1.0001, tick);
return priceRaw * Math.pow(10, dec0 - dec1);
}
/** sqrtPriceX96 → человеческая цена token1/token0. */
export function sqrtPriceX96ToPrice(sqrtPriceX96: bigint, dec0: number, dec1: number): number {
const ratio = Number(sqrtPriceX96) / Number(Q96);
const priceRaw = ratio * ratio;
return priceRaw * Math.pow(10, dec0 - dec1);
}
export function alignTick(tick: number, spacing: number): number {
const aligned = Math.round(tick / spacing) * spacing;
return Math.min(Math.max(aligned, MIN_TICK), MAX_TICK);
}
// ── LiquidityAmounts ───────────────────────────────────────────────────
function sortSqrt(a: bigint, b: bigint): [bigint, bigint] {
return a > b ? [b, a] : [a, b];
}
export function getLiquidityForAmount0(sqrtA: bigint, sqrtB: bigint, amount0: bigint): bigint {
[sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB);
const intermediate = (sqrtA * sqrtB) / Q96;
return (amount0 * intermediate) / (sqrtB - sqrtA);
}
export function getLiquidityForAmount1(sqrtA: bigint, sqrtB: bigint, amount1: bigint): bigint {
[sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB);
return (amount1 * Q96) / (sqrtB - sqrtA);
}
export function getLiquidityForAmounts(
sqrtP: bigint, sqrtA: bigint, sqrtB: bigint, amount0: bigint, amount1: bigint,
): bigint {
[sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB);
if (sqrtP <= sqrtA) return getLiquidityForAmount0(sqrtA, sqrtB, amount0);
if (sqrtP < sqrtB) {
const l0 = getLiquidityForAmount0(sqrtP, sqrtB, amount0);
const l1 = getLiquidityForAmount1(sqrtA, sqrtP, amount1);
return l0 < l1 ? l0 : l1;
}
return getLiquidityForAmount1(sqrtA, sqrtB, amount1);
}
export function getAmount0ForLiquidity(sqrtA: bigint, sqrtB: bigint, L: bigint): bigint {
[sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB);
if (sqrtA <= 0n) throw new Error('sqrt price must be positive'); // guard div-by-zero
return (((L << 96n) * (sqrtB - sqrtA)) / sqrtB) / sqrtA;
}
export function getAmount1ForLiquidity(sqrtA: bigint, sqrtB: bigint, L: bigint): bigint {
[sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB);
return (L * (sqrtB - sqrtA)) / Q96;
}
export function getAmountsForLiquidity(
sqrtP: bigint, sqrtA: bigint, sqrtB: bigint, L: bigint,
): { amount0: bigint; amount1: bigint } {
[sqrtA, sqrtB] = sortSqrt(sqrtA, sqrtB);
if (sqrtP <= sqrtA) {
return { amount0: getAmount0ForLiquidity(sqrtA, sqrtB, L), amount1: 0n };
}
if (sqrtP < sqrtB) {
return {
amount0: getAmount0ForLiquidity(sqrtP, sqrtB, L),
amount1: getAmount1ForLiquidity(sqrtA, sqrtP, L),
};
}
return { amount0: 0n, amount1: getAmount1ForLiquidity(sqrtA, sqrtB, L) };
}

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, getCsrfConfigSummary } from '../services/csrf.service'; import { verifyCsrfToken, isCsrfConfigured } 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,6 +28,15 @@ 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');
@@ -57,13 +66,7 @@ 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) {
const sigDiag = logger.warn(`CSRF validation failed: ${result.reason}`);
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

@@ -1,12 +0,0 @@
import { Router } from 'express';
import { scanApprovals, revokeApproval, revokeAllApprovals } from '../controllers/approvals.controller';
const router = Router();
// Mounted at /api/wallets — пути /:chain/approvals* не пересекаются с wallet/staking/lp routes.
// IMPORTANT: более специфичные /:chain/approvals/revoke* раньше /:chain/approvals.
router.post('/:chain/approvals/revoke-all', revokeAllApprovals);
router.post('/:chain/approvals/revoke', revokeApproval);
router.get('/:chain/approvals', scanApprovals);
export default router;

View File

@@ -1,266 +0,0 @@
/**
* 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

@@ -0,0 +1,218 @@
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

@@ -1,503 +0,0 @@
/**
* 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';
import { computeAppFee } from '../lib/app-fee';
import { formatSmallestUnits } from '../lib/amount-units';
const router = Router();
const LIFI_API_URL = 'https://li.quest/v1';
const LIFI_TIMEOUT_MS = 20_000;
/**
* Вычисляет нашу app-комиссию 0.7% (в токене + USDT) из LiFi-ответа (action.fromToken + fromAmount,
* estimate.fromAmountUSD). Чисто информативно (показать в quote) — сбор не трогаем (0.7% берётся
* атомарно в /bridge/execute), поэтому LiFi fee-параметры НЕ инжектим (иначе двойное списание).
*/
function computeJumperAppCommission(body: any): {
ratePercent: number;
inToken: { symbol: string | null; amount: string | null };
usd: number | null;
} | null {
const act = body?.action;
const est = body?.estimate;
const fromToken = act?.fromToken;
const fromAmount = act?.fromAmount ?? est?.fromAmount;
if (!fromToken || fromAmount == null) return null;
let amountToken: string | null = null;
try {
const dec = Number(fromToken.decimals ?? 0);
const feeSmallest = computeAppFee(String(fromAmount)); // fromAmount × 0.7%
amountToken = formatSmallestUnits(feeSmallest.toString(), dec);
} catch { amountToken = null; }
const fromUsd = Number(est?.fromAmountUSD);
const usd = Number.isFinite(fromUsd) ? Math.round(fromUsd * 0.007 * 1e6) / 1e6 : null;
return {
ratePercent: 0.7,
inToken: { symbol: fromToken.symbol ?? null, amount: amountToken },
usd,
};
}
/**
* 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 {
// /quote — доинжектим вычисляемую appCommission (наша 0.7% в токене + USDT) для видимости.
if (jumperPath === '/quote') {
try {
const obj = JSON.parse(text);
obj.appCommission = computeJumperAppCommission(obj);
res.send(JSON.stringify(obj));
} catch {
res.send(text); // не JSON — passthrough
}
return;
}
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}`);
// reason = код/имя сетевой ошибки (ECONNREFUSED/ENOTFOUND/UND_ERR_*) без хоста/IP/кредов прокси.
const reason = error?.cause?.code ?? error?.code ?? error?.name ?? 'unknown';
res.status(502).json({ success: false, error: 'Jumper proxy error', reason });
}
}
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', appCommission: computeJumperAppCommission(nearRes.body) });
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', appCommission: computeJumperAppCommission(bestRes.body) });
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}`);
// reason = код/имя сетевой ошибки (ECONNREFUSED/ENOTFOUND/UND_ERR_*) без хоста/IP/кредов прокси.
const reason = error?.cause?.code ?? error?.code ?? error?.name ?? 'unknown';
res.status(502).json({ success: false, error: 'Quote-best failed', reason });
}
}

View File

@@ -1,13 +0,0 @@
import { Router } from 'express';
import { lpPools, lpQuote, lpAdd, lpRemove, lpPositions } from '../controllers/lp.controller';
const router = Router();
// Mounted at /api/wallets — пути /:chain/lp/* не пересекаются с wallet/staking routes.
router.get('/:chain/lp/pools', lpPools);
router.post('/:chain/lp/quote', lpQuote);
router.post('/:chain/lp/add', lpAdd);
router.post('/:chain/lp/remove', lpRemove);
router.get('/:chain/lp/positions', lpPositions);
export default router;

View File

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

View File

@@ -3,42 +3,11 @@ 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, formatSmallestUnits } from '../lib/amount-units';
import { computeAppFee } from '../lib/app-fee';
const router = Router(); const router = Router();
const RELAY_API_URL = 'https://api.relay.link'; const RELAY_API_URL = 'https://api.relay.link';
const RELAY_TIMEOUT_MS = 20_000; const RELAY_TIMEOUT_MS = 20_000;
/**
* Вычисляет нашу app-комиссию 0.7% (в токене + USDT) из Relay-ответа `details.currencyIn`.
* Чисто информативно (показать в quote/cost-estimate) — сбор комиссии не трогаем (отдельная /app-fee tx),
* поэтому НЕ инжектим Relay appFees (иначе было бы двойное списание).
*/
function computeRelayAppCommission(full: any): {
ratePercent: number;
inToken: { symbol: string | null; amount: string | null };
usd: number | null;
} | null {
const cIn = full?.details?.currencyIn;
if (!cIn || cIn.amount == null) return null;
let amountToken: string | null = null;
try {
const dec = Number(cIn.currency?.decimals ?? 0);
const feeSmallest = computeAppFee(String(cIn.amount)); // amount × 0.7%
amountToken = formatSmallestUnits(feeSmallest.toString(), dec);
} catch { amountToken = null; }
const inUsd = Number(cIn.amountUsd);
const usd = Number.isFinite(inUsd) ? Math.round(inUsd * 0.007 * 1e6) / 1e6 : null;
return {
ratePercent: 0.7,
inToken: { symbol: cIn.currency?.symbol ?? null, amount: amountToken },
usd,
};
}
// chainId → ChainCode. Relay использует EVM chainIds + custom большие для не-EVM. // chainId → ChainCode. Relay использует EVM chainIds + custom большие для не-EVM.
const RELAY_CHAINID_TO_CHAIN: Record<number, ChainCode> = { const RELAY_CHAINID_TO_CHAIN: Record<number, ChainCode> = {
1: 'ETH', 1: 'ETH',
@@ -50,9 +19,7 @@ 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']);
// `/cost-estimate` — LOCAL alias (not a Relay endpoint). Internally calls Relay /quote и const ALLOWED_POST_PATHS = new Set(['/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',
@@ -86,14 +53,10 @@ 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 === '/cost-estimate' || relayPath.startsWith('/execute/'))) { if (req.method === 'POST' && (relayPath === '/quote' || 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' });
@@ -149,45 +112,7 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
} }
} }
// ADDITIVE: amountHuman preprocessing для /quote, /cost-estimate, /execute/*. const relayUrl = new URL(`${RELAY_API_URL}${relayPath}`);
// Если 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)) {
@@ -205,9 +130,7 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
let upstream: globalThis.Response; let upstream: globalThis.Response;
try { try {
// Через OUTBOUND_PROXY_URL если задан (bridge path) — Relay calls идут через proxy. upstream = await fetch(relayUrl.toString(), {
// Fallback на native fetch если env пустой.
upstream = await proxiedFetch(relayUrl.toString(), {
method: req.method, method: req.method,
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@@ -242,77 +165,11 @@ 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 },
},
// Наша app-комиссия 0.7% в токене + USDT (информативно; собирается отдельной /app-fee tx).
appCommission: computeRelayAppCommission(full),
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;
}
// /quote — доинжектим вычисляемую appCommission (наша 0.7% в токене + USDT) для видимости в Swagger.
// Остальное (steps[], fees, details) проходит без изменений. Сбор комиссии не меняется.
if (relayPath === '/quote') {
try {
const obj = JSON.parse(text);
obj.appCommission = computeRelayAppCommission(obj);
res.send(JSON.stringify(obj));
} catch {
res.send(text); // не JSON — passthrough как есть
}
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') {
@@ -320,8 +177,6 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
return; return;
} }
logger.error(`Relay proxy failed: ${error?.stack || error?.message}`); logger.error(`Relay proxy failed: ${error?.stack || error?.message}`);
// reason = код/имя сетевой ошибки (ECONNREFUSED/ENOTFOUND/UND_ERR_*) без хоста/IP/кредов прокси. res.status(502).json({ success: false, error: 'Relay proxy error' });
const reason = error?.cause?.code ?? error?.code ?? error?.name ?? 'unknown';
res.status(502).json({ success: false, error: 'Relay proxy error', reason });
} }
} }

View File

@@ -0,0 +1,210 @@
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

@@ -1,20 +0,0 @@
import { Router } from 'express';
import {
stakeQuote,
stakeExecute,
stakePositions,
unstakeExecute,
unstakeClaim,
} from '../controllers/staking.controller';
const router = Router();
// Mounted at /api/wallets — paths не пересекаются с wallet.routes (нет /:chain/stake там).
// IMPORTANT: более специфичные пути раньше: /:chain/stake/quote ПЕРЕД /:chain/stake.
router.post('/:chain/stake/quote', stakeQuote);
router.post('/:chain/stake', stakeExecute);
router.get('/:chain/stake/positions', stakePositions);
router.post('/:chain/unstake/claim', unstakeClaim);
router.post('/:chain/unstake', unstakeExecute);
export default router;

View File

@@ -1,66 +0,0 @@
/**
* 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,7 +209,6 @@ 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

@@ -0,0 +1,499 @@
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

@@ -3,29 +3,15 @@ import { WalletController } from '../controllers/wallet.controller';
const router = Router(); const router = Router();
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/swap', WalletController.swapOnChain);
// Read-only «стоимость операции» (наша 0.7% в native+USD + проверка баланса токена/газа) для свапа/бриджа.
router.post('/:chain/op-cost', WalletController.opCost);
router.post('/:chain/app-fee', WalletController.appFeeTransfer);
router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx); 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,50 +1,31 @@
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 validation compatible with Python `itsdangerous` URLSafeTimedSerializer (Flask-WTF). * CSRF token validation compatible with Python's `itsdangerous`
* Token: <b64url_payload>.<b64url_timestamp>.<b64url_signature> * `URLSafeTimedSerializer` (which Flask-WTF uses).
* *
* Vault: read-only. `secret_key` обязателен; `salt`/`digest` опциональны (дефолты при отсутствии). * Token format: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
* *
* itsdangerous 2.x по умолчанию: key_derivation=django-concat, digest=sha1. * Default algorithm (itsdangerous ≥ 2.0):
* Старый Node-код использовал legacy-hmac-signer — из-за этого prod 403 при верном secret_key. * - digest: SHA-512 (HMAC)
* - salt: "itsdangerous.Signer" (or app-specific, e.g. "csrf-token")
* - derived_key = HMAC(secret, salt + "signer").digest()
* - signature = HMAC(derived_key, payload + "." + timestamp).digest()
*
* Timestamp: seconds since 2011-01-01 (itsdangerous epoch), base64url-encoded big-endian.
*/ */
const ITSDANGEROUS_EPOCH = 1293840000; const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time
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 {
@@ -55,51 +36,9 @@ export function isCsrfConfigured(): boolean {
return current !== null; return current !== null;
} }
export function csrfSecretFingerprint(secret: string): string { /**
return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 8); * Pre-fetch CSRF config из Vault — НЕ мутирует глобал, возвращает новый объект.
} */
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,
@@ -117,27 +56,24 @@ 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');
} }
let saltFromVault = false; const salt = secrets.salt || 'itsdangerous.Signer';
let salt = DEFAULT_SALT; if (typeof salt !== 'string' || salt.length < 8) {
if (secrets.salt && typeof secrets.salt === 'string' && secrets.salt.length >= 1) { throw new Error('CSRF salt invalid: must be string >= 8 chars');
salt = secrets.salt;
saltFromVault = true;
} }
let digestFromVault = false; // sha1 deprecated — accept только sha256/sha512.
let digest: 'sha256' | 'sha512' = 'sha256'; let digest: 'sha256' | 'sha512' = 'sha512';
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; let maxAgeSec = 60 * 60 * 24 * 7; // 7 days
if (secrets.max_age_sec) { if (secrets.max_age_sec) {
const n = parseInt(String(secrets.max_age_sec), 10); const n = parseInt(secrets.max_age_sec);
if (!Number.isNaN(n) && n > 0) maxAgeSec = n; if (!Number.isNaN(n) && n > 0) maxAgeSec = n;
} }
return { secret, salt, digest, maxAgeSec, saltFromVault, digestFromVault }; return { secret, salt, digest, maxAgeSec };
} }
function b64urlDecode(s: string): Buffer { function b64urlDecode(s: string): Buffer {
@@ -146,137 +82,26 @@ function b64urlDecode(s: string): Buffer {
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
} }
/** itsdangerous 2.x default: hash(salt + b"signer" + secret_key). */ function deriveKey(secret: string, salt: string, digest: string): Buffer {
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();
} }
export function deriveSigningKey( function decodeTimestamp(encoded: string): number {
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; return ts + ITSDANGEROUS_EPOCH;
}
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;
} }
type CsrfDigest = 'sha1' | 'sha256' | 'sha512'; export function verifyCsrfToken(token: string): CsrfVerifyResult {
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('.');
@@ -290,8 +115,8 @@ function verifyCsrfTokenWithParams(
const tsStr = payloadTs.slice(prevDot + 1); const tsStr = payloadTs.slice(prevDot + 1);
const derived = deriveSigningKey(cfg.secret, salt, digest, keyDerivation); const derived = deriveKey(current.secret, current.salt, current.digest);
const expectedSig = crypto.createHmac(digest, derived).update(payloadTs).digest(); const expectedSig = crypto.createHmac(current.digest, derived).update(payloadTs).digest();
let actualSig: Buffer; let actualSig: Buffer;
try { try {
@@ -301,122 +126,20 @@ function verifyCsrfTokenWithParams(
} }
if (expectedSig.length !== actualSig.length) { if (expectedSig.length !== actualSig.length) {
return { return { valid: false, reason: 'Signature length mismatch' };
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' };
} }
const tsResult = verifyCsrfTimestamp(tsStr, cfg.maxAgeSec);
if (!tsResult.ok) {
return { valid: false, reason: tsResult.reason };
}
return { valid: true };
}
function saltsToTry(vaultSalt: string): string[] {
const out: string[] = [];
const add = (s: string, minLen = MIN_VERIFY_SALT_LEN) => {
if (s && s.length >= minLen && !out.includes(s)) out.push(s);
};
add(vaultSalt, 8);
for (const s of LEGACY_VERIFY_SALTS) add(s);
return out;
}
function digestsToTry(primary: CsrfVerifyResult, vaultDigest: CsrfDigest): CsrfDigest[] {
const order: CsrfDigest[] = [];
const add = (d: CsrfDigest) => {
if (!order.includes(d)) order.push(d);
};
add(vaultDigest);
if (primary.actualSigLen !== undefined) {
const inferred = DIGEST_BY_SIG_LEN[primary.actualSigLen];
if (inferred) add(inferred);
}
for (const d of ALL_DIGESTS) add(d);
return order;
}
function derivationsToTry(primary: CsrfVerifyResult): CsrfKeyDerivation[] {
const order: CsrfKeyDerivation[] = [...KEY_DERIVATIONS];
if (primary.actualSigLen === 20) {
// Prod auth: itsdangerous 2.x + sha1 → django-concat первым.
return ['django-concat', ...order.filter((d) => d !== 'django-concat')];
}
return order;
}
function isRetryableVerifyFailure(reason?: string): boolean {
return reason === 'Signature length mismatch' || reason === 'Signature mismatch';
}
/**
* Verify: django-concat (itsdangerous 2.x) + legacy matrix (salt × digest × key_derivation).
*/
function inferSigLenFromToken(token: string): number | undefined {
const lastDot = token.lastIndexOf('.');
if (lastDot < 0) return undefined;
try { try {
return b64urlDecode(token.slice(lastDot + 1)).length; const issuedAt = decodeTimestamp(tsStr);
const now = Math.floor(Date.now() / 1000);
if (issuedAt > now + 60) return { valid: false, reason: 'Token from the future' };
if (now - issuedAt > current.maxAgeSec) return { valid: false, reason: 'Token expired' };
} catch { } catch {
return undefined; return { valid: false, reason: 'Invalid timestamp' };
} }
}
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 }; 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,13 +24,9 @@ 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). 0 = without floor (use raw eth_feeHistory value). // Realistic mainnet floors (gwei).
// 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', ETH: '0.5',
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, logCsrfConfigLoaded } from './csrf.service'; import { fetchCsrfConfig, swapCsrfConfig } 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,7 +83,6 @@ 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

@@ -1,14 +1,9 @@
/** /**
* USD price oracle for wallet balance responses + 24h change percentage. * USD price oracle for wallet balance responses.
* *
* Data source: CoinGecko free API (https://api.coingecko.com/api/v3/simple/price). * Data source: CoinGecko free API (https://api.coingecko.com/api/v3/simple/price).
* Cache: KeyDB (Redis), TTL = 300s. * 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"): * Security (см. план §"Security checklist"):
* S1 — whitelist через getCoingeckoId → user input не попадает в URL. * S1 — whitelist через getCoingeckoId → user input не попадает в URL.
* S2 — лимит размеров вызовов через caller (controller `/prices`). * S2 — лимит размеров вызовов через caller (controller `/prices`).
@@ -30,35 +25,26 @@ const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price';
const CACHE_TTL_SECONDS = 300; const CACHE_TTL_SECONDS = 300;
const CACHE_KEY_PREFIX = 'price:'; const CACHE_KEY_PREFIX = 'price:';
const FETCH_TIMEOUT_MS = 5000; const FETCH_TIMEOUT_MS = 5000;
const MAX_IDS_PER_REQUEST = 100; const MAX_IDS_PER_REQUEST = 100; // CoinGecko allows ~250, мы консервативно 100.
export interface PriceWithChange {
usd: number;
change24h: number | null; // например -1.38 (= -1.38%), 0.06, null если CG не отдал
}
interface CachedPrice { interface CachedPrice {
usd: number; usd: number;
change24h: number | null;
ts: number; ts: number;
} }
/** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */ /** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */
const _inflight = new Map<string, Promise<Record<string, PriceWithChange | null>>>(); const _inflight = new Map<string, Promise<Record<string, number | null>>>();
function isValidPrice(n: unknown): n is number { function isValidPrice(n: unknown): n is number {
return typeof n === 'number' && Number.isFinite(n) && n >= 0; 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> { function buildHeaders(): Record<string, string> {
const headers: Record<string, string> = { Accept: 'application/json' }; const headers: Record<string, string> = { Accept: 'application/json' };
const key = process.env.COINGECKO_API_KEY; const key = process.env.COINGECKO_API_KEY;
if (key && key.length > 0) { if (key && key.length > 0) {
// CoinGecko Demo API key → `x-cg-demo-api-key`. Pro → `x-cg-pro-api-key`.
// Не печатаем header нигде, см. S9.
headers['x-cg-demo-api-key'] = key; headers['x-cg-demo-api-key'] = key;
} }
return headers; return headers;
@@ -66,10 +52,10 @@ function buildHeaders(): Record<string, string> {
/** /**
* Fetches CoinGecko /simple/price for a batch of coin ids. * Fetches CoinGecko /simple/price for a batch of coin ids.
* Now includes `include_24hr_change=true` — отдаёт usd_24h_change поле. * Internal — caller must ensure `ids.length > 0 && ids.length <= MAX_IDS_PER_REQUEST`.
*/ */
async function fetchCoingecko(ids: string[]): Promise<Record<string, PriceWithChange | null>> { async function fetchCoingecko(ids: string[]): Promise<Record<string, number | null>> {
const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd&include_24hr_change=true`; const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd`;
const ctrl = new AbortController(); const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
try { try {
@@ -78,29 +64,22 @@ async function fetchCoingecko(ids: string[]): Promise<Record<string, PriceWithCh
headers: buildHeaders(), headers: buildHeaders(),
}); });
if (!res.ok) { if (!res.ok) {
// S5: не логируем URL целиком (содержит query string).
logger.warn(`CoinGecko HTTP ${res.status} for ${ids.length} ids`); logger.warn(`CoinGecko HTTP ${res.status} for ${ids.length} ids`);
const out: Record<string, PriceWithChange | null> = {}; const out: Record<string, number | null> = {};
for (const id of ids) out[id] = null; for (const id of ids) out[id] = null;
return out; return out;
} }
const json = (await res.json()) as Record<string, { usd?: unknown; usd_24h_change?: unknown }>; const json = (await res.json()) as Record<string, { usd?: unknown }>;
const out: Record<string, PriceWithChange | null> = {}; const out: Record<string, number | null> = {};
for (const id of ids) { for (const id of ids) {
const usd = json?.[id]?.usd; const usd = json?.[id]?.usd;
const change = json?.[id]?.usd_24h_change; out[id] = isValidPrice(usd) ? usd : null;
if (isValidPrice(usd)) {
out[id] = {
usd,
change24h: isValidChange(change) ? change : null,
};
} else {
out[id] = null;
}
} }
return out; return out;
} catch (err: any) { } catch (err: any) {
logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`); logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`);
const out: Record<string, PriceWithChange | null> = {}; const out: Record<string, number | null> = {};
for (const id of ids) out[id] = null; for (const id of ids) out[id] = null;
return out; return out;
} finally { } finally {
@@ -109,25 +88,25 @@ async function fetchCoingecko(ids: string[]): Promise<Record<string, PriceWithCh
} }
/** /**
* Возвращает USD-цены + 24h change для списка CoinGecko ids. * Возвращает USD-цены для списка CoinGecko ids.
* Никогда не throws — degrades to `null` per-id. * Никогда не throws — degrades to `null` per-id.
* *
* Cache: read-through KeyDB, 300s TTL. Только валидные usd кэшируются (S12). * Cache: read-through KeyDB, 300s TTL. Только валидные числа кэшируются (S12).
* Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4). * Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4).
*/ */
export async function getPricesWithChangeByIds( export async function getPricesByIds(ids: string[]): Promise<Record<string, number | null>> {
ids: string[],
): Promise<Record<string, PriceWithChange | null>> {
if (!Array.isArray(ids) || ids.length === 0) return {}; if (!Array.isArray(ids) || ids.length === 0) return {};
// Дедупликация ids (на случай если caller передал duplicates).
const uniqIds = Array.from(new Set(ids.filter((x) => typeof x === 'string' && x.length > 0))); const uniqIds = Array.from(new Set(ids.filter((x) => typeof x === 'string' && x.length > 0)));
if (uniqIds.length === 0) return {}; if (uniqIds.length === 0) return {};
const result: Record<string, PriceWithChange | null> = {}; const result: Record<string, number | null> = {};
let redis: ReturnType<typeof getRedis> | null = null; let redis: ReturnType<typeof getRedis> | null = null;
try { try {
redis = getRedis(); redis = getRedis();
} catch { } catch {
// Redis singleton недоступен — продолжаем без cache, сразу идём в CG.
redis = null; redis = null;
} }
@@ -145,10 +124,7 @@ export async function getPricesWithChangeByIds(
try { try {
const parsed = JSON.parse(raw) as CachedPrice; const parsed = JSON.parse(raw) as CachedPrice;
if (isValidPrice(parsed?.usd)) { if (isValidPrice(parsed?.usd)) {
result[id] = { result[id] = parsed.usd;
usd: parsed.usd,
change24h: isValidChange(parsed?.change24h) ? parsed.change24h : null,
};
return; return;
} }
} catch { } catch {
@@ -159,6 +135,7 @@ export async function getPricesWithChangeByIds(
}); });
} catch (err: any) { } catch (err: any) {
logger.warn(`Redis cache read failed: ${err?.message || 'unknown'}`); logger.warn(`Redis cache read failed: ${err?.message || 'unknown'}`);
// Cache miss for ALL ids — degrade to upstream fetch.
for (const id of uniqIds) { for (const id of uniqIds) {
if (!(id in result)) misses.push(id); if (!(id in result)) misses.push(id);
} }
@@ -169,8 +146,8 @@ export async function getPricesWithChangeByIds(
if (misses.length === 0) return result; if (misses.length === 0) return result;
// 2) Fetch misses в batches + in-flight dedup (S4). // 2) Fetch misses в batches (S2-style guard) + in-flight dedup (S4).
const fetched: Record<string, PriceWithChange | null> = {}; const fetched: Record<string, number | null> = {};
for (let i = 0; i < misses.length; i += MAX_IDS_PER_REQUEST) { for (let i = 0; i < misses.length; i += MAX_IDS_PER_REQUEST) {
const batch = misses.slice(i, i + MAX_IDS_PER_REQUEST); const batch = misses.slice(i, i + MAX_IDS_PER_REQUEST);
const batchKey = batch.join('|'); const batchKey = batch.join('|');
@@ -190,14 +167,10 @@ export async function getPricesWithChangeByIds(
const setP = redis.pipeline(); const setP = redis.pipeline();
let writes = 0; let writes = 0;
for (const [id, val] of Object.entries(fetched)) { for (const [id, val] of Object.entries(fetched)) {
if (val && isValidPrice(val.usd)) { if (isValidPrice(val)) {
setP.set( setP.set(
CACHE_KEY_PREFIX + id, CACHE_KEY_PREFIX + id,
JSON.stringify({ JSON.stringify({ usd: val, ts: Date.now() } satisfies CachedPrice),
usd: val.usd,
change24h: val.change24h,
ts: Date.now(),
} satisfies CachedPrice),
'EX', 'EX',
CACHE_TTL_SECONDS, CACHE_TTL_SECONDS,
); );
@@ -206,6 +179,7 @@ export async function getPricesWithChangeByIds(
} }
if (writes > 0) await setP.exec(); if (writes > 0) await setP.exec();
} catch (err: any) { } catch (err: any) {
// Cache write failure → не критично, продолжаем.
logger.warn(`Redis cache write failed: ${err?.message || 'unknown'}`); logger.warn(`Redis cache write failed: ${err?.message || 'unknown'}`);
} }
} }
@@ -218,24 +192,14 @@ export async function getPricesWithChangeByIds(
return result; 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) парами. * Convenience-обёртка для callers которые оперируют (chain, symbol) парами.
* *
* Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null. * Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null.
* Ключ совпадает с тем что caller затем использует на lookup'е.
*
* Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful). * Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful).
* Никаких throw'ов, никаких побочек кроме cache writes.
*/ */
export async function getPricesBySymbols( export async function getPricesBySymbols(
pairs: { chain: ChainCode; symbol: string }[], pairs: { chain: ChainCode; symbol: string }[],
@@ -243,12 +207,13 @@ export async function getPricesBySymbols(
const out = new Map<string, number | null>(); const out = new Map<string, number | null>();
if (!Array.isArray(pairs) || pairs.length === 0) return out; if (!Array.isArray(pairs) || pairs.length === 0) return out;
// (chain:symbol) → coingeckoId | null
const pairToId = new Map<string, string | null>(); const pairToId = new Map<string, string | null>();
const idsToFetch = new Set<string>(); const idsToFetch = new Set<string>();
for (const { chain, symbol } of pairs) { for (const { chain, symbol } of pairs) {
const key = `${chain}:${symbol}`; const key = `${chain}:${symbol}`;
if (pairToId.has(key)) continue; if (pairToId.has(key)) continue; // dedup
const id = getCoingeckoId(chain, symbol); const id = getCoingeckoId(chain, symbol);
pairToId.set(key, id); pairToId.set(key, id);
if (id) idsToFetch.add(id); if (id) idsToFetch.add(id);
@@ -268,39 +233,3 @@ export async function getPricesBySymbols(
return out; 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;
}

View File

@@ -1,347 +0,0 @@
/**
* Native Solana staking — custodial (эквивалент Phantom).
*
* stake: SystemProgram.createAccount(stake) + StakeProgram.initialize + StakeProgram.delegate(validator)
* unstake: StakeProgram.deactivate → (cooldown 1-2 эпохи) → StakeProgram.withdraw
* positions: getParsedProgramAccounts(StakeProgram) по staker-authority = адрес юзера (без БД)
*
* Валидатор — авто-выбор (resolveStakeValidator: env override → Stakewiz → RPC → fallback). Stake-аккаунт —
* свеже-сгенерённый keypair (подписывает создание как extraSigner); его адрес возвращается и
* заново находится on-chain. App-fee 0.7% — нативный SOL-перевод в той же транзакции.
*/
import {
Connection,
PublicKey,
Keypair,
SystemProgram,
StakeProgram,
Authorized,
Lockup,
TransactionInstruction,
} from '@solana/web3.js';
import { getAppFeeWallet, computeAppFee } from '../lib/app-fee';
import { formatSmallestUnits } from '../lib/amount-units';
import { getPricesBySymbols } from './price-oracle.service';
import { signAndBroadcastSolBuilt } from './wallet-signer.service';
import { StakingError } from './staking.service';
import { resolveStakeValidator } from '../lib/sol-validator';
function badRequest(msg: string): StakingError {
return new StakingError(msg, 'BAD_REQUEST', 400);
}
/** SOL = 9 decimals (lamports → SOL human-строка). */
const sol = (lamports: string): string => formatSmallestUnits(lamports, 9);
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
let _conn: Connection | null = null;
function getConn(): Connection {
if (!_conn) _conn = new Connection(SOL_RPC, 'confirmed');
return _conn;
}
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
export interface SolStakeQuote {
amountLamports: string;
amountLamportsHuman: string;
appFeeLamports: string;
appFeeLamportsHuman: string;
appFeeUsd: number | null; // наша 0.7% в USDT (≈USD); null если цена SOL недоступна
stakeLamports: string;
stakeLamportsHuman: string;
validator: string;
estApyPercent: number;
}
/** Превью: fee + сумма после fee + валидатор. APY — приблизительная оценка сети (~6.5%). */
export async function quoteSolStake(amountLamports: string): Promise<SolStakeQuote> {
if (typeof amountLamports !== 'string' || !/^\d+$/.test(amountLamports) || amountLamports === '0') {
throw badRequest('amount must be a positive integer string (lamports)');
}
const fee = computeAppFee(amountLamports);
const stake = BigInt(amountLamports) - fee;
if (stake <= 0n) throw badRequest('amount too small after 0.7% fee');
const validator = await resolveStakeValidator(getConn());
// USDT-эквивалент комиссии (≈USD). Цена SOL через whitelist; null если недоступна.
let solPrice: number | null = null;
try { solPrice = (await getPricesBySymbols([{ chain: 'SOL', symbol: 'SOL' }])).get('SOL:SOL') ?? null; } catch { /* optional */ }
const appFeeHuman = sol(fee.toString());
const appFeeUsd = solPrice != null ? Math.round(Number(appFeeHuman) * solPrice * 1e6) / 1e6 : null;
return {
amountLamports,
amountLamportsHuman: sol(amountLamports),
appFeeLamports: fee.toString(),
appFeeLamportsHuman: appFeeHuman,
appFeeUsd,
stakeLamports: stake.toString(),
stakeLamportsHuman: sol(stake.toString()),
validator,
estApyPercent: 6.5,
};
}
export interface SolStakeResult {
stakeTxid: string;
stakeAccount: string;
stakeLamports: string;
stakeLamportsHuman: string;
appFeeLamports: string;
appFeeLamportsHuman: string;
validator: string;
}
/** stake: create+delegate в одной транзакции (+ app-fee). */
export async function executeSolStake(p: {
mnemonic: string;
expectedFromAddress: string;
amountLamports: string;
}): Promise<SolStakeResult> {
if (typeof p.amountLamports !== 'string' || !/^\d+$/.test(p.amountLamports) || p.amountLamports === '0') {
throw badRequest('amount must be a positive integer string (lamports)');
}
const fee = computeAppFee(p.amountLamports);
const stakeLamports = BigInt(p.amountLamports) - fee;
if (stakeLamports <= 0n) throw badRequest('amount too small after 0.7% fee');
if (stakeLamports > MAX_SAFE) throw badRequest('amount exceeds Number precision; split into multiple stakes');
const conn = getConn();
const rentExempt = BigInt(await conn.getMinimumBalanceForRentExemption(StakeProgram.space));
if (stakeLamports <= rentExempt) {
throw badRequest(`amount too small: must exceed rent-exempt reserve (${rentExempt} lamports) after fee`);
}
const user = new PublicKey(p.expectedFromAddress);
const validator = await resolveStakeValidator(conn);
const votePubkey = new PublicKey(validator);
const stakeAccount = Keypair.generate();
const instructions: TransactionInstruction[] = [];
// app fee 0.7% (native SOL)
if (fee > MAX_SAFE) throw badRequest('fee exceeds Number precision; amount too large');
if (fee > 0n) {
instructions.push(
SystemProgram.transfer({
fromPubkey: user,
toPubkey: new PublicKey(getAppFeeWallet('SOL')),
lamports: Number(fee),
}),
);
}
// create + initialize stake account (StakeProgram.createAccount возвращает Transaction)
const createTx = StakeProgram.createAccount({
fromPubkey: user,
stakePubkey: stakeAccount.publicKey,
authorized: new Authorized(user, user),
lockup: new Lockup(0, 0, PublicKey.default),
lamports: Number(stakeLamports),
});
instructions.push(...createTx.instructions);
// delegate к валидатору
const delegateTx = StakeProgram.delegate({
stakePubkey: stakeAccount.publicKey,
authorizedPubkey: user,
votePubkey,
});
instructions.push(...delegateTx.instructions);
const { signature } = await signAndBroadcastSolBuilt({
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
instructions,
extraSigners: [stakeAccount],
});
return {
stakeTxid: signature,
stakeAccount: stakeAccount.publicKey.toBase58(),
stakeLamports: stakeLamports.toString(),
stakeLamportsHuman: sol(stakeLamports.toString()),
appFeeLamports: fee.toString(),
appFeeLamportsHuman: sol(fee.toString()),
validator,
};
}
export interface SolUnstakeResult {
action: 'deactivate' | 'withdraw';
txid: string;
withdrawnLamports?: string;
withdrawnLamportsHuman?: string;
note?: string;
}
/**
* unstake: один эндпоинт, два состояния.
* active/activating → deactivate (дальше cooldown 1-2 эпохи)
* inactive → withdraw всех lamports на адрес юзера
* deactivating → ещё рано (cooldown)
*/
export async function executeSolUnstake(p: {
mnemonic: string;
expectedFromAddress: string;
stakeAccount: string;
}): Promise<SolUnstakeResult> {
let stakePubkey: PublicKey;
try {
stakePubkey = new PublicKey(p.stakeAccount);
} catch {
throw badRequest('invalid stakeAccount address');
}
const user = new PublicKey(p.expectedFromAddress);
const conn = getConn();
// ownership/authority guard: stake account должен иметь staker-authority = адрес юзера
await assertStakeOwnedByUser(conn, stakePubkey, p.expectedFromAddress);
const state = await readStakeState(conn, stakePubkey);
if (state === 'active' || state === 'activating') {
const tx = StakeProgram.deactivate({ stakePubkey, authorizedPubkey: user });
const { signature } = await signAndBroadcastSolBuilt({
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
instructions: tx.instructions,
});
return {
action: 'deactivate',
txid: signature,
note: 'Деактивация запущена. Через 1-2 эпохи (cooldown сети) вызови /unstake снова — выведет средства.',
};
}
if (state === 'deactivating') {
throw badRequest('stake is deactivating; wait 1-2 epochs (network cooldown), then call /unstake again to withdraw');
}
// inactive → withdraw
const acct = await conn.getAccountInfo(stakePubkey);
const lamports = acct?.lamports ?? 0;
if (lamports <= 0) throw badRequest('stake account empty or not found');
if (lamports > Number.MAX_SAFE_INTEGER) throw badRequest('stake balance exceeds safe integer limit');
const wtx = StakeProgram.withdraw({
stakePubkey,
authorizedPubkey: user,
toPubkey: user,
lamports,
});
const { signature } = await signAndBroadcastSolBuilt({
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
instructions: wtx.instructions,
});
return { action: 'withdraw', txid: signature, withdrawnLamports: String(lamports), withdrawnLamportsHuman: sol(String(lamports)) };
}
export interface SolStakePosition {
stakeAccount: string;
lamports: string;
lamportsHuman: string;
state: string;
validator: string | null;
delegatedLamports: string | null;
delegatedLamportsHuman: string | null;
}
export interface SolStakePositions {
protocol: 'native';
chain: 'SOL';
validator: string | null;
positions: SolStakePosition[];
}
/** Позиции: все stake-аккаунты с staker-authority = адрес юзера. */
export async function readSolStakePositions(address: string): Promise<SolStakePositions> {
const conn = getConn();
let user: PublicKey;
try {
user = new PublicKey(address);
} catch {
throw badRequest('invalid SOL address');
}
// Stake account layout: state(4) + Meta{ rentReserve(8) + Authorized{ staker(32)@12, withdrawer(32)@44 } ... }
const accounts = await conn.getParsedProgramAccounts(StakeProgram.programId, {
filters: [{ memcmp: { offset: 12, bytes: user.toBase58() } }],
});
const positions: SolStakePosition[] = [];
for (const a of accounts) {
const info: any = (a.account.data as any)?.parsed?.info;
const state = await readStakeState(conn, a.pubkey, info);
const delegated = info?.stake?.delegation?.stake ?? null;
positions.push({
stakeAccount: a.pubkey.toBase58(),
lamports: String(a.account.lamports),
lamportsHuman: sol(String(a.account.lamports)),
state,
validator: info?.stake?.delegation?.voter ?? null,
delegatedLamports: delegated,
delegatedLamportsHuman: delegated != null ? sol(String(delegated)) : null,
});
}
return {
protocol: 'native',
chain: 'SOL',
validator: await resolveStakeValidator(conn).catch(() => null),
positions,
};
}
/**
* Состояние стейка с fallback. getStakeActivation() есть в @solana/web3.js 1.98, но многие
* публичные RPC его не держат → fallback на разбор parsed account info (delegation).
*/
async function readStakeState(
conn: Connection,
stakePubkey: PublicKey,
parsedInfo?: any,
): Promise<string> {
try {
return (await conn.getStakeActivation(stakePubkey)).state;
} catch {
let info = parsedInfo;
if (!info) {
try {
const acc = await conn.getParsedAccountInfo(stakePubkey);
info = (acc.value?.data as any)?.parsed?.info;
} catch {
return 'unknown';
}
}
const deleg = info?.stake?.delegation;
if (!deleg) return info ? 'inactive' : 'unknown';
const MAX_U64 = '18446744073709551615';
const deactEpoch = String(deleg.deactivationEpoch ?? '');
const actEpoch = String(deleg.activationEpoch ?? '');
const hasStake = deleg.stake && BigInt(deleg.stake) > 0n;
// КРИТИЧНО: сравниваем эпоху деактивации/активации с ТЕКУЩЕЙ. Раньше любая
// deactivationEpoch != MAX давала 'deactivating' навсегда → withdraw был недостижим,
// средства застревали. Деактивация завершается после своей эпохи → дальше 'inactive' (выводим).
const { epoch } = await conn.getEpochInfo();
const cur = BigInt(epoch);
if (deactEpoch && deactEpoch !== MAX_U64) {
return cur > BigInt(deactEpoch) ? 'inactive' : 'deactivating';
}
if (actEpoch && actEpoch !== MAX_U64 && cur <= BigInt(actEpoch)) return 'activating';
return hasStake ? 'active' : 'inactive';
}
}
/** Проверка: staker-authority stake-аккаунта = адрес юзера (защита от unstake чужого аккаунта). */
async function assertStakeOwnedByUser(conn: Connection, stakePubkey: PublicKey, userAddress: string): Promise<void> {
const acc = await conn.getParsedAccountInfo(stakePubkey);
const info: any = (acc.value?.data as any)?.parsed?.info;
const staker = info?.meta?.authorized?.staker;
if (!staker) {
throw badRequest('not a stake account or unreadable');
}
if (staker !== userAddress) {
throw badRequest('stake account not owned by user (staker authority mismatch)');
}
}

View File

@@ -1,427 +0,0 @@
/**
* Staking orchestrator — custodial.
*
* Milestone 1: Lido ETH staking (stake / quote / positions).
* Паттерн как в bridge-execute.service.ts: сервер строит calldata, отнимает app-fee 0.7%,
* затем подписывает+бродкастит через wallet-signer. Mnemonic не покидает API.
*
* Lido `submit(referral)` payable минтит stETH ~1:1 к внесённому ETH. stETH — ребейзный ERC20,
* поэтому позиция = просто stETH balanceOf(адрес юзера); отдельная таблица в БД не нужна.
*/
import { ethers } from 'ethers';
import { pickProxiedEvmProvider } from '../lib/outbound-proxy';
import {
ETH_RPCS,
lidoStEthAddress,
lidoWithdrawalQueueAddress,
curveStEthPoolAddress,
assertDefiEvmTarget,
LIDO_ABI,
LIDO_WQ_ABI,
CURVE_STETH_ABI,
} from '../lib/defi-contracts';
import { computeAppFee } from '../lib/app-fee';
import { formatSmallestUnits } from '../lib/amount-units';
import { getPricesBySymbols } from './price-oracle.service';
import { signAndBroadcastEvmFeeTx, signAndBroadcastRawEvm } from './wallet-signer.service';
import { signAndBroadcastEvmApprove } from './wallet-signer-bridge';
/** ETH/stETH/WETH = 18 decimals → human-строка. */
const eth = (wei: string): string => formatSmallestUnits(wei, 18);
/** Типизированная ошибка с HTTP-кодом для контроллера. */
export class StakingError extends Error {
constructor(message: string, public code: string, public httpStatus = 400) {
super(message);
this.name = 'StakingError';
}
}
function badRequest(msg: string): StakingError {
return new StakingError(msg, 'BAD_REQUEST', 400);
}
/** Lido submit обычно жжёт ~90k; 150k с запасом. */
const STAKE_GAS_LIMIT = '150000';
async function getEthProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
return pickProxiedEvmProvider(ETH_RPCS, 1);
}
export interface LidoQuote {
amountWei: string;
amountWeiHuman: string;
appFeeWei: string;
appFeeWeiHuman: string;
appFeeUsd: number | null; // наша 0.7% в USDT (≈USD); null если цена ETH недоступна
stakeAmountWei: string;
stakeAmountWeiHuman: string;
expectedStEthWei: string;
expectedStEthWeiHuman: string;
aprPercent: number | null;
}
/** Превью стейка: fee, сумма после fee, ожидаемый stETH (~1:1), APR. */
export async function quoteLidoStake(amountWei: string): Promise<LidoQuote> {
if (typeof amountWei !== 'string' || !/^\d+$/.test(amountWei) || amountWei === '0') {
throw badRequest('amount must be a positive integer string (wei)');
}
const fee = computeAppFee(amountWei);
const stakeAmount = BigInt(amountWei) - fee;
if (stakeAmount <= 0n) throw badRequest('amount too small after 0.7% fee');
const apr = await fetchLidoApr();
// USDT-эквивалент комиссии (≈USD). Цена ETH через whitelist; null если недоступна.
let ethPrice: number | null = null;
try { ethPrice = (await getPricesBySymbols([{ chain: 'ETH', symbol: 'ETH' }])).get('ETH:ETH') ?? null; } catch { /* optional */ }
const appFeeHuman = eth(fee.toString());
const appFeeUsd = ethPrice != null ? Math.round(Number(appFeeHuman) * ethPrice * 1e6) / 1e6 : null;
return {
amountWei,
amountWeiHuman: eth(amountWei),
appFeeWei: fee.toString(),
appFeeWeiHuman: appFeeHuman,
appFeeUsd,
stakeAmountWei: stakeAmount.toString(),
stakeAmountWeiHuman: eth(stakeAmount.toString()),
// Lido минтит stETH ~1:1 к внесённому ETH (после fee)
expectedStEthWei: stakeAmount.toString(),
expectedStEthWeiHuman: eth(stakeAmount.toString()),
aprPercent: apr,
};
}
export interface LidoStakeResult {
feeTxid: string;
stakeTxid: string;
stakeAmountWei: string;
stakeAmountWeiHuman: string;
appFeeWei: string;
appFeeWeiHuman: string;
}
/**
* Выполнить стейк: (1) app-fee native ETH (atomic, ждёт 1 conf), (2) Lido submit(referral) payable.
* Nonce-последовательность: fee использует nonce N (wait 1 conf) → submit берёт pending = N+1.
*/
export async function executeLidoStake(p: {
mnemonic: string;
expectedFromAddress: string;
amountWei: string;
}): Promise<LidoStakeResult> {
if (typeof p.amountWei !== 'string' || !/^\d+$/.test(p.amountWei) || p.amountWei === '0') {
throw badRequest('amount must be a positive integer string (wei)');
}
const fee = computeAppFee(p.amountWei);
const stakeAmount = BigInt(p.amountWei) - fee;
if (stakeAmount <= 0n) throw badRequest('amount too small after 0.7% fee');
// 1) app fee (native ETH) — отдельная транза, ждёт 1 conf; если revert → throw, submit не уйдёт
const feeRes = await signAndBroadcastEvmFeeTx({
chain: 'ETH',
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
bridgeAmount: p.amountWei,
bridgeToken: null, // native
feeTier: 'normal',
});
// 2) Lido submit(referral) payable, value = stakeAmount
const to = lidoStEthAddress();
assertDefiEvmTarget(to);
const data = new ethers.utils.Interface(LIDO_ABI).encodeFunctionData('submit', [ethers.constants.AddressZero]);
const stakeRes = await signAndBroadcastRawEvm({
chain: 'ETH',
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
feeTier: 'normal', // override fees из gas-oracle (tx.maxFee* игнорятся)
tx: {
to,
data,
value: stakeAmount.toString(),
chainId: 1,
gas: STAKE_GAS_LIMIT,
maxFeePerGas: '0',
maxPriorityFeePerGas: '0',
},
});
return {
feeTxid: feeRes.feeTxid,
stakeTxid: stakeRes.txid,
stakeAmountWei: stakeAmount.toString(),
stakeAmountWeiHuman: eth(stakeAmount.toString()),
appFeeWei: fee.toString(),
appFeeWeiHuman: eth(fee.toString()),
};
}
export interface LidoWithdrawalRequest {
requestId: string;
amountStEthWei: string;
amountStEthWeiHuman: string;
isFinalized: boolean;
isClaimed: boolean;
}
export interface LidoPositions {
protocol: 'lido';
chain: 'ETH';
stEthBalanceWei: string;
stEthBalanceWeiHuman: string;
aprPercent: number | null;
withdrawalRequests: LidoWithdrawalRequest[];
}
/**
* Позиция = stETH balanceOf(юзера) + pending withdrawal requests (mode=queue).
* Читается live из чейна (без БД).
*/
export async function readLidoPositions(ethAddress: string): Promise<LidoPositions> {
const provider = await getEthProvider();
const stEth = new ethers.Contract(lidoStEthAddress(), LIDO_ABI, provider);
const wq = new ethers.Contract(lidoWithdrawalQueueAddress(), LIDO_WQ_ABI, provider);
const [bal, apr, requests] = await Promise.all([
stEth.balanceOf(ethAddress) as Promise<ethers.BigNumber>,
fetchLidoApr(),
readWithdrawalRequests(wq, ethAddress),
]);
return {
protocol: 'lido',
chain: 'ETH',
stEthBalanceWei: bal.toString(),
stEthBalanceWeiHuman: eth(bal.toString()),
aprPercent: apr,
withdrawalRequests: requests,
};
}
async function readWithdrawalRequests(
wq: ethers.Contract,
owner: string,
): Promise<LidoWithdrawalRequest[]> {
try {
const ids: ethers.BigNumber[] = await wq.getWithdrawalRequests(owner);
if (!ids.length) return [];
const statuses: any[] = await wq.getWithdrawalStatus(ids);
return ids.map((id, i) => ({
requestId: id.toString(),
amountStEthWei: statuses[i]?.amountOfStETH?.toString() ?? '0',
amountStEthWeiHuman: eth(statuses[i]?.amountOfStETH?.toString() ?? '0'),
isFinalized: Boolean(statuses[i]?.isFinalized),
isClaimed: Boolean(statuses[i]?.isClaimed),
}));
} catch {
return [];
}
}
// ── Unstake ────────────────────────────────────────────────────────────
const SLIPPAGE_DENOM = 10000n;
// Авто-slippage на выводе stETH→ETH: маленький фикс (0.3%). Безопасно — get_dy читается свежим
// перед exchange, поэтому slippage покрывает только окно исполнения (peg/impact уже в get_dy).
const UNSTAKE_SWAP_SLIPPAGE_BPS = 30n; // 0.3%
const SWAP_GAS_LIMIT = '300000';
const WQ_REQUEST_GAS_LIMIT = '350000';
const WQ_CLAIM_GAS_LIMIT = '250000';
export interface LidoUnstakeSwapResult {
mode: 'swap';
approveTxid: string;
swapTxid: string;
swapAmountStEthWei: string;
swapAmountStEthWeiHuman: string;
minEthOutWei: string;
minEthOutWeiHuman: string;
}
/**
* mode=swap: мгновенный вывод stETH→ETH через Curve stETH/ETH пул (deepest liquidity).
* Вывод БЕЗ комиссии: approve stETH→Curve → exchange(1,0,...) на полную сумму.
*/
export async function unstakeLidoViaSwap(p: {
mnemonic: string;
expectedFromAddress: string;
amountStEthWei: string;
}): Promise<LidoUnstakeSwapResult> {
if (typeof p.amountStEthWei !== 'string' || !/^\d+$/.test(p.amountStEthWei) || p.amountStEthWei === '0') {
throw badRequest('amount must be a positive integer string (stETH wei)');
}
const swapAmount = BigInt(p.amountStEthWei);
const stEth = lidoStEthAddress();
const pool = curveStEthPoolAddress();
// approve stETH→Curve (полная сумма, вывод без комиссии)
const approveRes = await signAndBroadcastEvmApprove({
chain: 'ETH',
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
spender: pool,
token: stEth,
amount: swapAmount.toString(),
});
// min_dy из get_dy с авто-slippage
const provider = await getEthProvider();
const curve = new ethers.Contract(pool, CURVE_STETH_ABI, provider);
const dy: ethers.BigNumber = await curve.get_dy(1, 0, swapAmount.toString());
const minDy = (BigInt(dy.toString()) * (SLIPPAGE_DENOM - UNSTAKE_SWAP_SLIPPAGE_BPS)) / SLIPPAGE_DENOM;
// exchange(1=stETH, 0=ETH, swapAmount, minDy) — ETH прилетает юзеру
assertDefiEvmTarget(pool);
const data = new ethers.utils.Interface(CURVE_STETH_ABI).encodeFunctionData('exchange', [
1,
0,
swapAmount.toString(),
minDy.toString(),
]);
const swapRes = await signAndBroadcastRawEvm({
chain: 'ETH',
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
feeTier: 'normal',
tx: { to: pool, data, value: '0', chainId: 1, gas: SWAP_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' },
});
return {
mode: 'swap',
approveTxid: approveRes.txid,
swapTxid: swapRes.txid,
swapAmountStEthWei: swapAmount.toString(),
swapAmountStEthWeiHuman: eth(swapAmount.toString()),
minEthOutWei: minDy.toString(),
minEthOutWeiHuman: eth(minDy.toString()),
};
}
export interface LidoUnstakeQueueResult {
mode: 'queue';
approveTxid: string;
requestTxid: string;
requestedStEthWei: string;
requestedStEthWeiHuman: string;
note: string;
}
/**
* mode=queue: честный вывод через Lido Withdrawal Queue. БЕЗ комиссии.
* Шаги: approve stETH→WQ → requestWithdrawals([amount], owner) на полную сумму.
* requestId(ы) появятся в GET /stake/positions; затем POST /unstake/claim.
*/
export async function unstakeLidoViaQueue(p: {
mnemonic: string;
expectedFromAddress: string;
amountStEthWei: string;
}): Promise<LidoUnstakeQueueResult> {
if (typeof p.amountStEthWei !== 'string' || !/^\d+$/.test(p.amountStEthWei) || p.amountStEthWei === '0') {
throw badRequest('amount must be a positive integer string (stETH wei)');
}
const reqAmount = BigInt(p.amountStEthWei);
// Lido WQ ограничивает один запрос ≤ 1000 stETH
if (reqAmount > ethers.utils.parseEther('1000').toBigInt()) {
throw badRequest('single withdrawal request must be ≤ 1000 stETH; split into multiple');
}
const stEth = lidoStEthAddress();
const wq = lidoWithdrawalQueueAddress();
const approveRes = await signAndBroadcastEvmApprove({
chain: 'ETH',
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
spender: wq,
token: stEth,
amount: reqAmount.toString(),
});
assertDefiEvmTarget(wq);
const data = new ethers.utils.Interface(LIDO_WQ_ABI).encodeFunctionData('requestWithdrawals', [
[reqAmount.toString()],
p.expectedFromAddress,
]);
const reqRes = await signAndBroadcastRawEvm({
chain: 'ETH',
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
feeTier: 'normal',
tx: { to: wq, data, value: '0', chainId: 1, gas: WQ_REQUEST_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' },
});
return {
mode: 'queue',
approveTxid: approveRes.txid,
requestTxid: reqRes.txid,
requestedStEthWei: reqAmount.toString(),
requestedStEthWeiHuman: eth(reqAmount.toString()),
note: 'Withdrawal requested (без комиссии). Check GET /wallets/ETH/stake/positions for requestId + isFinalized, then POST /wallets/ETH/unstake/claim.',
};
}
/** Claim finalized withdrawal request (mode=queue). */
export async function claimLidoWithdrawal(p: {
mnemonic: string;
expectedFromAddress: string;
requestId: string;
}): Promise<{ claimTxid: string }> {
if (typeof p.requestId !== 'string' || !/^\d+$/.test(p.requestId)) {
throw badRequest('requestId must be a positive integer string');
}
const wq = lidoWithdrawalQueueAddress();
// Pre-check: requestId должен принадлежать юзеру — иначе on-chain revert + сожжённый газ.
// Деградация: если чтение упало (RPC) — не блокируем (claim сам отвергнет чужой requestId on-chain).
try {
const provider = await getEthProvider();
const wqRead = new ethers.Contract(wq, LIDO_WQ_ABI, provider);
const ids: ethers.BigNumber[] = await wqRead.getWithdrawalRequests(p.expectedFromAddress);
if (!ids.some((id) => id.toString() === p.requestId)) {
throw badRequest('requestId not found for this user (см. GET /wallets/ETH/stake/positions)');
}
} catch (err: any) {
if (err instanceof StakingError) throw err;
/* RPC read failure → proceed (on-chain claim защищает) */
}
assertDefiEvmTarget(wq);
const data = new ethers.utils.Interface(LIDO_WQ_ABI).encodeFunctionData('claimWithdrawal', [p.requestId]);
const res = await signAndBroadcastRawEvm({
chain: 'ETH',
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
feeTier: 'normal',
tx: { to: wq, data, value: '0', chainId: 1, gas: WQ_CLAIM_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' },
});
return { claimTxid: res.txid };
}
// ── Lido APR (public API) с кэшем 10 мин + fallback ────────────────────
let _aprCache: { value: number; at: number } | null = null;
const APR_TTL_MS = 10 * 60_000;
async function fetchLidoApr(): Promise<number | null> {
const now = Date.now();
if (_aprCache && now - _aprCache.at < APR_TTL_MS) return _aprCache.value;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch('https://eth-api.lido.fi/v1/protocol/steth/apr/last', {
signal: controller.signal,
});
if (res.ok) {
const j: any = await res.json();
const apr = Number(j?.data?.apr ?? j?.apr);
if (Number.isFinite(apr)) {
_aprCache = { value: apr, at: now };
return apr;
}
}
} catch {
/* network/timeout → fallback на stale/null */
} finally {
clearTimeout(t);
}
return _aprCache?.value ?? null;
}

View File

@@ -21,13 +21,7 @@ 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';
import {
proxiedFetch,
pickProxiedEvmProvider,
getProxiedSolConnection,
} from '../lib/outbound-proxy';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { getSolTokens } from '../lib/token-registry';
const HTTP_TIMEOUT_MS = 20_000; const HTTP_TIMEOUT_MS = 20_000;
const MAX_GAS_PRICE_GWEI = 500; const MAX_GAS_PRICE_GWEI = 500;
@@ -74,55 +68,21 @@ export interface SwapBscParams {
feeTier?: FeeTier; feeTier?: FeeTier;
} }
export interface QuoteBscParams {
fromAddress: string; // derived custodial address (для allowance / estimateGas)
from: string;
to: string;
amount: string;
slippageBps?: number;
feeTier?: FeeTier;
}
export interface SwapQuoteRaw {
amountIn: string; // smallest units (gross — что юзер отдаёт всего)
expectedOut: string; // mid-market quote (smallest units), based on (amount - appFee)
minOut: string; // expectedOut × (10000 - slippageBps) / 10000
slippageBps: number;
route: string[]; // symbol path (info; не используется в execute)
approveRequired: boolean;
estimatedGasUnits?: string;
/** Network fee asset symbol + raw amount (smallest units). */
networkFee: {
asset: string;
amount: string;
};
/** App fee (0.7% BSC) — отправляется на BSC_FEE_WALLET перед swap. Только для BSC. */
appFee?: {
asset: string; // BNB или token symbol
amount: string; // smallest units (= amount * 70 / 10000)
recipient: string; // BSC_FEE_WALLET
};
/** Per-token decimals для controller форматирования. */
fromDecimals: number;
toDecimals: number;
}
export interface ExecuteBscParams {
mnemonic: string;
expectedFromAddress: string;
from: string;
to: string;
amount: string;
slippageBps?: number;
feeTier?: FeeTier;
/** Locked minOut из quote (anti-MEV). Если undefined — re-quote on-chain (legacy fallback). */
lockedMinOut?: string;
}
/** Wrapper над `pickProxiedEvmProvider` — все swap-orchestrator EVM calls
* идут через OUTBOUND_PROXY_URL если задан. Если не задан — fallback direct. */
async function pickProvider(rpcs: string[], chainId: number): Promise<ethers.providers.StaticJsonRpcProvider> { async function pickProvider(rpcs: string[], chainId: number): Promise<ethers.providers.StaticJsonRpcProvider> {
return pickProxiedEvmProvider(rpcs, chainId); let lastErr: any;
for (const url of rpcs) {
const p = new ethers.providers.StaticJsonRpcProvider(url, chainId);
try {
await Promise.race([
p.getBlockNumber(),
new Promise((_, reject) => setTimeout(() => reject(new Error('rpc_alive_timeout')), 3000)),
]);
return p;
} catch (err) {
lastErr = err;
}
}
throw new Error(`All BSC RPCs failed: ${lastErr?.message || lastErr}`);
} }
function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> { function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
@@ -132,175 +92,13 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
]); ]);
} }
// BSC decimals map (для quote response). Native BNB = 18. Все BSC tokens из BSC_TOKENS
// = 18 (USDT/USDC/WBNB/BUSD/DOGE — да, на BSC DOGE — 8, остальное 18). Lookup через registry.
import { getEvmTokens } from '../lib/token-registry';
import { BSC_FEE_WALLET, computeBscFee } from '../lib/bsc-fee';
function bscTokenDecimals(symbol: string): number {
const upper = symbol.toUpperCase();
if (upper === 'BNB') return 18;
const t = getEvmTokens('BSC').find((x) => x.symbol.toUpperCase() === upper);
return t?.decimals ?? 18;
}
/** /**
* BSC quote — read-only расчёт expected output, slippage, gas fee, route. * BSC chained swap. Если `from` не нативный BNB и allowance < amount —
* НЕ требует mnemonic — все checks через `fromAddress` (custodial address из DB).
*
* Используется в `POST /api/wallets/BSC/swap/quote`.
*/
export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
const fromUpper = p.from.toUpperCase();
const toUpper = p.to.toUpperCase();
if (!BSC_TOKEN_MAP[fromUpper] || !BSC_TOKEN_MAP[toUpper] || fromUpper === toUpper) {
throw new Error(`Invalid BSC swap pair: ${fromUpper}${toUpper}`);
}
if (!/^\d+$/.test(p.amount) || BigInt(p.amount) <= 0n) {
throw new Error('amount must be positive integer string');
}
const slippageBps = p.slippageBps ?? 50;
if (slippageBps < 1 || slippageBps > 1000) {
throw new Error('slippageBps must be 1-1000 (0.01%-10%)');
}
const provider = await pickProvider(BSC_RPCS, BSC_CHAIN_ID);
// Gas tier
const tier: FeeTier = p.feeTier ?? 'normal';
const fee = await getEvmFeeForTier('BSC', tier);
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
const maxFeePerGas = ethers.BigNumber.from(fee.maxFeePerGas);
if (maxFeePerGas.gt(capWei)) {
throw new Error('Gas fee exceeds policy cap');
}
// App fee 0.7% (off-chain double-tx). Применяется ВСЕГДА на BSC swap.
// amount → fee + swap = amount × 70/10000 + amount × 9930/10000.
// getAmountsOut вычисляется на swapAmount (после fee) → expectedOut учитывает fee.
const feeAmountBig = computeBscFee(p.amount); // 0.7% от amount
const swapAmountBig = BigInt(p.amount) - feeAmountBig;
if (swapAmountBig <= 0n) {
throw new Error('amount too small after 0.7% fee deduction');
}
const swapAmountStr = swapAmountBig.toString();
// Quote via getAmountsOut на swapAmount (не на full amount)
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]];
const amountsOut: ethers.BigNumber[] = await withTimeout(
routerContract.getAmountsOut(swapAmountStr, path),
HTTP_TIMEOUT_MS,
'PancakeSwap quote timed out',
);
const expectedOut = amountsOut[amountsOut.length - 1];
if (expectedOut.lte(0)) {
throw new Error('PancakeSwap quote returned 0 — no liquidity for this pair');
}
const minOut = expectedOut.mul(10000 - slippageBps).div(10000);
// Allowance check — approveRequired? (только для token-in)
// Check allowance >= swapAmount (не full amount — fee tx сам payer'ом юзером).
let approveRequired = false;
if (fromUpper !== 'BNB') {
const tokenContract = new ethers.Contract(BSC_TOKEN_MAP[fromUpper], ERC20_ABI, provider);
try {
const currentAllowance: ethers.BigNumber = await withTimeout(
tokenContract.allowance(p.fromAddress, PANCAKE_ROUTER),
HTTP_TIMEOUT_MS,
'Allowance check timed out',
);
approveRequired = currentAllowance.lt(ethers.BigNumber.from(swapAmountStr));
} catch {
// Allowance check failed → assume approve needed (conservative)
approveRequired = true;
}
}
// Estimate gas (rough; without simulating actual approve)
// +21k для fee tx (native transfer) или +65k (ERC-20 transfer) добавляется на off-chain double-tx.
const feeTxGas = fromUpper === 'BNB' ? 21_000 : 65_000;
let estGas = ethers.BigNumber.from(approveRequired ? 330_000 + feeTxGas : 250_000 + feeTxGas);
try {
const deadline = Math.floor(Date.now() / 1000) + 1200;
let swapData: string;
let value: ethers.BigNumber;
if (fromUpper === 'BNB') {
swapData = routerContract.interface.encodeFunctionData(
'swapExactETHForTokensSupportingFeeOnTransferTokens',
[minOut, path, p.fromAddress, deadline],
);
value = ethers.BigNumber.from(swapAmountStr);
} else if (toUpper === 'BNB') {
swapData = routerContract.interface.encodeFunctionData(
'swapExactTokensForETHSupportingFeeOnTransferTokens',
[swapAmountStr, minOut, path, p.fromAddress, deadline],
);
value = ethers.BigNumber.from(0);
} else {
swapData = routerContract.interface.encodeFunctionData(
'swapExactTokensForTokensSupportingFeeOnTransferTokens',
[swapAmountStr, minOut, path, p.fromAddress, deadline],
);
value = ethers.BigNumber.from(0);
}
// estimateGas работает только если уже approve'd. Если нет — skip estimate, оставляем дефолт.
if (!approveRequired) {
const estimated = await provider.estimateGas({
from: p.fromAddress,
to: PANCAKE_ROUTER,
data: swapData,
value,
});
// +feeTxGas на дополнительную fee transfer
estGas = estimated.mul(120).div(100).add(feeTxGas);
const minGas = ethers.BigNumber.from(150_000 + feeTxGas);
const maxGas = ethers.BigNumber.from(500_000 + feeTxGas);
if (estGas.lt(minGas)) estGas = minGas;
if (estGas.gt(maxGas)) estGas = maxGas;
} else {
// Сложить approve (~80k) + fee tx + swap (~250k)
estGas = ethers.BigNumber.from(330_000 + feeTxGas);
}
} catch {
// Estimate failed — оставляем default
}
const networkFeeWei = estGas.mul(maxFeePerGas);
return {
amountIn: p.amount, // gross — что юзер отдаёт всего
expectedOut: expectedOut.toString(), // based on swapAmount (после fee)
minOut: minOut.toString(),
slippageBps,
route: [fromUpper, toUpper],
approveRequired,
estimatedGasUnits: estGas.toString(),
networkFee: {
asset: 'BNB',
amount: networkFeeWei.toString(),
},
appFee: {
asset: fromUpper, // BNB / USDT / etc.
amount: feeAmountBig.toString(),
recipient: BSC_FEE_WALLET,
},
fromDecimals: bscTokenDecimals(fromUpper),
toDecimals: bscTokenDecimals(toUpper),
};
}
/**
* BSC chained execute (формерно `swapBsc`). Если `from` не нативный BNB и allowance < amount —
* сначала approve(exact), wait 1 confirmation, потом swap. * сначала approve(exact), wait 1 confirmation, потом swap.
* *
* Если `lockedMinOut` задан (2-step flow с quote→execute) — используется он, иначе
* re-quote on-chain (legacy single-shot).
*
* Returns: { approveTxid?, swapTxid } * Returns: { approveTxid?, swapTxid }
*/ */
export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: string; feeTxid?: string; swapTxid: string }> { export async function swapBsc(p: SwapBscParams): Promise<{ approveTxid?: string; swapTxid: string }> {
const fromUpper = p.from.toUpperCase(); const fromUpper = p.from.toUpperCase();
const toUpper = p.to.toUpperCase(); const toUpper = p.to.toUpperCase();
@@ -323,14 +121,6 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
const provider = await pickProvider(BSC_RPCS, BSC_CHAIN_ID); const provider = await pickProvider(BSC_RPCS, BSC_CHAIN_ID);
const signer = wallet.connect(provider); const signer = wallet.connect(provider);
// App fee 0.7% — off-chain double-tx. Применяется ВСЕГДА на BSC swap.
const feeAmountBig = computeBscFee(p.amount);
const swapAmountBig = BigInt(p.amount) - feeAmountBig;
if (swapAmountBig <= 0n) {
throw new Error('amount too small after 0.7% fee deduction');
}
const swapAmountStr = swapAmountBig.toString();
// Gas tier // Gas tier
const tier: FeeTier = p.feeTier ?? 'normal'; const tier: FeeTier = p.feeTier ?? 'normal';
const fee = await getEvmFeeForTier('BSC', tier); const fee = await getEvmFeeForTier('BSC', tier);
@@ -341,15 +131,11 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
throw new Error('Gas fee invariant violated'); throw new Error('Gas fee invariant violated');
} }
// minOut: locked from quote OR re-quote on-chain (на swapAmount, не p.amount!) // Quote via getAmountsOut → compute amountOutMin server-side (anti-MEV)
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider); const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]]; const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]];
let amountOutMin: ethers.BigNumber;
if (p.lockedMinOut && /^\d+$/.test(p.lockedMinOut)) {
amountOutMin = ethers.BigNumber.from(p.lockedMinOut);
} else {
const amountsOut: ethers.BigNumber[] = await withTimeout( const amountsOut: ethers.BigNumber[] = await withTimeout(
routerContract.getAmountsOut(swapAmountStr, path), routerContract.getAmountsOut(p.amount, path),
HTTP_TIMEOUT_MS, HTTP_TIMEOUT_MS,
'PancakeSwap quote timed out', 'PancakeSwap quote timed out',
); );
@@ -357,8 +143,8 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
if (expectedOut.lte(0)) { if (expectedOut.lte(0)) {
throw new Error('PancakeSwap quote returned 0 — no liquidity for this pair'); throw new Error('PancakeSwap quote returned 0 — no liquidity for this pair');
} }
amountOutMin = expectedOut.mul(10000 - slippageBps).div(10000); // amountOutMin = expectedOut × (10000 - slippageBps) / 10000
} const amountOutMin = expectedOut.mul(10000 - slippageBps).div(10000);
const deadline = Math.floor(Date.now() / 1000) + 1200; // 20 minutes const deadline = Math.floor(Date.now() / 1000) + 1200; // 20 minutes
const feeFields: Partial<ethers.providers.TransactionRequest> = { const feeFields: Partial<ethers.providers.TransactionRequest> = {
@@ -368,11 +154,9 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
}; };
let approveTxid: string | undefined; let approveTxid: string | undefined;
let feeTxid: string | undefined;
let nonce = await provider.getTransactionCount(wallet.address, 'pending'); let nonce = await provider.getTransactionCount(wallet.address, 'pending');
// ── Token-to-anything: check allowance, approve if needed, wait 1 conf ── // ── Token-to-anything: check allowance, approve if needed, wait 1 conf ──
// Approve на swapAmount (не full amount — fee идёт через отдельную transfer tx).
if (fromUpper !== 'BNB') { if (fromUpper !== 'BNB') {
const tokenAddress = BSC_TOKEN_MAP[fromUpper]; const tokenAddress = BSC_TOKEN_MAP[fromUpper];
const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider); const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
@@ -381,15 +165,15 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
HTTP_TIMEOUT_MS, HTTP_TIMEOUT_MS,
'Allowance check timed out', 'Allowance check timed out',
); );
if (currentAllowance.lt(ethers.BigNumber.from(swapAmountStr))) { if (currentAllowance.lt(ethers.BigNumber.from(p.amount))) {
const approveData = tokenContract.interface.encodeFunctionData('approve', [PANCAKE_ROUTER, swapAmountStr]); const approveData = tokenContract.interface.encodeFunctionData('approve', [PANCAKE_ROUTER, p.amount]);
const approveTx: ethers.providers.TransactionRequest = { const approveTx: ethers.providers.TransactionRequest = {
to: tokenAddress, to: tokenAddress,
data: approveData, data: approveData,
value: 0, value: 0,
chainId: BSC_CHAIN_ID, chainId: BSC_CHAIN_ID,
nonce, nonce,
gasLimit: ethers.BigNumber.from(80_000), gasLimit: ethers.BigNumber.from(80_000), // approve consistently fits в 60-80k
...feeFields, ...feeFields,
}; };
const approveSent = await withTimeout( const approveSent = await withTimeout(
@@ -398,46 +182,13 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
'approve broadcast timed out', 'approve broadcast timed out',
); );
approveTxid = approveSent.hash; approveTxid = approveSent.hash;
// Wait 1 confirmation (~3s on BSC) before swap — иначе swap revert'нет с "TransferHelper: TRANSFER_FROM_FAILED"
await withTimeout(approveSent.wait(1), 30_000, 'approve confirmation timed out'); await withTimeout(approveSent.wait(1), 30_000, 'approve confirmation timed out');
nonce += 1; nonce += 1;
} }
} }
// ── App fee 0.7% transfer ПЕРЕД swap. Если fee tx revert'нёт — swap НЕ делается. ── // ── Build swap tx ──
// Для BNB (native) — простой transfer на BSC_FEE_WALLET (gas 21k).
// Для ERC-20 — token.transfer(BSC_FEE_WALLET, feeAmount) (gas ~65k).
if (feeAmountBig > 0n) {
const feeTx: ethers.providers.TransactionRequest = fromUpper === 'BNB'
? {
to: BSC_FEE_WALLET,
value: ethers.BigNumber.from(feeAmountBig.toString()),
chainId: BSC_CHAIN_ID,
nonce,
gasLimit: ethers.BigNumber.from(21_000),
...feeFields,
}
: {
to: BSC_TOKEN_MAP[fromUpper],
data: new ethers.utils.Interface(['function transfer(address,uint256) returns (bool)'])
.encodeFunctionData('transfer', [BSC_FEE_WALLET, feeAmountBig.toString()]),
value: 0,
chainId: BSC_CHAIN_ID,
nonce,
gasLimit: ethers.BigNumber.from(65_000),
...feeFields,
};
const feeSent = await withTimeout(
signer.sendTransaction(feeTx),
HTTP_TIMEOUT_MS,
'fee tx broadcast timed out',
);
feeTxid = feeSent.hash;
// Wait 1 confirmation — гарантия что fee пришёл до swap.
await withTimeout(feeSent.wait(1), 30_000, 'fee tx confirmation timed out');
nonce += 1;
}
// ── Build swap tx (на swapAmount, не p.amount) ──
let swapData: string; let swapData: string;
let value: ethers.BigNumber; let value: ethers.BigNumber;
if (fromUpper === 'BNB') { if (fromUpper === 'BNB') {
@@ -445,17 +196,18 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
'swapExactETHForTokensSupportingFeeOnTransferTokens', 'swapExactETHForTokensSupportingFeeOnTransferTokens',
[amountOutMin, path, wallet.address, deadline], [amountOutMin, path, wallet.address, deadline],
); );
value = ethers.BigNumber.from(swapAmountStr); value = ethers.BigNumber.from(p.amount);
} else if (toUpper === 'BNB') { } else if (toUpper === 'BNB') {
swapData = routerContract.interface.encodeFunctionData( swapData = routerContract.interface.encodeFunctionData(
'swapExactTokensForETHSupportingFeeOnTransferTokens', 'swapExactTokensForETHSupportingFeeOnTransferTokens',
[swapAmountStr, amountOutMin, path, wallet.address, deadline], [p.amount, amountOutMin, path, wallet.address, deadline],
); );
value = ethers.BigNumber.from(0); value = ethers.BigNumber.from(0);
} else { } else {
// Token-to-token (e.g., USDT → DOGE)
swapData = routerContract.interface.encodeFunctionData( swapData = routerContract.interface.encodeFunctionData(
'swapExactTokensForTokensSupportingFeeOnTransferTokens', 'swapExactTokensForTokensSupportingFeeOnTransferTokens',
[swapAmountStr, amountOutMin, path, wallet.address, deadline], [p.amount, amountOutMin, path, wallet.address, deadline],
); );
value = ethers.BigNumber.from(0); value = ethers.BigNumber.from(0);
} }
@@ -492,7 +244,7 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
HTTP_TIMEOUT_MS, HTTP_TIMEOUT_MS,
'swap broadcast timed out', 'swap broadcast timed out',
); );
return { approveTxid, feeTxid, swapTxid: swapSent.hash }; return { approveTxid, swapTxid: swapSent.hash };
} }
// ─── TRX SunSwap ───────────────────────────────────────────────────── // ─── TRX SunSwap ─────────────────────────────────────────────────────
@@ -526,7 +278,7 @@ const TRX_BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrs
// FeeSwapRouter работает с supporting-fee variant — это уже доказано в production traffic. // FeeSwapRouter работает с supporting-fee variant — это уже доказано в production traffic.
const SEL_APPROVE = '095ea7b3'; const SEL_APPROVE = '095ea7b3';
const SEL_SWAP_EXACT_ETH_FOR_TOKENS = 'b6f9de95'; const SEL_SWAP_EXACT_ETH_FOR_TOKENS = 'b6f9de95';
const SEL_SWAP_EXACT_TOKENS_FOR_ETH_SUPPORTING_FEE = '791ac947'; const SEL_SWAP_EXACT_TOKENS_FOR_ETH = '18cbafe5';
const SEL_SWAP_NATIVE_WITH_FEE = '152dad1d'; const SEL_SWAP_NATIVE_WITH_FEE = '152dad1d';
const SEL_SWAP_TOKEN_WITH_FEE = 'e8d1f203'; const SEL_SWAP_TOKEN_WITH_FEE = 'e8d1f203';
@@ -539,30 +291,11 @@ export interface SwapTrxParams {
slippageBps?: number; slippageBps?: number;
} }
export interface QuoteTrxParams {
fromAddress: string; // base58 tron address
from: string;
to: string;
amount: string;
slippageBps?: number;
}
export interface ExecuteTrxParams {
mnemonic: string;
expectedFromAddress: string;
from: string;
to: string;
amount: string;
slippageBps?: number;
lockedMinOut?: string;
}
async function fetchJson(url: string, init?: RequestInit): Promise<any> { async function fetchJson(url: string, init?: RequestInit): Promise<any> {
const controller = new AbortController(); const controller = new AbortController();
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS); const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
try { try {
// proxiedFetch routes через OUTBOUND_PROXY_URL если задан, иначе native fetch. const res = await fetch(url, { ...init, signal: controller.signal });
const res = await proxiedFetch(url, { ...init, signal: controller.signal });
if (!res.ok) { if (!res.ok) {
const body = await res.text().catch(() => ''); const body = await res.text().catch(() => '');
throw new Error(`Upstream ${res.status}: ${body.slice(0, 200)}`); throw new Error(`Upstream ${res.status}: ${body.slice(0, 200)}`);
@@ -617,8 +350,8 @@ function buildSwapExactETHForTokensCalldata(
encAddr(to) + encU256(deadline) + pathLen + pathElements; encAddr(to) + encU256(deadline) + pathLen + pathElements;
} }
// function swapExactTokensForETHSupportingFeeOnTransferTokens(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline) // function swapExactTokensForETH(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline)
function buildSwapExactTokensForETHSupportingFeeCalldata( function buildSwapExactTokensForETHCalldata(
amountIn: bigint, amountIn: bigint,
amountOutMin: bigint, amountOutMin: bigint,
path: string[], path: string[],
@@ -628,7 +361,7 @@ function buildSwapExactTokensForETHSupportingFeeCalldata(
const offsetToPath = encU256(160n); // 5 × 32 bytes const offsetToPath = encU256(160n); // 5 × 32 bytes
const pathLen = encU256(BigInt(path.length)); const pathLen = encU256(BigInt(path.length));
const pathElements = path.map(encAddr).join(''); const pathElements = path.map(encAddr).join('');
return SEL_SWAP_EXACT_TOKENS_FOR_ETH_SUPPORTING_FEE + encU256(amountIn) + encU256(amountOutMin) + return SEL_SWAP_EXACT_TOKENS_FOR_ETH + encU256(amountIn) + encU256(amountOutMin) +
offsetToPath + encAddr(to) + encU256(deadline) + pathLen + pathElements; offsetToPath + encAddr(to) + encU256(deadline) + pathLen + pathElements;
} }
@@ -858,90 +591,19 @@ async function waitTrxInclusion(
} }
/** /**
* TRX quote — read-only расчёт expected output для SunSwap V2 + FeeSwapRouter (0.7% fee). * TRX swap через SunSwap V2 + FeeSwapRouter (0.7% fee).
* Только TRX↔USDT. Не требует mnemonic. * Поддерживает только TRX↔USDT (same scope как legacy /tron/swap/build).
*
* Network fee: для TRX swap fee оценивается как ~30 TRX (typical SunSwap energy cost при отсутствии resources).
* Это approximation — реальный fee может быть 0 если у юзера достаточно frozen energy/bandwidth.
*/
export async function quoteTrx(p: QuoteTrxParams): Promise<SwapQuoteRaw> {
const fromU = p.from.toUpperCase();
const toU = p.to.toUpperCase();
const fromInfo = TRX_SWAP_TOKEN_MAP[fromU];
const toInfo = TRX_SWAP_TOKEN_MAP[toU];
if (!fromInfo || !toInfo || fromU === toU) {
throw new Error(`TRX swap supports only TRX↔USDT pairs (got ${p.from}${p.to})`);
}
const amount = BigInt(p.amount);
if (amount <= 0n) throw new Error('TRX swap: amount must be positive');
const slippageBpsNum = p.slippageBps !== undefined && Number.isFinite(p.slippageBps) ? p.slippageBps : 50;
const slippageBps = BigInt(slippageBpsNum);
if (slippageBps < 1n || slippageBps > 1000n) {
throw new Error('TRX swap: slippageBps must be between 1 and 1000');
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
// Compute fee + swap split (FeeSwapRouter забирает 0.7%).
const feeAmount = (amount * FEE_BPS) / BPS_DENOMINATOR;
const swapAmount = amount - feeAmount;
const isTrxToUsdt = fromU === 'TRX';
const path = isTrxToUsdt
? [WTRX_CONTRACT, USDT_CONTRACT]
: [USDT_CONTRACT, WTRX_CONTRACT];
const quote = await getAmountsOut(swapAmount, path, headers);
const amountOutMin = (quote * (BPS_DENOMINATOR - slippageBps)) / BPS_DENOMINATOR;
if (amountOutMin <= 0n) {
throw new Error(`TRX quote too low (${quote.toString()}) — no liquidity?`);
}
// Allowance check (только USDT → TRX)
let approveRequired = false;
if (!isTrxToUsdt) {
try {
const allowance = await checkAllowance(p.fromAddress, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, headers);
approveRequired = allowance < amount;
} catch {
approveRequired = true;
}
}
// Network fee approximation (TRON energy → ~30 TRX for SunSwap, ~15 if pre-approved).
// TRX has 6 decimals → 30_000_000 sun = 30 TRX.
const networkFeeSun = approveRequired ? 45_000_000n : 30_000_000n;
return {
amountIn: p.amount,
expectedOut: quote.toString(),
minOut: amountOutMin.toString(),
slippageBps: slippageBpsNum,
route: [fromU, toU],
approveRequired,
estimatedGasUnits: undefined,
networkFee: {
asset: 'TRX',
amount: networkFeeSun.toString(),
},
fromDecimals: fromInfo.decimals,
toDecimals: toInfo.decimals,
};
}
/**
* TRX execute (формерно `swapTrx`) — broadcast swap через SunSwap V2 + FeeSwapRouter (0.7% fee).
* Поддерживает только TRX↔USDT.
* *
* TRX → USDT (1 tx): swapNativeWithFee wraps swapExactETHForTokens. * TRX → USDT (1 tx): swapNativeWithFee wraps swapExactETHForTokens.
* USDT → TRX (1-2 tx): optional approve(infinite) + swapTokenWithFee wraps supporting-fee swapExactTokensForETH. * USDT → TRX (1-2 tx): optional approve(infinite) + swapTokenWithFee wraps swapExactTokensForETH.
* *
* Slippage: если `lockedMinOut` задан → используется. Иначе re-quote on-chain. * Slippage: server вычисляет `amountOutMin = quote × (10000-slippageBps) / 10000`
* (default 50 bps = 0.5%). Клиент НЕ задаёт amountOutMin (защита от MEV-sandwich).
*
* Возвращает `{ approveTxid?, swapTxid }` для совместимости с swapBsc.
*/ */
export async function executeTrx( export async function swapTrx(
p: ExecuteTrxParams, p: SwapTrxParams,
): Promise<{ approveTxid?: string; swapTxid: string }> { ): Promise<{ approveTxid?: string; swapTxid: string }> {
const fromU = p.from.toUpperCase(); const fromU = p.from.toUpperCase();
const toU = p.to.toUpperCase(); const toU = p.to.toUpperCase();
@@ -975,22 +637,16 @@ export async function executeTrx(
const feeAmount = (amount * FEE_BPS) / BPS_DENOMINATOR; const feeAmount = (amount * FEE_BPS) / BPS_DENOMINATOR;
const swapAmount = amount - feeAmount; const swapAmount = amount - feeAmount;
// Quote (на 99.3%, т.к. это то что SunSwap реально получит).
const isTrxToUsdt = fromU === 'TRX'; const isTrxToUsdt = fromU === 'TRX';
const path = isTrxToUsdt const path = isTrxToUsdt
? [WTRX_CONTRACT, USDT_CONTRACT] ? [WTRX_CONTRACT, USDT_CONTRACT]
: [USDT_CONTRACT, WTRX_CONTRACT]; : [USDT_CONTRACT, WTRX_CONTRACT];
// amountOutMin: lockedMinOut from quote OR re-quote on-chain
let amountOutMin: bigint;
if (p.lockedMinOut && /^\d+$/.test(p.lockedMinOut)) {
amountOutMin = BigInt(p.lockedMinOut);
} else {
const quote = await getAmountsOut(swapAmount, path, headers); const quote = await getAmountsOut(swapAmount, path, headers);
amountOutMin = (quote * (BPS_DENOMINATOR - slippageBps)) / BPS_DENOMINATOR; const amountOutMin = (quote * (BPS_DENOMINATOR - slippageBps)) / BPS_DENOMINATOR;
if (amountOutMin <= 0n) { if (amountOutMin <= 0n) {
throw new Error(`TRX quote too low (${quote.toString()}) — no liquidity?`); throw new Error(`TRX quote too low (${quote.toString()}) — no liquidity?`);
} }
}
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // 20 минут const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // 20 минут
@@ -1029,13 +685,14 @@ export async function executeTrx(
} }
// ─── USDT → TRX ─── // ─── USDT → TRX ───
// Step 1: check allowance, approve EXACT amount if needed. // Step 1: check allowance, approve infinite if needed.
// ВАЖНО: аппрувим РОВНО сумму свопа (не infinite). Точный approve самоочищается — роутер забирает
// ровно `amount`, allowance → 0. Бессрочный approval позволил бы вывести любые будущие поступления.
let approveTxid: string | undefined; let approveTxid: string | undefined;
const allowance = await checkAllowance(fromTronAddr, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, headers); const allowance = await checkAllowance(fromTronAddr, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, headers);
if (allowance < amount) { if (allowance < amount) {
const approveParam = encAddr(FEE_SWAP_ROUTER_TRX) + encU256(amount); const INFINITE = BigInt(
'0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
);
const approveParam = encAddr(FEE_SWAP_ROUTER_TRX) + encU256(INFINITE);
const approveTx = await buildTrigger({ const approveTx = await buildTrigger({
ownerAddress: fromTronAddr, ownerAddress: fromTronAddr,
contractAddress: USDT_CONTRACT, contractAddress: USDT_CONTRACT,
@@ -1058,7 +715,7 @@ export async function executeTrx(
} }
// Step 2: build swapTokenWithFee(USDT, amount, calldata). // Step 2: build swapTokenWithFee(USDT, amount, calldata).
const sunswapCalldata = buildSwapExactTokensForETHSupportingFeeCalldata( const sunswapCalldata = buildSwapExactTokensForETHCalldata(
swapAmount, amountOutMin, path, fromTronAddr, deadline, swapAmount, amountOutMin, path, fromTronAddr, deadline,
); );
const tokenInEnc = encAddr(USDT_CONTRACT); const tokenInEnc = encAddr(USDT_CONTRACT);
@@ -1089,32 +746,12 @@ export async function executeTrx(
// ─── SOL Jupiter ───────────────────────────────────────────────────── // ─── SOL Jupiter ─────────────────────────────────────────────────────
const SOL_RPC = 'https://api.mainnet-beta.solana.com'; const SOL_RPC = 'https://api.mainnet-beta.solana.com';
// Jupiter migrated в 2025: старый `quote-api.jup.ag/v6` deprecated и DNS удалён. const JUPITER_API = 'https://quote-api.jup.ag/v6';
// `lite-api.jup.ag/swap/v1` — public anonymous endpoint (~600 req/min), JSON-schema идентична.
const JUPITER_API = 'https://lite-api.jup.ag/swap/v1';
// SOL mint whitelist для custodial swap. Соответствует sol-swap-proxy.ALLOWED_MINTS.
// Включает wrapped SOL (для Jupiter native обозначение) + 14 SPL tokens из token-registry.
// Lazy-init из getSolTokens() чтобы registry оставался single source of truth.
const SOL_NATIVE_WRAPPED_MINT = 'So11111111111111111111111111111111111111112';
let _solMintWhitelist: Set<string> | null = null;
function isAllowedSolMint(mint: string): boolean {
if (!_solMintWhitelist) {
_solMintWhitelist = new Set([
SOL_NATIVE_WRAPPED_MINT,
...getSolTokens().map((t) => t.mint),
]);
}
return _solMintWhitelist.has(mint);
}
let _solConnection: Connection | null = null; let _solConnection: Connection | null = null;
function getSolConnection(): Connection { function getSolConnection(): Connection {
if (!_solConnection) { if (!_solConnection) {
// Через OUTBOUND_PROXY_URL если задан (swap path) — Jupiter swap broadcast'ы идут _solConnection = new Connection(SOL_RPC, 'confirmed');
// через proxy. Если env пустой — proxiedFetch fallback'ит на native, Connection
// работает direct.
_solConnection = getProxiedSolConnection(SOL_RPC, 'confirmed');
} }
return _solConnection; return _solConnection;
} }
@@ -1128,114 +765,10 @@ export interface SwapSolParams {
slippageBps?: number; slippageBps?: number;
} }
export interface QuoteSolParams {
inputMint: string;
outputMint: string;
amount: string;
slippageBps?: number;
}
export interface QuoteSolResult extends SwapQuoteRaw {
/** Jupiter /quote response object — нужен для execute reuse. */
jupiterQuoteResponse: any;
priceImpactPct?: string;
}
export interface ExecuteSolParams {
mnemonic: string;
expectedFromAddress: string;
inputMint: string;
outputMint: string;
amount: string;
slippageBps?: number;
/** Кешированный Jupiter /quote response (для re-use на /swap step). */
jupiterQuoteResponse?: any;
}
/** Возвращает decimals для known SOL mint (по token-registry). Wrapped SOL = 9. */
function solMintDecimals(mint: string): number {
if (mint === SOL_NATIVE_WRAPPED_MINT) return 9;
const t = getSolTokens().find((x) => x.mint === mint);
return t?.decimals ?? 6;
}
function validateSolParams(inputMint: string, outputMint: string, slippageBpsRaw?: number): number {
if (!isAllowedSolMint(inputMint)) {
throw new Error(`SOL swap inputMint not in whitelist: ${inputMint}`);
}
if (!isAllowedSolMint(outputMint)) {
throw new Error(`SOL swap outputMint not in whitelist: ${outputMint}`);
}
if (inputMint === outputMint) {
throw new Error('SOL swap: inputMint === outputMint');
}
const slippageBps = slippageBpsRaw ?? 50;
if (slippageBps < 1 || slippageBps > 1000) {
throw new Error('slippageBps must be 1-1000');
}
return slippageBps;
}
/** /**
* SOL quote — fetches Jupiter /quote (read-only, no broadcast). * SOL Jupiter chained swap. Получаем quote от Jupiter, build serialized tx, sign keypair'ом, broadcast.
* Returns expectedOut, minOut, route info, плюс full Jupiter quote response
* (нужен для execute step, который зовёт Jupiter /swap с тем же quoteResponse).
*/ */
export async function quoteSol(p: QuoteSolParams): Promise<QuoteSolResult> { export async function swapSol(p: SwapSolParams): Promise<{ signature: string }> {
const slippageBps = validateSolParams(p.inputMint, p.outputMint, p.slippageBps);
if (!/^\d+$/.test(p.amount) || BigInt(p.amount) <= 0n) {
throw new Error('SOL swap: amount must be positive integer string');
}
const quoteUrl = `${JUPITER_API}/quote?inputMint=${encodeURIComponent(p.inputMint)}&outputMint=${encodeURIComponent(p.outputMint)}&amount=${encodeURIComponent(p.amount)}&slippageBps=${slippageBps}`;
const headers: Record<string, string> = { Accept: 'application/json' };
if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey;
const quoteRes = await fetchJson(quoteUrl, { headers });
// Jupiter quote response shape:
// inAmount, outAmount, otherAmountThreshold (= minOut при ExactIn),
// priceImpactPct, routePlan: [{ swapInfo: { label, ... } }, ...]
const expectedOut = String(quoteRes.outAmount ?? '0');
const minOut = String(quoteRes.otherAmountThreshold ?? expectedOut);
// Build human-readable route (DEX names или mint→mint).
let route: string[] = [];
const plan = Array.isArray(quoteRes.routePlan) ? quoteRes.routePlan : [];
if (plan.length > 0) {
route = plan.map((hop: any) => hop?.swapInfo?.label ?? 'unknown');
} else {
route = ['Jupiter'];
}
// Network fee на SOL — Jupiter префронтит "auto" prioritization fee. Typical: ~5000-20000 lamports
// base + до 100000 priority. Точное значение видим только в /swap response (computeBudgetInstructions).
// Approximation: 25000 lamports = 0.000025 SOL. Это conservative upper bound.
const networkFeeLamports = '25000';
return {
amountIn: p.amount,
expectedOut,
minOut,
slippageBps,
route,
approveRequired: false, // SOL не требует ERC20-style approve
estimatedGasUnits: undefined,
networkFee: {
asset: 'SOL',
amount: networkFeeLamports,
},
fromDecimals: solMintDecimals(p.inputMint),
toDecimals: solMintDecimals(p.outputMint),
jupiterQuoteResponse: quoteRes,
priceImpactPct: quoteRes.priceImpactPct ? String(quoteRes.priceImpactPct) : undefined,
};
}
/**
* SOL execute (формерно `swapSol`) — Jupiter chained swap.
* Принимает либо `jupiterQuoteResponse` (locked from quote step) либо re-fetch'ит.
*/
export async function executeSol(p: ExecuteSolParams): Promise<{ signature: string; feeTxid?: string; feeAmount?: string }> {
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) {
@@ -1246,52 +779,16 @@ export async function executeSol(p: ExecuteSolParams): Promise<{ signature: stri
throw new Error(`SOL address mismatch: derived ${keypair.publicKey.toBase58()} ≠ DB ${p.expectedFromAddress}`); throw new Error(`SOL address mismatch: derived ${keypair.publicKey.toBase58()} ≠ DB ${p.expectedFromAddress}`);
} }
const slippageBps = validateSolParams(p.inputMint, p.outputMint, p.slippageBps); const slippageBps = p.slippageBps ?? 50;
if (slippageBps < 1 || slippageBps > 1000) {
// ── App fee 0.7% (atomic — fee SOL/SPL tx ПЕРЕД Jupiter swap) ── throw new Error('slippageBps must be 1-1000');
// Fee in inputMint same as swap input. SOL native → fee in lamports; SPL → fee in token.
// Jupiter quote/swap remains intact (user видит swap rate на full amount; fee — extra cost).
let feeTxid: string | undefined;
let feeAmount: string | undefined;
try {
const { computeAppFee, APP_FEE_WALLET_SOL } = await import('../lib/app-fee');
const { SOL_TOKENS } = await import('../lib/token-registry');
const feeBig = computeAppFee(p.amount);
if (feeBig > 0n) {
const SOL_NATIVE_MINT = 'So11111111111111111111111111111111111111112';
const isNative = p.inputMint === SOL_NATIVE_MINT || p.inputMint === '11111111111111111111111111111111';
const splToken = isNative ? null : SOL_TOKENS.find((t) => t.mint === p.inputMint);
if (!isNative && !splToken) {
throw new Error(`SOL swap fee: SPL mint ${p.inputMint} not in registry`);
}
// Use sendSol через signAndBroadcast wrapper (existing helper handles SPL ATA + native).
const { signAndBroadcast: _swapFeeSend } = await import('./wallet-signer.service');
const feeRes = await _swapFeeSend({
chain: 'SOL',
mnemonic: p.mnemonic,
expectedFromAddress: p.expectedFromAddress,
to: APP_FEE_WALLET_SOL,
amount: feeBig.toString(),
token: splToken ? splToken.symbol : undefined,
});
feeTxid = feeRes.txid;
feeAmount = feeBig.toString();
logger.info(`SOL swap fee: ${feeAmount} ${splToken ? splToken.symbol : 'lamports'}${APP_FEE_WALLET_SOL} txid=${feeTxid}`);
}
} catch (err: any) {
// Fee failed — abort swap (atomic). User не видит quote-locked swap, fee не списано (revert before send).
throw new Error(`SOL swap fee failed (swap NOT executed): ${err?.message || err}`);
} }
// 1. Jupiter quote
const quoteUrl = `${JUPITER_API}/quote?inputMint=${encodeURIComponent(p.inputMint)}&outputMint=${encodeURIComponent(p.outputMint)}&amount=${encodeURIComponent(p.amount)}&slippageBps=${slippageBps}`;
const headers: Record<string, string> = { Accept: 'application/json' }; const headers: Record<string, string> = { Accept: 'application/json' };
if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey; if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey;
const quoteRes = await fetchJson(quoteUrl, { headers });
// Re-use cached quote OR fetch fresh.
let quoteRes = p.jupiterQuoteResponse;
if (!quoteRes) {
const quoteUrl = `${JUPITER_API}/quote?inputMint=${encodeURIComponent(p.inputMint)}&outputMint=${encodeURIComponent(p.outputMint)}&amount=${encodeURIComponent(p.amount)}&slippageBps=${slippageBps}`;
quoteRes = await fetchJson(quoteUrl, { headers });
}
// 2. Jupiter swap (build serialized tx) // 2. Jupiter swap (build serialized tx)
const swapBody: Record<string, unknown> = { const swapBody: Record<string, unknown> = {
@@ -1344,5 +841,5 @@ export async function executeSol(p: ExecuteSolParams): Promise<{ signature: stri
logger.warn(`SOL Jupiter swap confirm warning (${name}): ${err.message}. sig=${sig}`); logger.warn(`SOL Jupiter swap confirm warning (${name}): ${err.message}. sig=${sig}`);
} }
return { signature: sig, feeTxid, feeAmount }; return { signature: sig };
} }

View File

@@ -1,788 +0,0 @@
/**
* Uniswap v3 liquidity provision — custodial. 2 куриных пула, произвольный диапазон.
*
* getPools — список пулов: токены, fee, текущая цена, TVL, APR (subgraph).
* quoteAdd — из [priceLower, priceUpper] → тики + нужные amount0/amount1 + slippage-мины.
* executeAdd— fee 0.7% (оба токена) → approve×2 → NFPM.mint() → tokenId.
* remove — NFPM.multicall(decreaseLiquidity + collect [+ burn]).
* positions — enumerate NFPM по owner, фильтр по куриным пулам, текущая стоимость + комиссии.
*
* Цена в API — token1 per token0 (raw pool convention, human-decimal-adjusted). Текущую цену и
* символы токенов смотри в GET /lp/pools, чтобы знать в каких числах задавать диапазон.
* MVP: оба токена — ERC20 (WETH как ERC20, без нативного ETH-leg). Авто-zap — позже.
*/
import { ethers } from 'ethers';
import { pickProxiedEvmProvider } from '../lib/outbound-proxy';
import {
ETH_RPCS,
uniswapNfpmAddress,
uniswapLpPools,
isCuratedPool,
assertDefiEvmTarget,
isWeth,
NFPM_ABI,
UNIV3_POOL_ABI,
ERC20_META_ABI,
} from '../lib/defi-contracts';
import { computeAppFee } from '../lib/app-fee';
import { formatSmallestUnits } from '../lib/amount-units';
import { getPricesBySymbols } from './price-oracle.service';
import { signAndBroadcastRawEvm } from './wallet-signer.service';
import {
signAndBroadcastEvmApprove,
signAndBroadcastWrapEth,
readErc20Balance,
readEvmNativeBalance,
} from './wallet-signer-bridge';
import { signAndBroadcastEvmFeeTx } from './wallet-signer.service';
import { getPoolStats } from '../lib/uniswap-subgraph';
import {
getSqrtRatioAtTick,
priceToTick,
tickToPrice,
alignTick,
sqrtPriceX96ToPrice,
getLiquidityForAmounts,
getAmountsForLiquidity,
} from '../lib/uniswap-v3-math';
import { StakingError } from './staking.service';
import { logger } from '../lib/logger';
/** Переиспользуем типизированную ошибку (контроллер маппит httpStatus). */
function badRequest(msg: string): StakingError {
return new StakingError(msg, 'BAD_REQUEST', 400);
}
const MINT_GAS_LIMIT = '600000';
const REMOVE_GAS_LIMIT = '500000';
// Оценочные gas-лимиты шагов LP-депозита (для networkFee в quote). Соответствуют реальным:
// mint=MINT_GAS_LIMIT, approve≈APPROVE_GAS_LIMIT, fee≈ERC20 transfer, wrap≈WETH.deposit.
const EST_GAS_MINT = 600_000n;
const EST_GAS_APPROVE = 80_000n;
const EST_GAS_FEE = 65_000n;
const EST_GAS_WRAP = 60_000n;
const DEFAULT_SLIPPAGE_BPS = 100n; // 1%
const MAX_SLIPPAGE_BPS = 500n; // 5% — верхняя граница (дефолт 1%)
const SLIPPAGE_DENOM = 10000n;
const MAX_UINT128 = ((1n << 128n) - 1n).toString();
const MAX_UINT256 = (1n << 256n) - 1n;
const POSITIONS_SCAN_CAP = 50;
/** Валидация amount-строки в smallest units: целое, неотрицательное, ≤ uint256. */
function parseAmountSmallest(s: string, label: string): bigint {
if (typeof s !== 'string' || !/^\d+$/.test(s)) throw badRequest(`${label} must be a non-negative integer string`);
const v = BigInt(s);
if (v > MAX_UINT256) throw badRequest(`${label} exceeds uint256 maximum`);
return v;
}
/** Валидация slippageBps: 0..5000. Возвращает bigint bps. */
function resolveSlippageBps(slippageBps?: number): bigint {
if (slippageBps === undefined) return DEFAULT_SLIPPAGE_BPS;
if (!Number.isFinite(slippageBps) || slippageBps < 0 || slippageBps > Number(MAX_SLIPPAGE_BPS)) {
throw badRequest(`slippageBps must be between 0 and ${Number(MAX_SLIPPAGE_BPS)}`);
}
return BigInt(Math.floor(slippageBps));
}
async function getProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
return pickProxiedEvmProvider(ETH_RPCS, 1);
}
interface PoolData {
poolAddress: string;
token0: string;
token1: string;
fee: number;
tickSpacing: number;
sqrtPriceX96: bigint;
tick: number;
liquidity: bigint;
dec0: number;
dec1: number;
sym0: string;
sym1: string;
}
async function readPool(provider: ethers.providers.Provider, poolAddr: string): Promise<PoolData> {
const pool = new ethers.Contract(poolAddr, UNIV3_POOL_ABI, provider);
const [token0, token1, fee, tickSpacing, slot0, liquidity] = await Promise.all([
pool.token0() as Promise<string>,
pool.token1() as Promise<string>,
pool.fee() as Promise<number>,
pool.tickSpacing() as Promise<number>,
pool.slot0() as Promise<any>,
pool.liquidity() as Promise<ethers.BigNumber>,
]);
const t0 = new ethers.Contract(token0, ERC20_META_ABI, provider);
const t1 = new ethers.Contract(token1, ERC20_META_ABI, provider);
const [dec0, dec1, sym0, sym1] = await Promise.all([
t0.decimals() as Promise<number>,
t1.decimals() as Promise<number>,
t0.symbol().catch(() => '?') as Promise<string>,
t1.symbol().catch(() => '?') as Promise<string>,
]);
return {
poolAddress: poolAddr.toLowerCase(),
token0,
token1,
fee: Number(fee),
tickSpacing: Number(tickSpacing),
sqrtPriceX96: BigInt(slot0.sqrtPriceX96.toString()),
tick: Number(slot0.tick),
liquidity: BigInt(liquidity.toString()),
dec0: Number(dec0),
dec1: Number(dec1),
sym0,
sym1,
};
}
// ── getPools ───────────────────────────────────────────────────────────
export interface PoolInfo {
poolAddress: string;
token0: string;
token1: string;
symbol0: string;
symbol1: string;
decimals0: number;
decimals1: number;
feeTier: number;
currentPrice: number; // token1 per token0 (human)
currentTick: number;
tvlUSD: number | null;
volumeUSD24h: number | null;
aprPercent: number | null;
}
export async function getPools(): Promise<PoolInfo[]> {
const provider = await getProvider();
const pools = uniswapLpPools();
const out: PoolInfo[] = [];
for (const addr of pools) {
try {
const p = await readPool(provider, addr);
const stats = await getPoolStats(addr, p.fee);
out.push({
poolAddress: p.poolAddress,
token0: p.token0,
token1: p.token1,
symbol0: p.sym0,
symbol1: p.sym1,
decimals0: p.dec0,
decimals1: p.dec1,
feeTier: p.fee,
currentPrice: sqrtPriceX96ToPrice(p.sqrtPriceX96, p.dec0, p.dec1),
currentTick: p.tick,
tvlUSD: stats.totalValueLockedUSD,
volumeUSD24h: stats.volumeUSD24h,
aprPercent: stats.aprPercent,
});
} catch (err: any) {
// один битый пул не валит весь список — но логируем для observability
logger.warn(`LP getPools: failed to read pool ${addr}: ${err?.message || err}`);
out.push({
poolAddress: addr,
token0: '', token1: '', symbol0: '?', symbol1: '?', decimals0: 0, decimals1: 0,
feeTier: 0, currentPrice: 0, currentTick: 0, tvlUSD: null, volumeUSD24h: null, aprPercent: null,
});
}
}
return out;
}
// ── quoteAdd ───────────────────────────────────────────────────────────
/** Достаточность баланса по одной ноге (human-числа). sufficient=null → не проверялось / покрыто нативным ETH. */
interface LegBalance {
symbol: string;
have: string | null;
need: string;
sufficient: boolean | null;
shortfall: string;
}
export interface AddQuote {
poolAddress: string;
tickLower: number;
tickUpper: number;
priceLower: number;
priceUpper: number;
amount0Desired: string;
amount0DesiredHuman: string;
amount1Desired: string;
amount1DesiredHuman: string;
amount0Min: string;
amount0MinHuman: string;
amount1Min: string;
amount1MinHuman: string;
liquidity: string;
aprPercent: number | null;
// ── Наша app-комиссия 0.7% по каждой ноге (0.7% от вносимой суммы) ──
appFee0: string;
appFee0Human: string;
appFee1: string;
appFee1Human: string;
appFee0Usd: number | null;
appFee1Usd: number | null;
appFeeTotalUsd: number | null;
appFeeTotalEth: string | null; // комиссия в монете сети (ETH-эквивалент)
// ── Оценка сетевой (gas) комиссии всего LP-депозита (wrap?+fee×ноги+approve×ноги+mint) ──
networkFee: {
estGasUnits: string;
gasPriceGwei: string | null;
amountEthHuman: string | null; // оценка газа в ETH
amountUsd: number | null; // та же оценка в USD
};
// ── Проверка баланса (только если передан ownerAddress) ──
balance?: {
token0: LegBalance;
token1: LegBalance;
gas: { symbol: string; have: string | null; needReserve: string; sufficient: boolean | null };
sufficient: boolean;
};
}
interface AddParams {
poolAddress: string;
priceLower: number;
priceUpper: number;
amount0Desired: string; // smallest units (после fee для execute; до fee для quote — informational)
amount1Desired: string;
slippageBps?: number;
ownerAddress?: string; // если задан — добавляем проверку баланса token0/token1/gas
useNativeEth?: boolean; // WETH-нога покрывается нативным ETH (авто-wrap) — влияет на проверку баланса
}
/** number → human-строка без scientific notation и хвостовых нулей. */
function trimNum(n: number): string {
if (!Number.isFinite(n)) return '0';
let s = n.toFixed(12).replace(/0+$/, '').replace(/\.$/, '');
if (s === '' || s === '-0') s = '0';
return s;
}
function roundUsd(n: number): number {
return Math.round(n * 1e6) / 1e6;
}
async function computeAddPlan(provider: ethers.providers.Provider, p: AddParams): Promise<{
pool: PoolData;
tickLower: number;
tickUpper: number;
amount0Used: bigint;
amount1Used: bigint;
amount0Min: bigint;
amount1Min: bigint;
liquidity: bigint;
}> {
if (!isCuratedPool(p.poolAddress)) throw badRequest('pool not in curated list');
if (!(p.priceLower > 0) || !(p.priceUpper > 0) || p.priceLower >= p.priceUpper) {
throw badRequest('priceLower/priceUpper must be > 0 and priceLower < priceUpper');
}
const a0 = parseAmountSmallest(p.amount0Desired, 'amount0Desired');
const a1 = parseAmountSmallest(p.amount1Desired, 'amount1Desired');
if (a0 === 0n && a1 === 0n) {
throw badRequest('at least one of amount0Desired/amount1Desired must be > 0');
}
const slipBps = resolveSlippageBps(p.slippageBps);
const pool = await readPool(provider, p.poolAddress);
let tLow = alignTick(priceToTick(p.priceLower, pool.dec0, pool.dec1), pool.tickSpacing);
let tHigh = alignTick(priceToTick(p.priceUpper, pool.dec0, pool.dec1), pool.tickSpacing);
if (tLow > tHigh) [tLow, tHigh] = [tHigh, tLow];
if (tLow === tHigh) tHigh += pool.tickSpacing;
const sqrtA = getSqrtRatioAtTick(tLow);
const sqrtB = getSqrtRatioAtTick(tHigh);
const L = getLiquidityForAmounts(pool.sqrtPriceX96, sqrtA, sqrtB, a0, a1);
if (L <= 0n) throw badRequest('computed liquidity is zero — increase amounts or widen range');
const used = getAmountsForLiquidity(pool.sqrtPriceX96, sqrtA, sqrtB, L);
const minOf = (x: bigint) => (x * (SLIPPAGE_DENOM - slipBps)) / SLIPPAGE_DENOM;
return {
pool,
tickLower: tLow,
tickUpper: tHigh,
amount0Used: used.amount0,
amount1Used: used.amount1,
amount0Min: minOf(used.amount0),
amount1Min: minOf(used.amount1),
liquidity: L,
};
}
export async function quoteAdd(p: AddParams): Promise<AddQuote> {
const provider = await getProvider();
const plan = await computeAddPlan(provider, p);
const stats = await getPoolStats(plan.pool.poolAddress, plan.pool.fee);
const pool = plan.pool;
// ── Наша app-комиссия 0.7% (база — вносимая сумма, как в execute: computeAppFee(amount{0,1}Desired)) ──
const in0 = BigInt(p.amount0Desired);
const in1 = BigInt(p.amount1Desired);
const fee0 = in0 > 0n ? computeAppFee(p.amount0Desired) : 0n;
const fee1 = in1 > 0n ? computeAppFee(p.amount1Desired) : 0n;
const fee0Human = formatSmallestUnits(fee0.toString(), pool.dec0);
const fee1Human = formatSmallestUnits(fee1.toString(), pool.dec1);
// Цены (whitelist через getCoingeckoId внутри). WETH-нога → цена ETH. XAUt и пр. вне whitelist → null.
let ethPrice: number | null = null;
let price0: number | null = null;
let price1: number | null = null;
try {
const priceMap = await getPricesBySymbols([
{ chain: 'ETH', symbol: 'ETH' },
{ chain: 'ETH', symbol: pool.sym0 },
{ chain: 'ETH', symbol: pool.sym1 },
]);
ethPrice = priceMap.get('ETH:ETH') ?? null;
price0 = isWeth(pool.token0) ? ethPrice : (priceMap.get(`ETH:${pool.sym0}`) ?? null);
price1 = isWeth(pool.token1) ? ethPrice : (priceMap.get(`ETH:${pool.sym1}`) ?? null);
} catch { /* prices optional */ }
// ── Оценка сетевой (gas) комиссии: суммируем gas-лимиты шагов, которые реально пойдут ──
const t0IsWethN = isWeth(pool.token0);
const t1IsWethN = isWeth(pool.token1);
let estGas = EST_GAS_MINT; // mint всегда
if (in0 > 0n) estGas += EST_GAS_APPROVE + EST_GAS_FEE; // нога token0: approve + fee
if (in1 > 0n) estGas += EST_GAS_APPROVE + EST_GAS_FEE; // нога token1
if (p.useNativeEth && (t0IsWethN || t1IsWethN)) estGas += EST_GAS_WRAP; // авто-wrap ETH→WETH
let gasPriceWei = 0n;
try {
const fd = await provider.getFeeData();
const gp = fd.maxFeePerGas ?? fd.gasPrice ?? null;
if (gp) gasPriceWei = BigInt(gp.toString());
} catch { /* gas price optional */ }
const networkFeeWei = estGas * gasPriceWei;
const networkFeeEthHuman = gasPriceWei > 0n ? formatSmallestUnits(networkFeeWei.toString(), 18) : null;
const networkFeeUsd = (networkFeeEthHuman != null && ethPrice != null)
? roundUsd(Number(networkFeeEthHuman) * ethPrice) : null;
const gasPriceGwei = gasPriceWei > 0n ? formatSmallestUnits(gasPriceWei.toString(), 9) : null;
const fee0Usd = price0 != null ? roundUsd(Number(fee0Human) * price0) : null;
const fee1Usd = price1 != null ? roundUsd(Number(fee1Human) * price1) : null;
const usdParts = [fee0Usd, fee1Usd].filter((v): v is number => v != null);
const appFeeTotalUsd = usdParts.length ? roundUsd(usdParts.reduce((a, b) => a + b, 0)) : null;
const appFeeTotalEth = (appFeeTotalUsd != null && ethPrice != null && ethPrice > 0)
? trimNum(appFeeTotalUsd / ethPrice)
: null;
// ── Проверка баланса (только если есть ownerAddress) ──
let balance: AddQuote['balance'];
if (p.ownerAddress) {
const owner = p.ownerAddress;
const t0IsWeth = isWeth(pool.token0);
const t1IsWeth = isWeth(pool.token1);
const legBalance = async (need: bigint, tokenAddr: string, isWethLeg: boolean, sym: string, dec: number): Promise<LegBalance> => {
const needHuman = formatSmallestUnits(need.toString(), dec);
if (need === 0n) return { symbol: sym, have: '0', need: needHuman, sufficient: true, shortfall: '0' };
// WETH-нога при useNativeEth покрывается нативным ETH → проверяется в gas-ноге, здесь не блокируем.
if (p.useNativeEth && isWethLeg) return { symbol: sym, have: null, need: needHuman, sufficient: null, shortfall: '0' };
try {
const bal = await readErc20Balance('ETH', tokenAddr, owner);
const sufficient = bal >= need;
return { symbol: sym, have: formatSmallestUnits(bal.toString(), dec), need: needHuman, sufficient, shortfall: sufficient ? '0' : formatSmallestUnits((need - bal).toString(), dec) };
} catch (err: any) {
logger.warn(`LP quote balance read failed ${sym}: ${err?.message}`);
return { symbol: sym, have: null, need: needHuman, sufficient: null, shortfall: '0' };
}
};
const token0 = await legBalance(in0, pool.token0, t0IsWeth, pool.sym0, pool.dec0);
const token1 = await legBalance(in1, pool.token1, t1IsWeth, pool.sym1, pool.dec1);
const reserve = ethers.utils.parseEther('0.001').toBigInt();
let ethNeed = reserve;
if (p.useNativeEth) {
const wrapAmt = t0IsWeth ? in0 : t1IsWeth ? in1 : 0n;
ethNeed = wrapAmt + reserve;
}
let gasHave: string | null = null;
let gasSufficient: boolean | null = null;
try {
const ethBal = await readEvmNativeBalance('ETH', owner);
gasHave = formatSmallestUnits(ethBal.toString(), 18);
gasSufficient = ethBal >= ethNeed;
} catch (err: any) {
logger.warn(`LP quote ETH balance read failed: ${err?.message}`);
}
const gas = { symbol: 'ETH', have: gasHave, needReserve: formatSmallestUnits(ethNeed.toString(), 18), sufficient: gasSufficient };
balance = {
token0, token1, gas,
sufficient: token0.sufficient !== false && token1.sufficient !== false && gas.sufficient !== false,
};
}
return {
poolAddress: pool.poolAddress,
tickLower: plan.tickLower,
tickUpper: plan.tickUpper,
priceLower: tickToPrice(plan.tickLower, pool.dec0, pool.dec1),
priceUpper: tickToPrice(plan.tickUpper, pool.dec0, pool.dec1),
amount0Desired: plan.amount0Used.toString(),
amount0DesiredHuman: formatSmallestUnits(plan.amount0Used.toString(), pool.dec0),
amount1Desired: plan.amount1Used.toString(),
amount1DesiredHuman: formatSmallestUnits(plan.amount1Used.toString(), pool.dec1),
amount0Min: plan.amount0Min.toString(),
amount0MinHuman: formatSmallestUnits(plan.amount0Min.toString(), pool.dec0),
amount1Min: plan.amount1Min.toString(),
amount1MinHuman: formatSmallestUnits(plan.amount1Min.toString(), pool.dec1),
liquidity: plan.liquidity.toString(),
aprPercent: stats.aprPercent,
appFee0: fee0.toString(),
appFee0Human: fee0Human,
appFee1: fee1.toString(),
appFee1Human: fee1Human,
appFee0Usd: fee0Usd,
appFee1Usd: fee1Usd,
appFeeTotalUsd,
appFeeTotalEth,
networkFee: {
estGasUnits: estGas.toString(),
gasPriceGwei,
amountEthHuman: networkFeeEthHuman,
amountUsd: networkFeeUsd,
},
...(balance ? { balance } : {}),
};
}
// ── executeAdd ─────────────────────────────────────────────────────────
export interface AddResult {
feeTxids: string[];
approveTxids: string[];
mintTxid: string;
wrapTxid?: string;
tickLower: number;
tickUpper: number;
appFee0: string;
appFee0Human: string;
appFee1: string;
appFee1Human: string;
}
export async function executeAddLiquidity(p: {
mnemonic: string;
expectedFromAddress: string;
poolAddress: string;
priceLower: number;
priceUpper: number;
amount0Desired: string;
amount1Desired: string;
slippageBps?: number;
useNativeEth?: boolean;
}): Promise<AddResult> {
if (!isCuratedPool(p.poolAddress)) throw badRequest('pool not in curated list');
const a0 = parseAmountSmallest(p.amount0Desired, 'amount0Desired');
const a1 = parseAmountSmallest(p.amount1Desired, 'amount1Desired');
if (a0 === 0n && a1 === 0n) {
throw badRequest('at least one of amount0Desired/amount1Desired must be > 0');
}
resolveSlippageBps(p.slippageBps); // validate 0..5000 ДО любых broadcast
const provider = await getProvider();
const pool = await readPool(provider, p.poolAddress);
const nfpm = uniswapNfpmAddress();
// ── Pre-flight: баланс ОБОИХ ног ДО любых транзакций ──
// Иначе при нехватке второго токена wrap+комиссия проходили, флоу рвался на полпути, юзер оставался
// с WETH + потерянной комиссией. Теперь нехватка → throw ДО любого broadcast, НИЧЕГО не движется.
const t0IsWeth = isWeth(pool.token0);
const t1IsWeth = isWeth(pool.token1);
if (a0 > 0n && !(p.useNativeEth && t0IsWeth)) {
const bal = await readErc20Balance('ETH', pool.token0, p.expectedFromAddress);
if (bal < a0) {
throw badRequest(`Недостаточно ${pool.sym0}: нужно ${formatSmallestUnits(a0.toString(), pool.dec0)}, есть ${formatSmallestUnits(bal.toString(), pool.dec0)}`);
}
}
if (a1 > 0n && !(p.useNativeEth && t1IsWeth)) {
const bal = await readErc20Balance('ETH', pool.token1, p.expectedFromAddress);
if (bal < a1) {
throw badRequest(`Недостаточно ${pool.sym1}: нужно ${formatSmallestUnits(a1.toString(), pool.dec1)}, есть ${formatSmallestUnits(bal.toString(), pool.dec1)}`);
}
}
// WETH-нога при useNativeEth: заворачиваем ТОЛЬКО недостающее (учитываем уже имеющийся WETH от прошлых
// попыток — не плодим WETH и не требуем лишний ETH). wethDeficit переиспользуется в шаге wrap ниже.
let wethDeficit = 0n;
if (p.useNativeEth) {
const wrapAmt = t0IsWeth ? a0 : t1IsWeth ? a1 : 0n;
const wethLegAddr = t0IsWeth ? pool.token0 : t1IsWeth ? pool.token1 : '';
if (wrapAmt > 0n && wethLegAddr) {
let wethBal = 0n;
try { wethBal = await readErc20Balance('ETH', wethLegAddr, p.expectedFromAddress); } catch { /* assume 0 */ }
wethDeficit = wethBal >= wrapAmt ? 0n : wrapAmt - wethBal;
const ethBal = await readEvmNativeBalance('ETH', p.expectedFromAddress);
const need = wethDeficit + ethers.utils.parseEther('0.001').toBigInt(); // недостающий WETH + буфер на газ
if (ethBal < need) {
throw badRequest(`Недостаточно ETH: нужно ~${formatSmallestUnits(need.toString(), 18)} (wrap недостающего WETH + газ), есть ${formatSmallestUnits(ethBal.toString(), 18)}`);
}
}
}
// useNativeEth: заворачиваем ТОЛЬКО недостающий WETH (wethDeficit). Если WETH уже хватает — пропускаем
// (используем имеющийся). Дальше WETH = обычный ERC20: fee/approve/mint без изменений.
let wrapTxid: string | undefined;
if (p.useNativeEth && wethDeficit > 0n) {
const w = await signAndBroadcastWrapEth({
mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, amountWei: wethDeficit.toString(),
});
wrapTxid = w.txid;
}
// app fee 0.7% по каждому токену (net = то что реально пойдёт в пул)
const fee0 = a0 > 0n ? computeAppFee(p.amount0Desired) : 0n;
const fee1 = a1 > 0n ? computeAppFee(p.amount1Desired) : 0n;
const net0 = a0 - fee0;
const net1 = a1 - fee1;
// Порядок approve → fee → mint. approve-first минимизирует blast radius: если approve упадёт —
// fee ещё НЕ списан (ничего не потеряно). Если fee упадёт после approve — allowance безвреден.
// Только падение mint после fee теряет fee (неизбежно без on-chain escrow).
const approveTxids: string[] = [];
try {
if (net0 > 0n) {
const r = await signAndBroadcastEvmApprove({
chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress,
spender: nfpm, token: pool.token0, amount: net0.toString(),
});
approveTxids.push(r.txid);
}
if (net1 > 0n) {
const r = await signAndBroadcastEvmApprove({
chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress,
spender: nfpm, token: pool.token1, amount: net1.toString(),
});
approveTxids.push(r.txid);
}
} catch (err: any) {
throw new StakingError(`approve failed before fee — nothing charged: ${err?.message || err}`, 'APPROVE_FAILED', 502);
}
const feeTxids: string[] = [];
if (fee0 > 0n) {
const r = await signAndBroadcastEvmFeeTx({
chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress,
bridgeAmount: p.amount0Desired, bridgeToken: pool.token0, feeTier: 'normal',
});
feeTxids.push(r.feeTxid);
}
if (fee1 > 0n) {
const r = await signAndBroadcastEvmFeeTx({
chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress,
bridgeAmount: p.amount1Desired, bridgeToken: pool.token1, feeTier: 'normal',
});
feeTxids.push(r.feeTxid);
}
// план mint на net-суммах
const plan = await computeAddPlan(provider, {
poolAddress: p.poolAddress,
priceLower: p.priceLower,
priceUpper: p.priceUpper,
amount0Desired: net0.toString(),
amount1Desired: net1.toString(),
slippageBps: p.slippageBps,
});
const deadline = Math.floor(Date.now() / 1000) + 1200;
const iface = new ethers.utils.Interface(NFPM_ABI);
const data = iface.encodeFunctionData('mint', [{
token0: pool.token0,
token1: pool.token1,
fee: pool.fee,
tickLower: plan.tickLower,
tickUpper: plan.tickUpper,
amount0Desired: net0.toString(),
amount1Desired: net1.toString(),
amount0Min: plan.amount0Min.toString(),
amount1Min: plan.amount1Min.toString(),
recipient: p.expectedFromAddress,
deadline,
}]);
assertDefiEvmTarget(nfpm);
let mint: { txid: string };
try {
mint = await signAndBroadcastRawEvm({
chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, feeTier: 'normal',
tx: { to: nfpm, data, value: '0', chainId: 1, gas: MINT_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' },
});
} catch (err: any) {
throw new StakingError(
`mint failed after fee charged (feeTxids: ${feeTxids.join(',') || 'none'}): ${err?.message || err}`,
'MINT_FAILED', 502,
);
}
return {
feeTxids,
approveTxids,
mintTxid: mint.txid,
wrapTxid,
tickLower: plan.tickLower,
tickUpper: plan.tickUpper,
appFee0: fee0.toString(),
appFee0Human: formatSmallestUnits(fee0.toString(), pool.dec0),
appFee1: fee1.toString(),
appFee1Human: formatSmallestUnits(fee1.toString(), pool.dec1),
};
}
// ── executeRemove ──────────────────────────────────────────────────────
export interface RemoveResult {
removeTxid: string;
removedLiquidity: string;
burned: boolean;
}
export async function executeRemoveLiquidity(p: {
mnemonic: string;
expectedFromAddress: string;
tokenId: string;
percent: number;
}): Promise<RemoveResult> {
if (!/^\d+$/.test(p.tokenId)) throw badRequest('tokenId must be a positive integer string');
if (!Number.isFinite(p.percent) || p.percent <= 0 || p.percent > 100) {
throw badRequest('percent must be in (0, 100]');
}
const provider = await getProvider();
const nfpm = uniswapNfpmAddress();
const c = new ethers.Contract(nfpm, NFPM_ABI, provider);
// ownership guard
const owner: string = await c.ownerOf(p.tokenId);
if (owner.toLowerCase() !== p.expectedFromAddress.toLowerCase()) {
throw badRequest('position not owned by user');
}
const pos: any = await c.positions(p.tokenId);
const liquidity = BigInt(pos.liquidity.toString());
if (liquidity <= 0n) throw badRequest('position has no liquidity');
const removeLiq = (liquidity * BigInt(Math.round(p.percent))) / 100n;
if (removeLiq <= 0n) throw badRequest('computed removal liquidity is zero');
const deadline = Math.floor(Date.now() / 1000) + 1200;
const iface = new ethers.utils.Interface(NFPM_ABI);
const calls: string[] = [];
calls.push(iface.encodeFunctionData('decreaseLiquidity', [{
tokenId: p.tokenId,
liquidity: removeLiq.toString(),
amount0Min: '0',
amount1Min: '0',
deadline,
}]));
// collect тело + накопленные комиссии юзеру
calls.push(iface.encodeFunctionData('collect', [{
tokenId: p.tokenId,
recipient: p.expectedFromAddress,
amount0Max: MAX_UINT128,
amount1Max: MAX_UINT128,
}]));
const burned = p.percent >= 100;
if (burned) {
calls.push(iface.encodeFunctionData('burn', [p.tokenId]));
}
const data = iface.encodeFunctionData('multicall', [calls]);
assertDefiEvmTarget(nfpm);
const res = await signAndBroadcastRawEvm({
chain: 'ETH', mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, feeTier: 'normal',
tx: { to: nfpm, data, value: '0', chainId: 1, gas: REMOVE_GAS_LIMIT, maxFeePerGas: '0', maxPriorityFeePerGas: '0' },
});
return { removeTxid: res.txid, removedLiquidity: removeLiq.toString(), burned };
}
// ── readPositions ──────────────────────────────────────────────────────
export interface LpPosition {
tokenId: string;
poolAddress: string;
symbol0: string;
symbol1: string;
feeTier: number;
tickLower: number;
tickUpper: number;
priceLower: number;
priceUpper: number;
liquidity: string;
currentAmount0: string;
currentAmount0Human: string;
currentAmount1: string;
currentAmount1Human: string;
feesOwed0: string;
feesOwed0Human: string;
feesOwed1: string;
feesOwed1Human: string;
inRange: boolean;
}
export async function readLpPositions(
address: string,
): Promise<{ chain: 'ETH'; positions: LpPosition[]; totalNfts: number; scanned: number; truncated: boolean }> {
const provider = await getProvider();
const nfpm = uniswapNfpmAddress();
const c = new ethers.Contract(nfpm, NFPM_ABI, provider);
// карта куриных пулов по ключу token0|token1|fee
const curated = await Promise.all(uniswapLpPools().map((a) => readPool(provider, a).catch(() => null)));
const byKey = new Map<string, PoolData>();
for (const cp of curated) {
if (cp) byKey.set(`${cp.token0.toLowerCase()}|${cp.token1.toLowerCase()}|${cp.fee}`, cp);
}
const balBn: ethers.BigNumber = await c.balanceOf(address);
const totalNfts = Number(balBn.toString());
// ERC721Enumerable не гарантирует стабильный порядок; при > cap часть позиций не сканируется.
const bal = Math.min(totalNfts, POSITIONS_SCAN_CAP);
const positions: LpPosition[] = [];
for (let i = 0; i < bal; i++) {
let tokenId: ethers.BigNumber;
try {
tokenId = await c.tokenOfOwnerByIndex(address, i);
} catch {
continue;
}
let pos: any;
try {
pos = await c.positions(tokenId);
} catch {
continue;
}
const key = `${String(pos.token0).toLowerCase()}|${String(pos.token1).toLowerCase()}|${Number(pos.fee)}`;
const pool = byKey.get(key);
if (!pool) continue; // не наш куриный пул
const tickLower = Number(pos.tickLower);
const tickUpper = Number(pos.tickUpper);
const liquidity = BigInt(pos.liquidity.toString());
const sqrtA = getSqrtRatioAtTick(tickLower);
const sqrtB = getSqrtRatioAtTick(tickUpper);
const amounts = getAmountsForLiquidity(pool.sqrtPriceX96, sqrtA, sqrtB, liquidity);
positions.push({
tokenId: tokenId.toString(),
poolAddress: pool.poolAddress,
symbol0: pool.sym0,
symbol1: pool.sym1,
feeTier: pool.fee,
tickLower,
tickUpper,
priceLower: tickToPrice(tickLower, pool.dec0, pool.dec1),
priceUpper: tickToPrice(tickUpper, pool.dec0, pool.dec1),
liquidity: liquidity.toString(),
currentAmount0: amounts.amount0.toString(),
currentAmount0Human: formatSmallestUnits(amounts.amount0.toString(), pool.dec0),
currentAmount1: amounts.amount1.toString(),
currentAmount1Human: formatSmallestUnits(amounts.amount1.toString(), pool.dec1),
feesOwed0: pos.tokensOwed0.toString(),
feesOwed0Human: formatSmallestUnits(pos.tokensOwed0.toString(), pool.dec0),
feesOwed1: pos.tokensOwed1.toString(),
feesOwed1Human: formatSmallestUnits(pos.tokensOwed1.toString(), pool.dec1),
inRange: pool.tick >= tickLower && pool.tick < tickUpper,
});
}
return { chain: 'ETH', positions, totalNfts, scanned: bal, truncated: totalNfts > POSITIONS_SCAN_CAP };
}

View File

@@ -3,7 +3,6 @@
* Server-side для custodial-флоу. * Server-side для custodial-флоу.
*/ */
import '../lib/btc-ecc'; // initEccLib(ecc) до любых BTC-операций — нужно для выхода на taproot fee-адрес (bc1p)
import { ethers } from 'ethers'; import { ethers } from 'ethers';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import bs58 from 'bs58'; import bs58 from 'bs58';

View File

@@ -8,7 +8,6 @@ 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 { getPricesBySymbols } from './price-oracle.service';
import { logger } from '../lib/logger'; 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';
@@ -212,126 +211,6 @@ export async function getBalance(chain: ChainCode, address: string): Promise<Bal
return 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> {
const res = await fetchJson(`${BLOCKSTREAM}/address/${address}`); const res = await fetchJson(`${BLOCKSTREAM}/address/${address}`);
const stats = res.chain_stats; const stats = res.chain_stats;

View File

@@ -1,934 +0,0 @@
/**
* 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 '../lib/btc-ecc'; // initEccLib(ecc) до любых BTC-операций — нужно для выхода на taproot fee-адрес (bc1p)
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';
import { WETH_ADDRESS } from '../lib/defi-contracts';
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). Считаем один раз на все tx.
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;
// Отправить один approve(spender, value), дождаться 1 conf. Nonce берётся 'pending' перед КАЖДОЙ tx
// (после reset предыдущая уже подтверждена → следующая получит инкремент).
const sendOne = async (value: ethers.BigNumber): Promise<string> => {
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
const data = token.interface.encodeFunctionData('approve', [p.spender, value]);
const sent = await signer.sendTransaction({
to: p.token, data, value: 0, chainId: expectedChainId, nonce,
gasLimit: APPROVE_GAS_LIMIT, type: 2, maxFeePerGas, maxPriorityFeePerGas,
});
await Promise.race([
sent.wait(1),
new Promise((_, reject) => setTimeout(() => reject(new Error('approve confirm timeout')), 60_000)),
]);
return sent.hash;
};
const needed = ethers.BigNumber.from(p.amount);
// Текущий allowance — для idempotency + USDT-safe reset. (amount '0'/revoke ниже не попадает в эти ветки.)
let current = ethers.BigNumber.from(0);
try {
current = await token.allowance(wallet.address, p.spender);
} catch (err: any) {
logger.warn(`EVM approve: allowance read failed (assume 0): ${err?.message}`);
}
// Уже достаточно → не шлём лишнюю tx (повторы, и WETH-нога с остаточным allowance).
if (needed.gt(0) && current.gte(needed)) {
logger.info(`EVM approve skip: allowance ${current.toString()} >= needed ${needed.toString()} (token=${p.token} spender=${p.spender})`);
return { txid: '' };
}
// USDT-style: контракт реверит approve(ненулевое) пока allowance != 0 → сначала reset в 0, потом ставим нужное.
if (needed.gt(0) && current.gt(0)) {
logger.info(`EVM approve reset-to-0 (USDT-safe): ${current.toString()} → 0 → ${needed.toString()} (token=${p.token} spender=${p.spender})`);
await sendOne(ethers.constants.Zero);
}
const txid = await sendOne(needed);
logger.info(`EVM approve confirmed: chain=${p.chain} token=${p.token} spender=${p.spender} amount=${p.amount} txid=${txid}`);
return { txid };
}
/**
* Wrap native ETH → WETH (WETH.deposit{value}). Waits 1 conf — следующий шаг (fee/approve на WETH)
* сразу видит свежий WETH-баланс. ETH-only. Используется в LP-депозите для пулов с WETH-ногой.
*/
export async function signAndBroadcastWrapEth(p: {
mnemonic: string;
expectedFromAddress: string;
amountWei: string;
}): Promise<{ txid: string }> {
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
if (wallet.address.toLowerCase() !== p.expectedFromAddress.toLowerCase()) {
throw new Error(`Derived ETH address ${wallet.address} ≠ stored ${p.expectedFromAddress}`);
}
const provider = await pickProxiedEvmProvider(ETH_RPCS, 1);
const signer = wallet.connect(provider);
const amount = ethers.BigNumber.from(p.amountWei);
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 gasLimit = ethers.BigNumber.from(60_000); // WETH.deposit ~27-45k
const bal = await provider.getBalance(wallet.address);
if (bal.lt(amount.add(maxFeePerGas.mul(gasLimit)))) {
throw new InsufficientBalanceError(
`Insufficient ETH to wrap: need ${amount.toString()} wei + gas, have ${bal.toString()}`,
);
}
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
const data = new ethers.utils.Interface(['function deposit() payable']).encodeFunctionData('deposit', []);
const sent = await signer.sendTransaction({
to: ethers.utils.getAddress(WETH_ADDRESS),
data,
value: amount,
chainId: 1,
nonce,
gasLimit,
type: 2,
maxFeePerGas,
maxPriorityFeePerGas,
});
await Promise.race([
sent.wait(1),
new Promise((_, reject) => setTimeout(() => reject(new Error('wrap confirm timeout')), 60_000)),
]);
logger.info(`ETH→WETH wrap confirmed: amount=${p.amountWei} 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

@@ -8,17 +8,13 @@
* 5. broadcast'ит в сеть * 5. broadcast'ит в сеть
*/ */
import '../lib/btc-ecc'; // initEccLib(ecc) до любых BTC-операций — нужно для выхода на taproot fee-адрес (bc1p)
import { ethers } from 'ethers'; import { ethers } from 'ethers';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import * as bip39 from 'bip39'; 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 { import { Keypair, Connection, PublicKey, SystemProgram, Transaction, ComputeBudgetProgram, VersionedTransaction } from '@solana/web3.js';
Keypair, Connection, PublicKey, SystemProgram, Transaction, ComputeBudgetProgram,
VersionedTransaction, TransactionMessage, AddressLookupTableAccount, TransactionInstruction,
} from '@solana/web3.js';
import { import {
getAssociatedTokenAddressSync, getAssociatedTokenAddressSync,
createAssociatedTokenAccountIdempotentInstruction, createAssociatedTokenAccountIdempotentInstruction,
@@ -29,12 +25,6 @@ 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 { getTokenInfo } from '../lib/token-registry';
import type { ChainCode } from '../lib/address-validators'; import type { ChainCode } from '../lib/address-validators';
@@ -155,9 +145,8 @@ 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. BRIDGE path → через OUTBOUND_PROXY_URL если задан (Jupiter/Relay // H29 — RPC failover
// часто rate-limit'ят cloud-IP'ы). Fallback на direct если env пустой. const provider = await pickProvider(rpcs, expectedChainId);
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.
@@ -218,114 +207,6 @@ 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;
// ERC20 fee: pre-check баланса токена — иначе transfer revert'нёт on-chain (потеря газа).
if (!isNative) {
const erc20 = new ethers.Contract(
p.bridgeToken as string,
['function balanceOf(address) view returns (uint256)'],
provider,
);
const tokBal: ethers.BigNumber = await erc20.balanceOf(wallet.address);
if (tokBal.lt(ethers.BigNumber.from(feeAmountBig.toString()))) {
throw new Error(
`Insufficient token balance for 0.7% fee (need ${feeAmountBig.toString()}, have ${tokBal.toString()})`,
);
}
}
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;
@@ -538,7 +419,6 @@ 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) {
@@ -547,16 +427,6 @@ 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) ───── // ─── SOL custodial sign-and-broadcast (для Relay bridge SOL-side) ─────
export interface SignSolanaTxParams { export interface SignSolanaTxParams {
@@ -610,8 +480,7 @@ export async function signAndBroadcastSolanaTx(p: SignSolanaTxParams): Promise<{
tx.sign([keypair]); tx.sign([keypair]);
// Bridge path — через OUTBOUND_PROXY_URL если задан (Relay SOL-side / Jupiter serialized). const conn = getSolConnection();
const conn = getBridgeSolConnection();
const sig = await conn.sendRawTransaction(tx.serialize()); const sig = await conn.sendRawTransaction(tx.serialize());
try { try {
@@ -635,167 +504,6 @@ export async function signAndBroadcastSolanaTx(p: SignSolanaTxParams): Promise<{
return { signature: 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 };
}
/**
* Подписать + бродкастить SERVER-BUILT Solana инструкции (legacy Transaction).
* Для custodial DeFi (нативный стейкинг): сервер строит StakeProgram-инструкции, подписывает
* user-ключом + опциональными extraSigners (напр. свеже-сгенерённый stake-account keypair).
*
* Direct connection (НЕ bridge-proxy) — стейкинг не rate-limit-sensitive.
*/
export async function signAndBroadcastSolBuilt(p: {
mnemonic: string;
expectedFromAddress: string;
instructions: TransactionInstruction[];
extraSigners?: Keypair[];
}): 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');
if (!Array.isArray(p.instructions) || p.instructions.length === 0) {
throw new Error('SOL built tx: empty instructions');
}
const conn = getSolConnection();
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
const tx = new Transaction({ feePayer: keypair.publicKey, blockhash, lastValidBlockHeight });
for (const ix of p.instructions) tx.add(ix);
const signers = [keypair, ...(p.extraSigners ?? [])];
tx.sign(...signers);
const sig = await conn.sendRawTransaction(tx.serialize());
try {
await conn.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, 'confirmed');
} catch (err: any) {
const name = err?.name || '';
if (name === 'TransactionExpiredBlockheightExceededError') {
throw new Error(`SOL built tx EXPIRED (blockhash invalid). sig=${sig}`);
}
if (name === 'TransactionExpiredTimeoutError') {
throw new Error(`SOL built tx unconfirmed after timeout (may still land). sig=${sig}`);
}
throw new Error(`SOL built tx 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 }> {

File diff suppressed because it is too large Load Diff

9
pnpm-lock.yaml generated
View File

@@ -80,9 +80,6 @@ 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
@@ -1921,10 +1918,6 @@ 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'}
@@ -4257,8 +4250,6 @@ 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,9 +29,7 @@ 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).
# Не используйте `docker compose down -v` — удалит keydb_data (кэш/idempotency). echo "[INFO] Building and starting container..."
# Не пересоздавайте 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..."