diff --git a/.env.example b/.env.example index dbe4ff6..5f31b24 100644 --- a/.env.example +++ b/.env.example @@ -1,79 +1,74 @@ -# ── Vault (AppRole) ──────────────────────────────────────────────── -VAULT_ADDR= -VAULT_ROLE_ID= -VAULT_SECRET_ID= +# ───────────────────────────────────────────────────────────────────── +# Production .env template для CryptoWallet API. +# Скопируй: cp .env.example .env && chmod 600 .env && nano .env +# ───────────────────────────────────────────────────────────────────── + +# ─── HashiCorp Vault (для master-key, JWT public keys, CSRF secret) ── +VAULT_ADDR=https://vault.your-domain.com +VAULT_ROLE_ID=00000000-0000-0000-0000-000000000000 +VAULT_SECRET_ID=00000000-0000-0000-0000-000000000000 VAULT_MOUNT_POINT=dev-secrets -VAULT_SECRET_PATH=database +VAULT_SECRET_PATH=crypto/master VAULT_JWT_KID_PATH=jwt/kid -VAULT_JWT_KIDS_PREFIX=jwt/kids - -# CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF) +VAULT_JWT_KIDS_PREFIX=jwt/kids/ VAULT_CSRF_PATH=csrf - -# Crypto master-key для шифрования мнемоник юзеров (AES-256-GCM). -# В Vault лежит hex-строка длиной 64 (32 байта). -# Положить: vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32) VAULT_CRYPTO_KEY_PATH=crypto/master -# ── JWT (внешний bitok issuer) ───────────────────────────────────── -# bitok-сервис подписывает JWT своим приватником, public key регистрируется -# в Vault под kid'ом (см. VAULT_JWT_KIDS_PREFIX). -# Allowed alg: RS256 / RS384 / RS512 / ES256 / ES384 / ES512 / EdDSA / PS256 / PS384 / PS512 +# ─── JWT (приём от bitok external issuer) ──────────────────────────── JWT_ALGORITHM=RS256 JWT_ISSUER=bitok -JWT_AUDIENCE=elcsa +JWT_AUDIENCE=cryptowallet-api -# ── Server ───────────────────────────────────────────────────────── +# ─── API runtime ───────────────────────────────────────────────────── API_PORT=3001 -LOG_LEVEL=INFO +LOG_LEVEL=info -# ── KeyDB / Redis (idempotency cache) ────────────────────────────── -# REDIS_PASSWORD also used by docker-compose to seed KeyDB --requirepass. -REDIS_HOST=keydb -REDIS_PORT=6379 -REDIS_PASSWORD= -REDIS_DB=0 - -# ── CORS ──────────────────────────────────────────────────────────── -# Comma-separated list of allowed origins, OR "*" для wildcard (dev/staging). -# ПУСТО = no cross-origin (fail-secure). -# Wildcard incompatible с CORS_ALLOW_CREDENTIALS=true (browser spec — credentials force=false). -# Production: явный whitelist для security (XSS на любом сайте не сможет дёрнуть API). -# Whitelist: CORS_ORIGINS=https://app.example.com,https://www.example.com -# Wildcard: CORS_ORIGINS=* -CORS_ORIGINS= +# CORS — comma-separated list разрешённых origins (фронтенд hosts) +CORS_ORIGINS=https://app.your-domain.com,https://admin.your-domain.com CORS_ALLOW_CREDENTIALS=true -# ── External API keys (optional, fallback если Vault их не выдаёт) ─ -RELAY_API_KEY= -TRON_API_KEY= -JUPITER_API_KEY= -JUPITER_REFERRAL_ACCOUNT= -JUPITER_FEE_BPS=70 +# ─── Postgres (для docker-compose) ─────────────────────────────────── +# Эти переменные используются compose'ом для создания/connect к Postgres контейнеру. +# Если оператор использует external managed Postgres — игнорь POSTGRES_* и впиши +# connection string в DATABASE_URL ниже. +POSTGRES_USER=cryptowallet +POSTGRES_PASSWORD=__GENERATE_STRONG_PASSWORD__ +POSTGRES_DB=cryptowallet -# ── Block explorers (optional, для tx history) ───────────────────── -ETHERSCAN_API_KEY= -BSCSCAN_API_KEY= +# Если используешь managed/external Postgres — раскомментируй и заполни: +# DATABASE_URL=postgres://user:pass@host:5432/dbname -# ── Price oracle (optional) ───────────────────────────────────────── -# CoinGecko Demo API key. Без ключа работает free tier (~10-30 req/min). -# Если задан → передаётся через header `x-cg-demo-api-key`. -# Используется в /api/wallets/{chain}/balance (для usdPrice/usdValue) -# и /api/prices?symbols=... KeyDB cache: 5 минут. +# ─── KeyDB / Redis (idempotency cache + NearIntents asset map cache) ─ +# REDIS_HOST=keydb имя service в compose — НЕ меняй если работаешь через compose +REDIS_HOST=keydb +REDIS_PORT=6379 +REDIS_PASSWORD=__GENERATE_STRONG_PASSWORD__ +REDIS_DB=0 + +# ─── Внешние API ───────────────────────────────────────────────────── +# CoinGecko — для prices/dynamics (без ключа работает с rate limits) COINGECKO_API_KEY= -# ── Outbound proxy для swap + bridge (optional) ───────────────────── -# Если задан — все calls к Jupiter / Relay / EVM RPC / Solana RPC / TronGrid -# из swap-orchestrator (custodial /wallets/{chain}/swap), relay-proxy -# (/api/relay/*), sign-raw-evm-tx, sign-and-broadcast-tx идут через -# этот HTTP proxy (squid-style). Read-only endpoints (/balance, -# /transactions, /send, /prices) идут direct. -# Format: http://[user:pass@]host:port (HTTPS proxy: https:// prefix) +# Jupiter — для SOL custodial swap (без ключа = lower rate limits) +JUPITER_API_KEY= + +# Jupiter referral — если хочешь чтобы SOL swap fees шли через Jupiter feeAccount. +# Сейчас у нас атомарный 0.7% fee atomic в swap-orchestrator (на APP_FEE_WALLET_SOL), +# referral отдельный механизм. Можно оставить пустым. +JUPITER_REFERRAL_ACCOUNT= + +# TronGrid — для TRX queries (без ключа 3 req/sec, с ключом 100/sec) +# Получить: https://www.trongrid.io/dashboard +TRON_API_KEY= + +# Outbound HTTP proxy (опц.) — если хотите чтобы Jupiter/Relay/NearIntents calls +# шли через proxy (rotation IP для rate limits). Формат: http://user:pass@host:port OUTBOUND_PROXY_URL= -# ── DB fallback (если Vault недоступен при старте) ───────────────── -DB_HOST= -DB_PORT=5432 -DB_USER= -DB_PASSWORD= -DB_NAME= +# ─── App fee wallets ───────────────────────────────────────────────── +# Эти адреса HARDCODED в backend (apps/api/src/lib/app-fee.ts), НЕ настраиваются +# через env. Если нужно изменить — code review + rebuild. +# EVM (ETH+BSC): 0xeb9fbf0d137ef5ea7b9959044c2ed44ec1206c68 +# SOL: DQkQegoX698XkcXZ6VX9P1qUpbV64Sgjz1BCPFgfWpjD +# TRX: TRwpFjnfMBe4aDJbHYEqeUVCG1auF8wFXP +# BTC: — (not collectable) diff --git a/README.md b/README.md index fadebf0..b939f17 100644 --- a/README.md +++ b/README.md @@ -34,26 +34,28 @@ vault kv put dev-secrets/jwt/kids/ \ # Залить bundle на сервер scp -P 2222 -r deployserver/ server@:~/cryptowallet/ -# На сервере: заполнить .env, поднять +# На сервере: убедись что .env заполнен (VAULT_*, JWT_*, CORS_ORIGINS, REDIS_PASSWORD, ...) ssh server@ -p 2222 -cd ~/cryptowallet -cp .env.example .env -chmod 600 .env -nano .env # заполни VAULT_*, JWT_*, CORS_ORIGINS -./start.sh +cd ~/cryptowallet/deployserver +docker compose up -d --build +docker compose logs -f api +curl http://localhost:3001/api/health ``` -В `.env` **обязательны**: `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`, `JWT_ISSUER=bitok`, `JWT_AUDIENCE`, `CORS_ORIGINS`. +API **не делает migrations / DROP / ALTER** при старте — только INSERT/UPDATE/SELECT. Schema (если нужны новые колонки/таблицы для нового функционала) обновляется только руками: `psql -f cryptowallet-schema.sql` (script append-only — `CREATE TABLE IF NOT EXISTS` / `ALTER TABLE ADD COLUMN IF NOT EXISTS`, никаких DROP). ## Update / Rebuild ```bash -scp -P 2222 -r deployserver/apps server@:~/cryptowallet/ -ssh server@ -p 2222 'cd cryptowallet && docker compose up -d --build' +# Залить новый src + rebuild api (keydb данные не теряются) +scp -P 2222 -r deployserver/apps deployserver/Dockerfile deployserver/docker-compose.yml \ + server@:~/cryptowallet/deployserver/ +ssh server@ -p 2222 'cd cryptowallet/deployserver && docker compose up -d --build' ``` ## Endpoints +### Core wallet management | Method | Path | Описание | |---|---|---| | GET | `/api/health` | Liveness (public) | @@ -61,13 +63,61 @@ ssh server@ -p 2222 'cd cryptowallet && docker compose up -d --build' | GET | `/api/docs/swagger.json` | OpenAPI JSON | | POST | **`/api/wallets/create`** | Сервер создаёт коша (no body, returns 5 addresses) | | GET | `/api/wallets` | Список адресов юзера | +| GET | `/api/wallets/portfolio` | Аггрегированный USD portfolio по всем 5 chains (KeyDB cached 1h, stale fallback) | | POST | **`/api/wallets/mnemonic/reveal`** | Reveal seed (body confirm + 5/час rate-limit) | | GET | `/api/wallets/{chain}/balance` | Баланс (native + все известные токены чейна) | | GET | `/api/wallets/{chain}/transactions` | История tx | | POST | **`/api/wallets/{chain}/send`** | Сервер подписывает + broadcast. Body: `{to, amount, token?, feeTier?}` | +| POST | `/api/wallets/{chain}/send/cost-estimate` | Pre-flight gas/fee estimate (no broadcast) | | GET | **`/api/wallets/{chain}/gas-suggestions`** | Slow/normal/fast tiers (ETH/BSC, parsed из eth_feeHistory) | -| POST | **`/api/wallets/{chain}/sign-raw-evm-tx`** | Подписать произвольную EVM tx (для Relay/Swap execute responses) | -| — | `/api/btc/*` `/api/tron/*` `/api/sol/*` `/api/bsc/*` `/api/relay/*` | Proxy endpoints (swap quote/build, bridge quote/execute/status) | +| POST | **`/api/wallets/{chain}/sign-raw-evm-tx`** | Подписать произвольную EVM tx (для Relay execute steps). Поддерживает opt-in `bridgeAmount`+`bridgeToken` → автоматически взимает 0.7% app fee на EVM fee wallet ПЕРЕД main tx (atomic). | +| POST | `/api/wallets/SOL/sign-and-broadcast-tx` | Sign+broadcast serialized VersionedTransaction (Jupiter/Relay SOL steps) | + +### Bridge & Swap (NEW) +| Method | Path | Описание | +|---|---|---| +| POST | **`/api/bridge/execute`** | One-click bridge orchestrator. Auto-routes: TRX source → NearIntents 1Click; EVM/SOL source → Jumper (LiFi) или Relay. Atomic fee tx ДО main bridge. Idempotency-Key support. Returns `{feeTxid?, approveTxid?, bridgeTxid, trackerUrl, provider}`. | +| GET | **`/api/jumper/quote-best`** | Bridge quote с NearIntents priority + smart LiFi fallback. JWT-binds fromAddress. | +| GET | `/api/jumper/{chains,tools,tokens,connections,status}` | LiFi metadata proxies (JWT-protected) | +| POST | `/api/jumper/advanced/{routes,stepTransaction}` | LiFi multi-route preview / step tx fetcher | +| POST | `/api/wallets/{chain}/swap/quote` | Custodial swap quote (BSC PancakeSwap / SOL Jupiter / TRX SunSwap) — 30s locked minOut | +| POST | **`/api/wallets/{chain}/swap`** | Confirm custodial swap (BSC/SOL/TRX). BSC + SOL: 0.7% atomic fee ДО main swap. TRX: existing on-chain FeeSwapRouter. | +| POST | `/api/wallets/{chain}/swap/cost-estimate` | Swap pre-flight cost estimate | +| — | `/api/relay/*` | Relay.link bridge proxy (quote / execute / status / cost-estimate). Unchanged from previous deploys. | + +### App fee 0.7% (NEW — hardcoded recipients) +| Method | Path | Описание | +|---|---|---| +| POST | **`/api/wallets/{chain}/app-fee`** | Standalone fee transfer endpoint. Для **Relay frontend hook** — клиент явно вызывает после Relay execute. Body: `{amount, token?}`. Server validates JWT-bind + computes 0.7% + signs + broadcasts. Idempotency-Key support. | + +Fee wallets (захардкожены в `lib/app-fee.ts`, нельзя override через env): + +| Chain | Recipient address | +|---|---| +| EVM (ETH+BSC) | `0xeb9fbf0d137ef5ea7b9959044c2ed44ec1206c68` | +| SOL | `DQkQegoX698XkcXZ6VX9P1qUpbV64Sgjz1BCPFgfWpjD` | +| TRX | `TRwpFjnfMBe4aDJbHYEqeUVCG1auF8wFXP` | +| BTC | (not collectable — no fee wallet) | + +Fee рассчёт: `feeAmount = amount × 70 / 10000` (BigInt-precision, 0 если < 144 smallest units). + +### Tokens & Prices (NEW) +| Method | Path | Описание | +|---|---|---| +| GET | **`/api/tokens`** `?chain={ETH,BSC,BTC,TRX,SOL}` `?bridgeable=true` | Token registry. `bridgeable=true` фильтрует только tokens с реальным bridge route (используется Jumper UI dropdowns) | +| GET | `/api/prices` `?symbols=BTC,ETH,USDT` `?chain={...}` | CoinGecko cached USD prices | +| GET | **`/api/prices/dynamics`** `?symbols=...` | 24h price change % для нескольких символов одним запросом | + +### Provider integrations (NEW) +- **NearIntents 1Click** (`https://1click.chaindefuser.com`) — direct TRX bridges. Asset map fetched dynamically через `/v0/tokens` (cached 1h in-memory). Quote → user transfers TRX/USDT to depositAddress → solver delivers cross-chain. No JWT для test env (0.2% NearIntents fee included в quote). Implementation: `src/lib/nearintents-client.ts`. +- **Jumper / LiFi** (`https://li.quest/v1`) — multi-chain bridge proxy + quote-best smart routing с NearIntents priority. Implementation: `src/routes/jumper-proxy.routes.ts`. +- **Relay.link** (`https://api.relay.link`) — EVM/SOL/BTC bridge proxy. **Unchanged** (preservation invariant). Implementation: `src/routes/relay-proxy.routes.ts`. + +### Audit log events (extended) +- `wallet.create`, `wallet.send`, `wallet.swap` (existing) +- `wallet.sign_raw_evm` (existing, +`bridgeAmount` meta) +- `wallet.bsc_fee` (existing) / `wallet.app_fee` (NEW — standalone fee endpoint) +- `bridge.execute`, `bridge.execute.broadcast` (NEW — bridge orchestrator) ## Security highlights @@ -87,12 +137,19 @@ ssh server@ -p 2222 'cd cryptowallet && docker compose up -d --build' - **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` +- **Audit log в stdout** (структурированный JSON с `"level":"audit"`) — `wallet.create` / `wallet.send` / `wallet.swap` / `wallet.sign_raw_evm` / `wallet.bsc_fee` / `wallet.app_fee` / `mnemonic.reveal` / `bridge.execute` / `bridge.execute.broadcast` - **Hourly key rotation** — JWT keys + CSRF secret из Vault (master-key НЕ ротируется) - **Fail-fast** — сервис не стартует без master-key, JWT_ISSUER, JWT_AUDIENCE - **Container hardening** — read-only fs, cap_drop ALL, no-new-privileges, pids/mem/cpu limits, non-root uid 1001, loopback-only port - **Relay proxy** whitelist method+path — `/quote` (POST), `/intents/status/v3` (GET), `/execute/{swap|bridge}` (POST). Никаких freeform action'ов -- **Bridge UI dropdown** ETH/BSC/SOL only (TRX через Relay убран — плохая ликвидность) +- **Jumper proxy** whitelist аналогично — `/quote`, `/quote-best`, `/chains`, `/tools`, `/tokens`, `/connections`, `/status`, `/advanced/{routes,stepTransaction}`. JWT-binds `fromAddress` к user wallet. +- **NearIntents direct integration** для TRX bridges — `signAndBroadcast(TRX)` через battle-tested sendTrx (4-layer MITM defense сохраняется). Asset map fetched через `/v0/tokens` (auth source) и cached 1h в-memory. depositAddress validated через Tron base58 regex `^T[1-9A-HJ-NP-Za-km-z]{33}$`. +- **App fee 0.7%** atomic enforcement в bridge-execute orchestrator + custodial swap orchestrator. Fee tx FIRST → если fail, main tx НЕ broadcast (atomic abort). Recipients hardcoded — нельзя override через body/env. +- **BridgeSimulationError** для EVM bridges — pre-broadcast `eth_call` dry-run; если revert → 400 `SIMULATION_FAILED`, fees не сгорают. +- **InsufficientBalanceError** pre-check на bridge — отвергаем 400 ДО подписи если balance < amount + reserve. +- **Anti-MEV** в bridge-execute — server re-quotes ПЕРЕД sign, валидирует `minAmountOut ≥ acceptedMinOut` (50 bps cushion). Если price moved → 409. +- **Idempotency-Key** на mutating endpoints (send / sign-raw / swap / bridge-execute / app-fee) — KeyDB cache 10 min, retry-safe. +- **TronGrid 429 retry** с exponential backoff (0/1.5/4/8s) для free-tier rate limits. ## Schema is non-destructive diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 57da8f1..86c3042 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -12,10 +12,12 @@ import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } import { errorHandler } from './middleware/error-handler'; import walletRoutes from './routes/wallet.routes'; import relayProxyRoutes from './routes/relay-proxy.routes'; +import jumperProxyRoutes from './routes/jumper-proxy.routes'; import tronProxyRoutes from './routes/tron-proxy.routes'; import btcProxyRoutes from './routes/btc-proxy.routes'; import pricesRoutes from './routes/prices.routes'; import tokensRoutes from './routes/tokens.routes'; +import bridgeRoutes from './routes/bridge.routes'; const app = express(); @@ -102,6 +104,9 @@ app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter); // Mutating (proxy + read endpoints) — повышенный лимит app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes); 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/btc', ...protect, mutateLimiter, btcProxyRoutes); // Legacy non-custodial swap proxies (/api/bsc/swap, /api/sol/swap, /api/tron/swap) @@ -113,6 +118,11 @@ 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 app.use((_req, res) => { res.status(404).json({ success: false, error: 'Not found' }); diff --git a/apps/api/src/controllers/prices.controller.ts b/apps/api/src/controllers/prices.controller.ts index 4a05167..24e5533 100644 --- a/apps/api/src/controllers/prices.controller.ts +++ b/apps/api/src/controllers/prices.controller.ts @@ -11,6 +11,7 @@ import { Request, Response } from 'express'; import { getCoingeckoId } from '../lib/token-registry'; import { ALL_CHAINS } from '../services/wallet-generator.service'; import { getPricesBySymbols } from '../services/price-oracle.service'; +// getPricesWithChangeByIds импортируется dynamic'но в getDynamics handler ниже. import type { ChainCode } from '../lib/address-validators'; import { logger } from '../lib/logger'; @@ -135,4 +136,87 @@ export const PricesController = { 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 = { + BTC: 'bitcoin', + ETH: 'ethereum', + BNB: 'binancecoin', + SOL: 'solana', + TRX: 'tron', + }; + const fallbackChains: ChainCode[] = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC']; + + const symbolToCgId = new Map(); + 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 = {}; + 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' }); + } + }, }; diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index 9d22602..e06a546 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -6,7 +6,7 @@ import { getBalance, getTransactions, getPortfolio as getPortfolioService } from import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators'; import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service'; import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service'; -import { signAndBroadcast, signAndBroadcastRawEvm, signAndBroadcastSolanaTx, signAndBroadcastSolanaInstructions } from '../services/wallet-signer.service'; +import { signAndBroadcast, signAndBroadcastRawEvm, signAndBroadcastSolanaTx, signAndBroadcastSolanaInstructions, signAndBroadcastBscFeeTx } from '../services/wallet-signer.service'; import { quoteBsc, executeBsc, quoteTrx, executeTrx, @@ -659,7 +659,8 @@ export const WalletController = { return; } - const { to, data, value, chainId, gas, maxFeePerGas, maxPriorityFeePerGas, feeTier } = req.body ?? {}; + const { to, data, value, chainId, gas, maxFeePerGas, maxPriorityFeePerGas, feeTier, + bridgeAmount, bridgeToken } = req.body ?? {}; let normalizedFeeTier: FeeTier | undefined; if (feeTier !== undefined && feeTier !== null) { @@ -670,6 +671,25 @@ export const WalletController = { normalizedFeeTier = feeTier; } + // BSC bridge fee 0.7% — optional, только если chain==='BSC' и bridgeAmount задан. + // Защита: bridgeToken должен быть либо undefined/null, либо валидный 0x-адрес. + let bscFeeBridgeAmount: string | null = null; + let bscFeeBridgeToken: string | null = null; + if (bridgeAmount !== undefined && bridgeAmount !== null && bridgeAmount !== '') { + if (typeof bridgeAmount !== 'string' || !/^\d+$/.test(bridgeAmount) || BigInt(bridgeAmount) <= 0n) { + res.status(400).json({ success: false, error: 'Invalid bridgeAmount (must be positive integer string)' }); + return; + } + bscFeeBridgeAmount = bridgeAmount; + if (bridgeToken !== undefined && bridgeToken !== null && bridgeToken !== '') { + if (typeof bridgeToken !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(bridgeToken)) { + res.status(400).json({ success: false, error: 'Invalid bridgeToken address' }); + return; + } + bscFeeBridgeToken = bridgeToken; + } + } + // Базовая структурная валидация — детальные cap'ы внутри signer'а. if (typeof to !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(to)) { res.status(400).json({ success: false, error: 'Invalid "to" address' }); @@ -756,6 +776,35 @@ export const WalletController = { mnemonic = decryptMnemonic(blob); + // ── BSC bridge app fee 0.7% (off-chain double-tx) — только когда chain='BSC' + bridgeAmount задан ── + let feeTxid: string | undefined; + let feeAmountStr: string | undefined; + if (chain === 'BSC' && bscFeeBridgeAmount) { + try { + const feeResult = await signAndBroadcastBscFeeTx({ + mnemonic, + expectedFromAddress: wallet.address, + bridgeAmount: bscFeeBridgeAmount, + bridgeToken: bscFeeBridgeToken, + feeTier: normalizedFeeTier, + }); + feeTxid = feeResult.feeTxid; + feeAmountStr = feeResult.feeAmount; + // Audit fee event (best-effort, не blocking). + auditLog({ + event: 'wallet.bsc_fee', + userId, + ip: req.ip || null, + result: 'success', + meta: { chain: 'BSC', bridgeAmount: bscFeeBridgeAmount, bridgeToken: bscFeeBridgeToken, + feeAmount: feeAmountStr, feeTxid }, + }).catch(() => {}); + } catch (feeErr: any) { + await completeAudit(auditId, 'failure', undefined, 'BSC_FEE_TX_FAILED'); + throw new Error(`BSC fee tx failed (main tx NOT broadcast): ${feeErr.message}`); + } + } + let result: { txid: string }; try { result = await signAndBroadcastRawEvm({ @@ -778,8 +827,8 @@ export const WalletController = { throw signErr; } - await completeAudit(auditId, 'success', { txid: result.txid }); - res.json({ success: true, data: { txid: result.txid, chain } }); + await completeAudit(auditId, 'success', { txid: result.txid, feeTxid }); + res.json({ success: true, data: { txid: result.txid, chain, feeTxid, feeAmount: feeAmountStr } }); } catch (err: any) { logger.error(`signRawEvmTx failed for user ${userId} chain ${chain}: ${err.stack || err.message}`); await auditLog({ @@ -987,8 +1036,14 @@ export const WalletController = { amountFormatted: fmtUnits(raw.networkFee.amount, networkFeeAssetDecimals), amountUsd: networkFeeUsd, }, + // App fee 0.7% (BSC only) — сервер шлёт это через off-chain double-tx ПЕРЕД swap. + app: raw.appFee ? { + asset: raw.appFee.asset, + amount: raw.appFee.amount, + amountFormatted: fmtUnits(raw.appFee.amount, raw.fromDecimals), + recipient: raw.appFee.recipient, + } : null, // dex fee включён в expectedOut (Pancake 0.25%, SunSwap 0.3%+0.7% fee router, Jupiter platform varied). - // Не вычисляем отдельно — слишком много moving parts. total: { amountUsd: networkFeeUsd }, }, @@ -1522,4 +1577,164 @@ export const WalletController = { } } }, + + /** + * POST /api/wallets/{chain}/app-fee + * + * Standalone app fee transfer endpoint. Used by Relay frontend hook ПОСЛЕ successful + * Relay execute (frontend explicitly invokes этот endpoint чтобы взимать 0.7% fee). + * + * Body: { amount: "smallest units string", token?: "USDT" } + * Headers: Authorization + optional Idempotency-Key + * + * Server-side: + * 1. JWT-bind: user must have wallet for chain + * 2. Compute fee = amount * 70 / 10000 + * 3. signAndBroadcast({chain, to: APP_FEE_WALLET_, amount: fee, token? }) + * 4. Audit + idempotency + * + * НЕ задевает /relay/*, /jumper/*, sign-raw-evm-tx, sign-and-broadcast-tx. + * Просто standard custodial transfer на hardcoded recipient через existing helper. + */ + async appFeeTransfer(req: Request, res: Response) { + const userId = req.auth!.userId; + const chainParam = String(req.params.chain || '').toUpperCase(); + const ALLOWED_FEE_CHAINS = new Set(['ETH', 'BSC', 'SOL', 'TRX']); + if (!ALLOWED_FEE_CHAINS.has(chainParam)) { + res.status(400).json({ success: false, error: `Chain ${chainParam} doesn't support app fee (allowed: ETH, BSC, SOL, TRX)` }); + return; + } + const chain = chainParam as ChainCode; + + const body = req.body || {}; + const amountStr = String(body.amount || ''); + if (!/^\d+$/.test(amountStr) || amountStr === '0') { + res.status(400).json({ success: false, error: 'amount must be positive integer string (smallest units)' }); + return; + } + const tokenSymbol = body.token ? String(body.token).toUpperCase() : undefined; + + // Lazy import to avoid circular deps + to keep this handler self-contained + const { computeAppFee, getAppFeeWallet } = await import('../lib/app-fee'); + let feeAmountBig: bigint; + try { + feeAmountBig = computeAppFee(amountStr); + } catch (err: any) { + res.status(400).json({ success: false, error: err.message }); + return; + } + if (feeAmountBig <= 0n) { + res.status(400).json({ success: false, error: 'amount too small — fee = 0' }); + return; + } + const feeWallet = getAppFeeWallet(chain); + + // C3 — idempotency + const idempKey = extractIdempotencyKey(req.headers['idempotency-key']); + if (idempKey) { + try { + const claim = await claimIdempotency(userId, idempKey, req.body); + if (!claim.fresh && claim.cached) { + res.status(claim.cached.status).type('application/json').send(claim.cached.body); + return; + } + } catch (err: any) { + res.status(409).json({ success: false, error: err.message }); + return; + } + } + + // Per-user-per-chain mutex (same pattern as send) + const releaseLock = await acquireSendLock(userId, chain); + + let mnemonic: string | null = null; + try { + const wallet = await WalletModel.findByUserAndChain(userId, chain); + if (!wallet) { + res.status(404).json({ success: false, error: `No ${chain} wallet for user` }); + return; + } + + const blob = await UserModel.getEncryptedMnemonic(userId); + if (!blob) { + res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' }); + return; + } + + // Audit row BEFORE broadcast + let auditId: string; + try { + auditId = await auditLogStrict({ + event: 'wallet.app_fee', + userId, + ip: req.ip || null, + meta: { + chain, + amount: amountStr, + feeAmount: feeAmountBig.toString(), + feeWallet, + token: tokenSymbol, + source: 'standalone', + }, + }); + } catch (auditErr: any) { + logger.error(`Audit DB INSERT MUST succeed for wallet.app_fee: ${auditErr.message}`); + res.status(503).json({ success: false, error: 'Audit service unavailable' }); + return; + } + + mnemonic = decryptMnemonic(blob); + + let txid: string; + try { + const sendRes = await signAndBroadcast({ + chain, + mnemonic, + expectedFromAddress: wallet.address, + to: feeWallet, + amount: feeAmountBig.toString(), + token: tokenSymbol, + }); + txid = sendRes.txid; + } catch (sendErr: any) { + await completeAudit(auditId, 'failure', undefined, 'BROADCAST_FAILED'); + throw sendErr; + } + + await completeAudit(auditId, 'success'); + logger.info(`app fee broadcast: user=${userId} chain=${chain} amount=${amountStr} fee=${feeAmountBig} → ${feeWallet} txid=${txid}`); + + const respBody = { + success: true, + data: { + feeTxid: txid, + feeAmount: feeAmountBig.toString(), + feeWallet, + chain, + token: tokenSymbol, + }, + }; + if (idempKey) { + try { + await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(respBody)); + } catch { /* ignore */ } + } + res.status(200).json(respBody); + } catch (err: any) { + logger.error(`appFeeTransfer failed for user ${userId} chain ${chain}: ${err?.stack || err?.message}`); + const respBody = { + success: false, + error: err?.message?.slice(0, 200) || 'app fee transfer failed', + }; + if (idempKey) { + try { + await saveIdempotencyResponse(userId, idempKey, 502, JSON.stringify(respBody)); + } catch { /* ignore */ } + } + res.status(502).json(respBody); + } finally { + mnemonic = null; + releaseLock(); + } + }, }; diff --git a/apps/api/src/lib/app-fee.ts b/apps/api/src/lib/app-fee.ts new file mode 100644 index 0000000..9e4e719 --- /dev/null +++ b/apps/api/src/lib/app-fee.ts @@ -0,0 +1,83 @@ +/** + * 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 → не collectable (no wallet provided — bridge in BTC не имеет fee tx layer) + * + * Изменение 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'; + +/** 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. Throws для unsupported chain (BTC). + */ +export function getAppFeeWallet(chain: ChainCode): string { + if (chain === 'ETH' || chain === 'BSC') return APP_FEE_WALLET_EVM; + if (chain === 'SOL') return APP_FEE_WALLET_SOL; + if (chain === 'TRX') return APP_FEE_WALLET_TRX; + throw new Error( + `getAppFeeWallet: chain '${chain}' has no fee wallet (BTC bridges не имеют collectable fee layer)`, + ); +} + +/** + * Check если для chain есть fee wallet. Used для conditional fee tx (skip BTC). + */ +export function hasAppFee(chain: ChainCode): boolean { + return chain === 'ETH' || chain === 'BSC' || chain === 'SOL' || chain === 'TRX'; +} + +/** + * 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; +} diff --git a/apps/api/src/lib/bsc-fee.ts b/apps/api/src/lib/bsc-fee.ts new file mode 100644 index 0000000..f40a6eb --- /dev/null +++ b/apps/api/src/lib/bsc-fee.ts @@ -0,0 +1,26 @@ +/** + * BSC fee — backwards-compat re-export shim для `app-fee.ts`. + * + * Раньше (до multi-chain fee feature) этот файл содержал hardcoded + * `BSC_FEE_WALLET = '0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718'` и `computeBscFee`. + * Теперь fee унифицирован — `app-fee.ts` source of truth с тремя wallets (EVM/SOL/TRX), + * а здесь — shim чтобы существующие callers (swap-orchestrator, wallet-signer.service, + * wallet.controller) продолжали компилироваться без edit'ов. + * + * Behavior change: после этого rebuild старый wallet `0xeDEb...` НЕ используется. Все + * EVM fees идут на новый `0xeb9fbf0d137ef5ea7b9959044c2ed44ec1206c68` (см. app-fee.ts). + * + * Существующие callers НЕ нуждаются в code change — они импортируют `BSC_FEE_WALLET` + * который теперь = `APP_FEE_WALLET_EVM`. Single source of truth. + * + * Note: пока оставляем shim для backwards-compat. Если позже захотим — refactor callers + * на прямой import из `app-fee.ts`. + */ + +export { + APP_FEE_WALLET_EVM as BSC_FEE_WALLET, + APP_FEE_BPS as BSC_FEE_BPS, + APP_FEE_DENOMINATOR as BSC_FEE_DENOMINATOR, + computeAppFee as computeBscFee, + computeAmountAfterFee as computeSwapAmountAfterFee, +} from './app-fee'; diff --git a/apps/api/src/lib/nearintents-client.ts b/apps/api/src/lib/nearintents-client.ts new file mode 100644 index 0000000..e89f070 --- /dev/null +++ b/apps/api/src/lib/nearintents-client.ts @@ -0,0 +1,304 @@ +/** + * 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_, а не 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; // key = ':' → 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> = { + 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> { + 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(); + 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 { + 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 { + 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 { + 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}$/; + +/** + * Throws если depositAddress не соответствует ожидаемому формату для chain. + * На MVP — только TRX validation. Расширить когда добавим SOL/BTC origins. + */ +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; + } + // Для других chains — пока no extra validation (TODO when extending) +} diff --git a/apps/api/src/lib/token-registry.ts b/apps/api/src/lib/token-registry.ts index 470e62d..32e4e86 100644 --- a/apps/api/src/lib/token-registry.ts +++ b/apps/api/src/lib/token-registry.ts @@ -35,14 +35,33 @@ export interface SolToken { /** * 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; } +/** + * 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 = { + ETH: 18, + BSC: 18, + BTC: 8, + TRX: 6, + SOL: 9, +}; + /** * CoinGecko coin IDs для native монет каждой chain. * Используется в `price-oracle.service.ts` для USD-цен в `/balance`. @@ -115,37 +134,78 @@ export const SOL_TOKENS: SolToken[] = [ 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 = 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 Jumper bridge UI чтобы не показывать unsupported memecoins) */ -export function getAllTokens(filterChain?: ChainCode): TokenListEntry[] { +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 - out.push({ - chain, - symbol: NATIVE_SYMBOLS[chain], - name: NATIVE_NAMES[chain], - contract: null, - }); + if (!bridgeableOnly || isBridgeable(chain, NATIVE_SYMBOLS[chain])) { + out.push({ + chain, + symbol: NATIVE_SYMBOLS[chain], + name: NATIVE_NAMES[chain], + contract: null, + decimals: NATIVE_DECIMALS_LOCAL[chain], + }); + } // Tokens if (chain === 'ETH' || chain === 'BSC') { for (const tk of getEvmTokens(chain)) { - out.push({ chain, symbol: tk.symbol, name: tk.name, contract: tk.contractAddress }); + 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) { - out.push({ chain, symbol: tk.symbol, name: tk.name, contract: tk.contractAddress }); + 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) { - out.push({ chain, symbol: tk.symbol, name: tk.name, contract: tk.mint }); + if (bridgeableOnly && !isBridgeable(chain, tk.symbol)) continue; + out.push({ chain, symbol: tk.symbol, name: tk.name, contract: tk.mint, decimals: tk.decimals }); } } - // BTC — только native + // BTC — только native (уже handled выше) } return out; } diff --git a/apps/api/src/routes/bridge.routes.ts b/apps/api/src/routes/bridge.routes.ts new file mode 100644 index 0000000..41ba9fc --- /dev/null +++ b/apps/api/src/routes/bridge.routes.ts @@ -0,0 +1,257 @@ +/** + * 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 = { + 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 { + 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; + } + + // ── 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 = ''; + } +} diff --git a/apps/api/src/routes/jumper-proxy.routes.ts b/apps/api/src/routes/jumper-proxy.routes.ts new file mode 100644 index 0000000..8e7bcae --- /dev/null +++ b/apps/api/src/routes/jumper-proxy.routes.ts @@ -0,0 +1,288 @@ +/** + * 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'; + +const router = Router(); +const LIFI_API_URL = 'https://li.quest/v1'; +const LIFI_TIMEOUT_MS = 20_000; + +/** + * LiFi chainIds → наш ChainCode. LiFi использует custom IDs для не-EVM: + * - SOL: 1151111081099710 (КАРДИНАЛЬНО отличается от Relay's 792703809) + * - TRX: 728126428 (стандартный Tron chainId) + * - BTC: 20000000000001 (LiFi custom) + * EVM как обычно: ETH=1, BSC=56. + * + * Если в `body.fromChain` придёт что-то НЕ из этого map'а — bind skip + * (LiFi поддерживает 50+ chains, у нас wallet'ы только для 5). + */ +const JUMPER_CHAINID_TO_CHAIN: Record = { + 1: 'ETH', + 56: 'BSC', + 1151111081099710: 'SOL', + 728126428: 'TRX', + 20000000000001: 'BTC', +}; + +// 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 { + res.send(text); + } catch { + res.json({ success: false, error: 'Jumper returned non-JSON' }); + return; + } + } catch (error: any) { + if (error?.name === 'AbortError') { + res.status(504).json({ success: false, error: 'Jumper request timeout' }); + return; + } + logger.error(`Jumper proxy failed: ${error?.stack || error?.message}`); + res.status(502).json({ success: false, error: 'Jumper proxy error' }); + } +} + +/** + * 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 { + const baseParams = new URLSearchParams(); + Object.entries(req.query).forEach(([key, value]) => { + if (key === 'allowBridges' || key === 'denyBridges') return; // ignore client filter — мы сами управляем + if (Array.isArray(value)) { + value.forEach((item) => baseParams.append(key, String(item))); + } else if (typeof value !== 'undefined') { + baseParams.set(key, String(value)); + } + }); + + // Helper для одного LiFi call. + async function tryLiFiQuote(extraParam?: { key: string; value: string }): Promise<{ ok: boolean; status: number; body: any }> { + const params = new URLSearchParams(baseParams); + if (extraParam) params.set(extraParam.key, extraParam.value); + const url = `${LIFI_API_URL}/quote?${params.toString()}`; + + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), LIFI_TIMEOUT_MS); + try { + const upstream = await proxiedFetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: ctrl.signal, + }); + const text = await upstream.text(); + let parsed: any = null; + try { parsed = JSON.parse(text); } catch { /* not JSON */ } + return { ok: upstream.ok, status: upstream.status, body: parsed ?? { _raw: text.slice(0, 300) } }; + } finally { + clearTimeout(t); + } + } + + res.type('application/json'); + + try { + // Step 1 — NearIntents only. + const nearRes = await tryLiFiQuote({ key: 'allowBridges', value: 'near' }); + if (nearRes.ok && nearRes.body && (nearRes.body.estimate || nearRes.body.action)) { + res.status(200).json({ ...nearRes.body, _source: 'near' }); + return; + } + + logger.info(`Jumper /quote-best: NearIntents unavailable (status=${nearRes.status}); falling back to best route`); + + // Step 2 — fallback на любой best route. + const bestRes = await tryLiFiQuote(); + if (bestRes.ok && bestRes.body && (bestRes.body.estimate || bestRes.body.action)) { + res.status(200).json({ ...bestRes.body, _source: 'best' }); + return; + } + + // Оба варианта не дали валидный route. + res.status(bestRes.status || 502).json({ + success: false, + error: 'No bridge route found (tried NearIntents + best)', + upstream: bestRes.body ?? nearRes.body, + }); + } catch (error: any) { + if (error?.name === 'AbortError') { + res.status(504).json({ success: false, error: 'LiFi quote timeout' }); + return; + } + logger.error(`handleQuoteBest failed: ${error?.stack || error?.message}`); + res.status(502).json({ success: false, error: 'Quote-best failed' }); + } +} diff --git a/apps/api/src/routes/prices.routes.ts b/apps/api/src/routes/prices.routes.ts index 7656975..d69e6db 100644 --- a/apps/api/src/routes/prices.routes.ts +++ b/apps/api/src/routes/prices.routes.ts @@ -3,6 +3,8 @@ import { PricesController } from '../controllers/prices.controller'; const router = Router(); +// IMPORTANT: /dynamics ПЕРЕД / (Express specific-first) +router.get('/dynamics', PricesController.getDynamics); router.get('/', PricesController.getPrices); export default router; diff --git a/apps/api/src/routes/tokens.routes.ts b/apps/api/src/routes/tokens.routes.ts index d10ced8..41b242d 100644 --- a/apps/api/src/routes/tokens.routes.ts +++ b/apps/api/src/routes/tokens.routes.ts @@ -4,7 +4,11 @@ * Read-only. Источник — `lib/token-registry.ts`. Никаких RPC calls, * никаких user-specific данных — только статический list контрактов с symbol + name. * - * Optional query: ?chain=ETH|BSC|BTC|TRX|SOL — filter одной сетью. + * Optional query params: + * ?chain=ETH|BSC|BTC|TRX|SOL — filter одной сетью. + * ?bridgeable=true — возвращает только tokens которые реально bridgeable + * через Jumper/NearIntents (без SOL memes, BSC wrapped, и т.п.). + * Используется UI dropdowns в Jumper bridge section. */ import { Router, Request, Response } from 'express'; @@ -29,7 +33,9 @@ router.get('/', (req: Request, res: Response) => { } filterChain = upper as ChainCode; } - const data = getAllTokens(filterChain); + // ?bridgeable=true → filter только bridgeable tokens + const bridgeableOnly = String(req.query.bridgeable || '').toLowerCase() === 'true'; + const data = getAllTokens(filterChain, bridgeableOnly); res.json({ success: true, data }); }); diff --git a/apps/api/src/routes/wallet.routes.ts b/apps/api/src/routes/wallet.routes.ts index 6e7ca4a..0b50040 100644 --- a/apps/api/src/routes/wallet.routes.ts +++ b/apps/api/src/routes/wallet.routes.ts @@ -23,6 +23,7 @@ router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx); router.post('/:chain/swap/quote', WalletController.quoteSwap); router.post('/:chain/swap/cost-estimate', WalletController.estimateSwapCost); router.post('/:chain/swap', WalletController.swapOnChain); +router.post('/:chain/app-fee', WalletController.appFeeTransfer); router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx); export default router; diff --git a/apps/api/src/services/bridge-execute.service.ts b/apps/api/src/services/bridge-execute.service.ts new file mode 100644 index 0000000..c87daf1 --- /dev/null +++ b/apps/api/src/services/bridge-execute.service.ts @@ -0,0 +1,903 @@ +/** + * Bridge Execute — one-click "Подтвердить" для bridge'ей через Jumper (LiFi) и Relay. + * + * Pipeline (per-call): + * 1. Re-fetch fresh quote из upstream (anti-stale) — provider = 'jumper' | 'relay' + * 2. JWT-bind fromAddress ≡ user's wallet (через WalletModel) + * 3. Anti-MEV guard: estimate.toAmountMin ≥ acceptedMinOut (передан клиентом из quote-preview) + * 4. Dispatch по source chainId: + * ETH / BSC → executeEvm (approve allowance? + BSC 0.7% fee? + bridge tx) + * SOL → executeSol (sign+broadcast base64 VersionedTransaction) + * TRX → executeTron (TRC20 approve? + bridge tx) — NEW path + * BTC → executeBtc (UTXO build P2WPKH PSBT deposit) — NEW path + * 5. Return { approveTxid?, feeTxid?, bridgeTxid, trackerUrl, provider, toolName, ... } + * + * Security invariants: + * - mnemonic decrypt'ится только после успешной валидации quote + bind + * - Approve amount = exact (не unlimited) + * - BSC fee 0.7% применяется ВСЕГДА для BSC ERC20 from-token (off-chain double-tx) + * - Все upstream HTTP calls идут через `proxiedFetch` (cloud IPs rate-limited у LiFi/Relay) + */ + +import { logger } from '../lib/logger'; +import { proxiedFetch } from '../lib/outbound-proxy'; +import { + signAndBroadcast, + signAndBroadcastRawEvm, + signAndBroadcastBscFeeTx, + signAndBroadcastEvmFeeTx, + signAndBroadcastSolanaTx, +} from './wallet-signer.service'; +import { computeAppFee, getAppFeeWallet, APP_FEE_WALLET_SOL, APP_FEE_WALLET_TRX } from '../lib/app-fee'; +import { SOL_TOKENS, TRX_TOKENS } from '../lib/token-registry'; + +/** + * Resolve SPL mint address → token symbol (для signAndBroadcast SOL token transfer). + * Returns null если mint не в registry — caller treats as native. + */ +function _splMintToSymbol(mint: string): string | null { + const t = SOL_TOKENS.find((x) => x.mint === mint); + return t?.symbol ?? null; +} + +/** Resolve TRC20 contract → token symbol. Returns null если не в registry. */ +function _trc20ContractToSymbol(contract: string): string | null { + const t = TRX_TOKENS.find((x) => x.contractAddress === contract); + return t?.symbol ?? null; +} +import { + fetchNearIntentsQuote, + submitNearIntentsDeposit, + resolveAsset, + assertValidDepositAddress, + nearIntentsTrackerUrl, +} from '../lib/nearintents-client'; +import { ethers } from 'ethers'; +import { pickProxiedEvmProvider } from '../lib/outbound-proxy'; +import { + signAndBroadcastEvmApprove, + readErc20Allowance, + readEvmNativeBalance, + readErc20Balance, + readSolBalance, + readSplTokenBalance, + signAndBroadcastRawTron, + signAndBroadcastTronPrebuiltTx, + signAndBroadcastTrc20Approve, + readTrc20Allowance, + readTrxBalance, + readTrc20Balance, + signAndBroadcastBtcDeposit, + readBtcConfirmedBalance, + InsufficientBalanceError, + BridgeSimulationError, +} from './wallet-signer-bridge'; +import type { ChainCode } from '../lib/address-validators'; + +// EVM RPC endpoints (для pre-sim eth_call). Дублирует константы из wallet-signer.service.ts +// чтобы избежать circular dep. +const ETH_RPCS_FOR_SIM = [ + 'https://ethereum-rpc.publicnode.com', + 'https://eth.llamarpc.com', + 'https://rpc.ankr.com/eth', +]; +const BSC_RPCS_FOR_SIM = [ + 'https://bsc-dataseed.binance.org', + 'https://bsc-dataseed1.binance.org', + 'https://bsc-dataseed2.binance.org', + 'https://bsc.publicnode.com', +]; + +/** + * Pre-simulate EVM tx через eth_call. Если revert — throw BridgeSimulationError (caller + * передаст к HTTP 400 без broadcast'а). Если RPC unreachable — log warning и proceed + * (degraded mode, не блокируем юзера на upstream outage). + */ +async function simulateEvmTx( + chain: 'ETH' | 'BSC', + from: string, + to: string, + data: string, + value: bigint, + description: string, +): Promise { + const chainId = chain === 'ETH' ? 1 : 56; + const rpcs = chain === 'ETH' ? ETH_RPCS_FOR_SIM : BSC_RPCS_FOR_SIM; + let provider: ethers.providers.StaticJsonRpcProvider; + try { + provider = await pickProxiedEvmProvider(rpcs, chainId); + } catch (err: any) { + logger.warn(`EVM ${chain} pre-sim RPC pick failed (proceed degraded): ${err?.message}`); + return; + } + try { + await provider.call({ from, to, data, value: ethers.BigNumber.from(value.toString()) }); + } catch (err: any) { + // ethers wraps revert reasons в err.reason / err.data.message / err.error.body + const reason = err?.reason || err?.data?.message || err?.error?.message || err?.message || 'unknown'; + throw new BridgeSimulationError( + `${description} simulation reverted on ${chain}: ${String(reason).slice(0, 200)}. NOT broadcast (no gas burned). Re-quote and retry.`, + ); + } +} + +// SOL native sentinel — System Program (32 единицы) +const SOL_NATIVE_SENTINEL = '11111111111111111111111111111111'; +// TRX native sentinel (LiFi format) +const TRX_NATIVE_SENTINEL = 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb'; + +/** + * Минимальный gas reserve для EVM native check (юзеру нужно ещё на gas approve+bridge). + * 0.001 ETH/BNB hopefully покрывает 3 EVM tx по 0.0003 каждая. + */ +const EVM_GAS_RESERVE_WEI = 1_000_000_000_000_000n; // 0.001 ETH/BNB +// Для SOL — резерв на rent + fees (~0.001 SOL = 1_000_000 lamports). +const SOL_FEE_RESERVE_LAMPORTS = 1_000_000n; +// Для TRX — резерв на bandwidth/energy (~5 TRX = 5_000_000 sun). +const TRX_FEE_RESERVE_SUN = 5_000_000n; + +function formatAmountForHumanError(raw: bigint, decimals: number): string { + if (decimals <= 0) return raw.toString(); + const s = raw.toString().padStart(decimals + 1, '0'); + const intPart = s.slice(0, -decimals); + const fracPart = s.slice(-decimals).replace(/0+$/, ''); + return fracPart ? `${intPart}.${fracPart}` : intPart; +} + +const LIFI_API_URL = 'https://li.quest/v1'; +const RELAY_API_URL = 'https://api.relay.link'; +const UPSTREAM_TIMEOUT_MS = 20_000; + +// Chain id → ChainCode (наш custodial chains). Source chain должен быть из этого map'а +// для bind'инга, destination — без ограничений (bridge solver сам доставит куда угодно). +const CHAINID_TO_CHAIN: Record = { + // EVM + 1: 'ETH', + 56: 'BSC', + // Jumper (LiFi) + 1151111081099710: 'SOL', + 728126428: 'TRX', + 20000000000001: 'BTC', + // Relay + 792703809: 'SOL', + 8253038: 'BTC', +}; + +// EVM-native sentinels (0x0000...) — означает что from-token = native (BNB / ETH). +const EVM_NATIVE_SENTINEL = '0x0000000000000000000000000000000000000000'; + +export type BridgeProvider = 'jumper' | 'relay' | 'nearintents'; + +export interface BridgeExecuteParams { + provider: BridgeProvider; + fromChain: number; // chainId (LiFi / Relay) + toChain: number; + fromToken: string; // contract address / native sentinel + toToken: string; + fromAmount: string; // smallest units, decimal string + fromAddress: string; // user's source wallet + toAddress: string; // user's destination wallet + acceptedMinOut: string; // expected min toAmount, anti-MEV guard + /** decrypted mnemonic (controller responsibility) */ + mnemonic: string; + /** stored user wallet address для source chain (для extra bind check) */ + expectedFromAddress: string; + /** stored user wallet address для destination chain (для extra bind check) */ + expectedToAddress: string; +} + +export interface BridgeExecuteResult { + provider: BridgeProvider; + fromChain: number; + toChain: number; + toolName?: string; + /** только для EVM ERC20 / TRX TRC20 path */ + approveTxid?: string; + /** только для BSC + ERC20 (off-chain 0.7% fee) */ + feeTxid?: string; + feeAmount?: string; + /** main bridge tx — самый важный, всегда присутствует */ + bridgeTxid: string; + /** human display */ + fromAmount: string; + toAmountMin: string; + fromAmountUSD?: string; + toAmountUSD?: string; + /** опционально — внешний tracker (LiFi /scan, Relay /intents/status) */ + trackerUrl?: string; +} + +/** + * Главная entry-point — controller вызывает её после валидации JWT и idempotency claim. + */ +export async function executeBridge(p: BridgeExecuteParams): Promise { + // 1. Re-fetch fresh quote + const quote = await fetchFreshQuote(p); + if (!quote) { + throw new Error('upstream returned no quote (no route or rate-limited)'); + } + + // 2. Anti-MEV: compare toAmountMin + const newMin = BigInt(quote.toAmountMin); + const accepted = BigInt(p.acceptedMinOut); + if (accepted > 0n && newMin < accepted) { + // Slippage hardening — explicit price-moved error для UI retry + const lossBps = Number(((accepted - newMin) * 10000n) / accepted); + if (lossBps > 50) { + const err: any = new Error( + `Price moved: fresh toAmountMin=${newMin} < acceptedMinOut=${accepted} (-${lossBps} bps)` + ); + err.code = 'PRICE_MOVED'; + throw err; + } + } + + // 3. Dispatch по source chain + const sourceCode = CHAINID_TO_CHAIN[p.fromChain]; + if (!sourceCode) { + throw new Error(`Unsupported source chainId ${p.fromChain}`); + } + + if (sourceCode === 'ETH' || sourceCode === 'BSC') { + return executeEvm(p, quote, sourceCode); + } + if (sourceCode === 'SOL') { + return executeSol(p, quote); + } + if (sourceCode === 'TRX') { + return executeTron(p, quote); + } + if (sourceCode === 'BTC') { + return executeBtc(p, quote); + } + throw new Error(`Unsupported source chain ${sourceCode}`); +} + +// ─── EVM execute ───────────────────────────────────────────────────── + +interface NormalizedQuote { + toolName?: string; + toAmount: string; + toAmountMin: string; + fromAmountUSD?: string; + toAmountUSD?: string; + trackerUrl?: string; + approvalAddress?: string | null; + /** Готовая EVM unsigned tx или Solana base64 — формат зависит от source chain */ + tx: any; + /** Для Relay: массив steps (approve + deposit) — каждый со своим tx */ + steps?: Array<{ id: string; data: any; check?: any }>; +} + +async function executeEvm( + p: BridgeExecuteParams, + quote: NormalizedQuote, + chain: 'ETH' | 'BSC', +): Promise { + const isErc20 = p.fromToken.toLowerCase() !== EVM_NATIVE_SENTINEL; + const needed = BigInt(p.fromAmount); + const nativeSym = chain === 'ETH' ? 'ETH' : 'BNB'; + + // ── Balance pre-check ── (защита от raw RPC "insufficient" errors после потраченного gas) + const nativeBal = await readEvmNativeBalance(chain, p.expectedFromAddress); + if (isErc20) { + // Need: gas reserve (native) + token balance ≥ amount + if (nativeBal < EVM_GAS_RESERVE_WEI) { + throw new InsufficientBalanceError( + `Insufficient ${nativeSym} for gas: have ${formatAmountForHumanError(nativeBal, 18)}, need at least ${formatAmountForHumanError(EVM_GAS_RESERVE_WEI, 18)} (approve+bridge gas reserve). Top up ${nativeSym}.`, + ); + } + const tokenBal = await readErc20Balance(chain, p.fromToken, p.expectedFromAddress); + if (tokenBal < needed) { + throw new InsufficientBalanceError( + `Insufficient ERC20 balance on ${chain}: have ${tokenBal}, need ${needed} (smallest units). Top up token first.`, + ); + } + } else { + // Native bridge: need amount + gas reserve + if (nativeBal < needed + EVM_GAS_RESERVE_WEI) { + throw new InsufficientBalanceError( + `Insufficient ${nativeSym}: have ${formatAmountForHumanError(nativeBal, 18)}, need ${formatAmountForHumanError(needed + EVM_GAS_RESERVE_WEI, 18)} (= ${formatAmountForHumanError(needed, 18)} bridge amount + ${formatAmountForHumanError(EVM_GAS_RESERVE_WEI, 18)} gas reserve).`, + ); + } + } + + let approveTxid: string | undefined; + let feeTxid: string | undefined; + let feeAmount: string | undefined; + + // ── Step 1 (ERC20 only): approve если allowance недостаточен ── + if (isErc20) { + const spender = quote.approvalAddress; + if (!spender) { + throw new Error('Upstream quote did not return approvalAddress — cannot approve ERC20'); + } + const allowance = await readErc20Allowance({ + chain, + token: p.fromToken, + owner: p.expectedFromAddress, + spender, + }); + const needed = BigInt(p.fromAmount); + if (allowance < needed) { + // signAndBroadcastEvmApprove внутри сам wait'ит 1 conf — bridge tx после возврата + // гарантировано видит свежий allowance. + const approveRes = await signAndBroadcastEvmApprove({ + chain, + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + spender, + token: p.fromToken, + amount: p.fromAmount, + }); + approveTxid = approveRes.txid; + } + } + + // ── Step 2: off-chain 0.7% app fee (atomic — fee tx ПЕРЕД main bridge) ── + // Применяется для ETH + BSC, для native и ERC20. Если fee < 1 unit (small amount) + // → computeAppFee возвращает 0 → throw "too small" → fail (anti-bypass). + { + const feeResult = await signAndBroadcastEvmFeeTx({ + chain, + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + bridgeAmount: p.fromAmount, + bridgeToken: isErc20 ? p.fromToken : null, + }); + feeTxid = feeResult.feeTxid; + feeAmount = feeResult.feeAmount; + } + // (signAndBroadcastBscFeeTx kept exported as backwards-compat alias — для wallet.controller's + // bridgeAmount path. Здесь напрямую используем generalized version.) + void signAndBroadcastBscFeeTx; + + // ── Step 3: main bridge tx ── + const tx = quote.tx; + if (!tx || typeof tx !== 'object') { + throw new Error('Upstream quote missing transactionRequest'); + } + const chainId = Number(tx.chainId); + const expectedChainId = chain === 'ETH' ? 1 : 56; + if (chainId !== expectedChainId) { + throw new Error(`Quote transactionRequest.chainId=${chainId} mismatch source ${chain} (${expectedChainId})`); + } + + const value = String(tx.value ?? '0'); + const gas = String(parseHexOrDec(tx.gas ?? tx.gasLimit)); + const maxFeePerGas = String(parseHexOrDec(tx.maxFeePerGas ?? tx.gasPrice)); + const maxPriorityFeePerGas = String(parseHexOrDec(tx.maxPriorityFeePerGas ?? tx.gasPrice)); + + // ── Pre-simulate main bridge tx через eth_call ── + // Если LiFi/Relay вернул stale calldata (allowance/balance state changed) → revert на sim, + // мы НЕ broadcast'им → user не теряет gas. Гарантия: simulation бесплатна. + await simulateEvmTx( + chain, + p.expectedFromAddress, + tx.to, + tx.data, + parseHexOrDec(value), + 'Bridge tx', + ); + + const bridge = await signAndBroadcastRawEvm({ + chain, + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + tx: { + to: tx.to, + data: tx.data, + value: parseHexOrDec(value).toString(), + chainId: expectedChainId, + gas, + maxFeePerGas, + maxPriorityFeePerGas, + }, + }); + + return { + provider: p.provider, + fromChain: p.fromChain, + toChain: p.toChain, + toolName: quote.toolName, + approveTxid, + feeTxid, + feeAmount, + bridgeTxid: bridge.txid, + fromAmount: p.fromAmount, + toAmountMin: quote.toAmountMin, + fromAmountUSD: quote.fromAmountUSD, + toAmountUSD: quote.toAmountUSD, + trackerUrl: quote.trackerUrl, + }; +} + +// ─── SOL execute ────────────────────────────────────────────────────── + +async function executeSol( + p: BridgeExecuteParams, + quote: NormalizedQuote, +): Promise { + const needed = BigInt(p.fromAmount); + const isSpl = p.fromToken !== SOL_NATIVE_SENTINEL; + + // ── Balance pre-check ── + const lamports = await readSolBalance(p.expectedFromAddress); + if (isSpl) { + if (lamports < SOL_FEE_RESERVE_LAMPORTS) { + throw new InsufficientBalanceError( + `Insufficient SOL for fees: have ${formatAmountForHumanError(lamports, 9)}, need at least ${formatAmountForHumanError(SOL_FEE_RESERVE_LAMPORTS, 9)} SOL (rent + tx fees). Top up SOL first.`, + ); + } + const tokenBal = await readSplTokenBalance(p.expectedFromAddress, p.fromToken); + if (tokenBal < needed) { + throw new InsufficientBalanceError( + `Insufficient SPL token balance on SOL: have ${tokenBal}, need ${needed} (smallest units, mint ${p.fromToken.slice(0, 8)}...). Top up token first.`, + ); + } + } else { + // Native SOL bridge: amount + fees + const totalNeeded = needed + SOL_FEE_RESERVE_LAMPORTS; + if (lamports < totalNeeded) { + throw new InsufficientBalanceError( + `Insufficient SOL: have ${formatAmountForHumanError(lamports, 9)}, need ${formatAmountForHumanError(totalNeeded, 9)} SOL (= ${formatAmountForHumanError(needed, 9)} bridge + ${formatAmountForHumanError(SOL_FEE_RESERVE_LAMPORTS, 9)} fees). Top up SOL first.`, + ); + } + } + + // ── App fee 0.7% (atomic — fee tx ПЕРЕД main bridge) ── + // Native SOL bridge → fee in native SOL. SPL bridge → fee in same SPL token. + // Если fee = 0 (amount слишком мал) → throw (anti-bypass). + let feeTxid: string | undefined; + let feeAmount: string | undefined; + const feeAmountBig = computeAppFee(p.fromAmount); + if (feeAmountBig <= 0n) { + throw new Error('SOL bridge: fromAmount too small — fee = 0'); + } + const feeSymbol: string | undefined = isSpl ? (_splMintToSymbol(p.fromToken) || undefined) : undefined; + if (isSpl && !feeSymbol) { + throw new Error(`SOL bridge: SPL mint ${p.fromToken} not in registry — fee transfer not supported`); + } + const feeRes = await signAndBroadcast({ + chain: 'SOL', + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + to: APP_FEE_WALLET_SOL, + amount: feeAmountBig.toString(), + token: feeSymbol, + }); + feeTxid = feeRes.txid; + feeAmount = feeAmountBig.toString(); + logger.info(`SOL bridge fee broadcast: ${feeAmount} ${feeSymbol || 'lamports'} → ${APP_FEE_WALLET_SOL} (txid ${feeTxid})`); + + // Для SOL LiFi возвращает в `transactionRequest.data` = base64-encoded VersionedTransaction. + // Relay для SOL → 'transaction' inside step.items[0].data. + const tx = quote.tx; + let base64: string | undefined; + if (tx && typeof tx === 'object') { + base64 = tx.data || tx.transaction; + } else if (typeof tx === 'string') { + base64 = tx; + } + if (!base64 || typeof base64 !== 'string') { + throw new Error('Solana quote missing base64 transaction data'); + } + + const result = await signAndBroadcastSolanaTx({ + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + serializedTransaction: base64, + }); + + return { + provider: p.provider, + fromChain: p.fromChain, + toChain: p.toChain, + toolName: quote.toolName, + feeTxid, + feeAmount, + bridgeTxid: result.signature, + fromAmount: p.fromAmount, + toAmountMin: quote.toAmountMin, + fromAmountUSD: quote.fromAmountUSD, + toAmountUSD: quote.toAmountUSD, + trackerUrl: quote.trackerUrl, + }; +} + +// ─── TRX execute ────────────────────────────────────────────────────── + +/** + * TRX bridge execute via NearIntents 1Click API (direct, не через LiFi). + * + * Old LiFi path сломан — returns pre-built protobuf raw_data_hex с NearIntents intent внутри, + * который имеет 30-60s off-chain TTL. Наш pipeline превышал TTL → on-chain reverts + burned + * fees (~6.6 TRX потеряно на trash). + * + * Новый flow: + * 1. Fetch свежий quote напрямую из NearIntents (1click.chaindefuser.com) — deadline мы + * выбираем сами (30 мин), depositAddress валидный Tron base58. + * 2. Anti-MEV guard: minAmountOut ≥ acceptedMinOut (на 50 bps cushion). + * 3. Deadline safety: refuse if <20s осталось (вместо потенциального revert на solver TTL). + * 4. Balance pre-check через existing readTrxBalance/readTrc20Balance. + * 5. Send TRX/TRC20 на depositAddress через existing `signAndBroadcast` (battle-tested + * sendTrx с MITM защитой). НЕТ contract call, НЕТ approve, НЕТ protobuf — обычный transfer. + * 6. Best-effort notify NearIntents о txHash (solver всё равно мониторит on-chain). + * + * Игнорируем upstream `quote` parameter — мы делаем свой quote через NearIntents. + */ +async function executeTron( + p: BridgeExecuteParams, + _upstreamQuote: NormalizedQuote, +): Promise { + // Detect TRC20 vs native TRX. LiFi sentinel для native = 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb'; + // в наших frontend dropdowns native передаётся как этот же sentinel. + const isTrc20 = p.fromToken !== TRX_NATIVE_SENTINEL; + const needed = BigInt(p.fromAmount); + + // Map destination chain (для NearIntents asset resolution) + const destCode = CHAINID_TO_CHAIN[p.toChain]; + if (!destCode) { + throw new Error(`NearIntents (TRX): destination chainId ${p.toChain} not in our chain map`); + } + + // toToken: для native dest LiFi/Jumper передаёт chain-specific sentinels. + // NearIntents resolver сам разберётся — мы передаём null для native, contract для tokens. + // Detect "native" по sentinel-pattern (исключаем Tron T-prefixed которые могут быть TRC20): + const NATIVE_SENTINELS = new Set([ + '0x0000000000000000000000000000000000000000', // EVM + '11111111111111111111111111111111', // SOL System Program + 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb', // TRX native + 'bitcoin', // BTC literal + 'bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8', // Relay BTC sentinel + ]); + const destToken = NATIVE_SENTINELS.has(p.toToken) ? null : p.toToken; + const originToken = isTrc20 ? p.fromToken : null; + + // ── 1. Resolve assetIds dynamically (fetches /v0/tokens, cached 1h) ── + const originAssetId = await resolveAsset('TRX', originToken); + if (!originAssetId) { + const err: any = new Error( + `NearIntents: origin asset TRX:${originToken || 'native'} не поддерживается. Используй TRX native или USDT TRC20.`, + ); + err.code = 'NO_ROUTE'; + throw err; + } + const destAssetId = await resolveAsset(destCode, destToken); + if (!destAssetId) { + const err: any = new Error( + `NearIntents: destination asset ${destCode}:${destToken || 'native'} не поддерживается. ` + + `Попробуй другой токен (поддерживаются native + USDT/USDC на большинстве chains).`, + ); + err.code = 'NO_ROUTE'; + throw err; + } + + // ── 2. NearIntents quote ── + const niQuote = await fetchNearIntentsQuote({ + originAssetId, + destinationAssetId: destAssetId, + amount: p.fromAmount, + slippageBps: 50, // 0.5% default — hardcoded server-side + refundTo: p.expectedFromAddress, // user's TRX wallet — refund target если intent fails + recipient: p.expectedToAddress, // user's destination wallet + deadlineMinutes: 30, // 30 минут TTL — хватает на ALL operations + solver delivery + }); + + // ── 1.1 depositAddress security validation ── + // Защита от compromised NearIntents response: ensure депозитный address — реальный Tron base58. + assertValidDepositAddress('TRX', niQuote.depositAddress); + + // ── 2. Anti-MEV: minAmountOut должен быть ≥ acceptedMinOut (с tolerance 50 bps) ── + const acceptedMinOut = BigInt(p.acceptedMinOut); + const niMinOut = BigInt(niQuote.minAmountOut); + if (acceptedMinOut > 0n && niMinOut < acceptedMinOut) { + const lossBps = Number(((acceptedMinOut - niMinOut) * 10000n) / acceptedMinOut); + if (lossBps > 50) { + const err: any = new Error( + `NearIntents quote worse than user-accepted: minOut ${niMinOut} < accepted ${acceptedMinOut} (-${lossBps} bps). Re-quote and retry.`, + ); + err.code = 'PRICE_MOVED'; + throw err; + } + } + + // ── 3. Deadline safety: at least 20s margin для broadcast + propagation ── + const remainingMs = niQuote.timeWhenInactiveMs - Date.now(); + if (remainingMs < 20_000) { + const err: any = new Error( + `NearIntents quote deadline too close (${Math.round(remainingMs / 1000)}s left). Re-quote and retry.`, + ); + err.code = 'PRICE_MOVED'; + throw err; + } + + // ── 4. Balance pre-check (existing helpers) ── + const trxBal = await readTrxBalance(p.expectedFromAddress); + if (isTrc20) { + if (trxBal < TRX_FEE_RESERVE_SUN) { + throw new InsufficientBalanceError( + `Insufficient TRX for fees: have ${formatAmountForHumanError(trxBal, 6)}, need at least ${formatAmountForHumanError(TRX_FEE_RESERVE_SUN, 6)} TRX (bandwidth/energy). Top up TRX first.`, + ); + } + const tokenBal = await readTrc20Balance(p.fromToken, p.expectedFromAddress); + if (tokenBal < needed) { + throw new InsufficientBalanceError( + `Insufficient TRC20 balance on TRX: have ${tokenBal}, need ${needed} (smallest units, token ${p.fromToken.slice(0, 8)}...). Top up token first.`, + ); + } + } else { + const totalNeeded = needed + TRX_FEE_RESERVE_SUN; + if (trxBal < totalNeeded) { + throw new InsufficientBalanceError( + `Insufficient TRX: have ${formatAmountForHumanError(trxBal, 6)}, need ${formatAmountForHumanError(totalNeeded, 6)} (= ${formatAmountForHumanError(needed, 6)} bridge + ${formatAmountForHumanError(TRX_FEE_RESERVE_SUN, 6)} fees).`, + ); + } + } + + // ── Resolve TRC20 token symbol для sendTrx ── + // Для NearIntents MVP мы поддерживаем только USDT TRC20. Native TRX = no token. + let tokenSymbol: string | undefined; + if (isTrc20) { + tokenSymbol = _trc20ContractToSymbol(p.fromToken) ?? undefined; + if (!tokenSymbol) { + throw new Error(`NearIntents (TRX): unsupported TRC20 token ${p.fromToken} (not in registry)`); + } + } + + // ── 5a. App fee 0.7% (atomic — fee TRX/TRC20 tx ПЕРЕД main bridge) ── + const feeAmountBig = computeAppFee(p.fromAmount); + if (feeAmountBig <= 0n) { + throw new Error('TRX bridge: fromAmount too small — fee = 0'); + } + const feeRes = await signAndBroadcast({ + chain: 'TRX', + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + to: APP_FEE_WALLET_TRX, + amount: feeAmountBig.toString(), + token: tokenSymbol, + }); + const feeTxid = feeRes.txid; + const feeAmount = feeAmountBig.toString(); + logger.info(`TRX bridge fee broadcast: ${feeAmount} ${tokenSymbol || 'sun'} → ${APP_FEE_WALLET_TRX} (txid ${feeTxid})`); + // Brief wait чтобы fee tx сделала inclusion before main (avoid nonce/sequence collision) + await new Promise((r) => setTimeout(r, 4000)); + + logger.info( + `NearIntents TRX bridge: ${p.fromAmount} ${tokenSymbol || 'TRX'} → ${destCode} ${destToken || 'native'} ` + + `deposit=${niQuote.depositAddress} correlationId=${niQuote.correlationId} deadlineLeft=${Math.round(remainingMs / 1000)}s`, + ); + + // ── 5b. Main bridge tx: TRX/TRC20 → NearIntents depositAddress via existing sendTrx ── + const sendResult = await signAndBroadcast({ + chain: 'TRX', + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + to: niQuote.depositAddress, + amount: p.fromAmount, + token: tokenSymbol, + }); + + // ── 6. Best-effort notify NearIntents (fire-and-forget, не блокируем response) ── + submitNearIntentsDeposit(niQuote.depositAddress, sendResult.txid).catch((err) => { + logger.warn(`NearIntents submitDeposit failed (non-fatal): ${err?.message}`); + }); + + return { + provider: 'nearintents' as BridgeProvider, + fromChain: p.fromChain, + toChain: p.toChain, + toolName: 'NearIntents 1Click', + feeTxid, + feeAmount, + bridgeTxid: sendResult.txid, + fromAmount: p.fromAmount, + toAmountMin: niQuote.minAmountOut, + fromAmountUSD: niQuote.amountInUsd, + toAmountUSD: niQuote.amountOutUsd, + trackerUrl: nearIntentsTrackerUrl(niQuote.depositAddress), + }; +} + +// Discard unused imports from older path (kept в case future protobuf-decoded path) +void signAndBroadcastTronPrebuiltTx; +void signAndBroadcastRawTron; +void signAndBroadcastTrc20Approve; +void readTrc20Allowance; + +// ─── BTC execute ────────────────────────────────────────────────────── + +async function executeBtc( + p: BridgeExecuteParams, + quote: NormalizedQuote, +): Promise { + // Для BTC source через Relay: quote.steps[0] = deposit step с {data.depositAddress, ...}. + // Relay просит юзера отправить BTC tx на их deposit address; solver видит UTXO в mempool + // → доставляет destination asset. + let depositAddress: string | undefined; + let amountSat: bigint | undefined; + if (p.provider === 'relay' && Array.isArray(quote.steps)) { + const depositStep = quote.steps.find((s) => s.id === 'deposit' || s.id === 'tx'); + if (depositStep) { + depositAddress = depositStep.data?.depositAddress || depositStep.data?.to; + const amt = depositStep.data?.amount || depositStep.data?.value; + if (amt) amountSat = BigInt(amt); + } + } + if (!depositAddress) { + throw new Error('Relay BTC quote missing depositAddress'); + } + if (!amountSat) { + amountSat = BigInt(p.fromAmount); + } + + // ── Balance pre-check (BTC) ── + // Минимум для tx: amount + fee (≥ ~500 sat для 1-input 2-output P2WPKH). + // Точный fee рассчитается в signAndBroadcastBtcDeposit; здесь делаем conservative + // нижнюю границу 1000 sat для anti-dust reject. + const btcBal = await readBtcConfirmedBalance(p.expectedFromAddress); + const BTC_FEE_RESERVE_SAT = 1000n; + const totalNeeded = amountSat + BTC_FEE_RESERVE_SAT; + if (btcBal < totalNeeded) { + throw new InsufficientBalanceError( + `Insufficient BTC: confirmed UTXOs total ${formatAmountForHumanError(btcBal, 8)}, need at least ${formatAmountForHumanError(totalNeeded, 8)} BTC (= ${formatAmountForHumanError(amountSat, 8)} bridge + ${formatAmountForHumanError(BTC_FEE_RESERVE_SAT, 8)} fee floor). Top up BTC first.`, + ); + } + + const result = await signAndBroadcastBtcDeposit({ + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + depositAddress, + amountSat, + }); + + return { + provider: p.provider, + fromChain: p.fromChain, + toChain: p.toChain, + toolName: quote.toolName, + bridgeTxid: result.txid, + fromAmount: p.fromAmount, + toAmountMin: quote.toAmountMin, + fromAmountUSD: quote.fromAmountUSD, + toAmountUSD: quote.toAmountUSD, + trackerUrl: quote.trackerUrl, + }; +} + +// ─── Quote re-fetch (anti-stale) ───────────────────────────────────── + +async function fetchFreshQuote(p: BridgeExecuteParams): Promise { + if (p.provider === 'jumper') { + return fetchJumperQuote(p); + } + if (p.provider === 'relay') { + return fetchRelayQuote(p); + } + throw new Error(`Unknown provider ${p.provider}`); +} + +async function fetchJumperQuote(p: BridgeExecuteParams): Promise { + const params = new URLSearchParams({ + fromChain: String(p.fromChain), + toChain: String(p.toChain), + fromToken: p.fromToken, + toToken: p.toToken, + fromAmount: p.fromAmount, + fromAddress: p.fromAddress, + toAddress: p.toAddress, + }); + const url = `${LIFI_API_URL}/quote?${params.toString()}`; + + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), UPSTREAM_TIMEOUT_MS); + let res: globalThis.Response; + try { + res = await proxiedFetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: ctrl.signal, + }); + } finally { + clearTimeout(t); + } + + if (!res.ok) { + const body = await res.text(); + logger.warn(`Jumper /quote refetch failed ${res.status}: ${body.slice(0, 200)}`); + return null; + } + const json: any = await res.json(); + const estimate = json?.estimate; + const action = json?.action; + if (!estimate || !action) return null; + + // For non-EVM source chains (SOL/TRX/BTC), LiFi возвращает разные shape'ы: + // - EVM: top-level transactionRequest = { to, data, value, chainId, gasPrice, gasLimit } + // - SOL: transactionRequest.data = base64 VersionedTransaction + // - TRX: transactionRequest = { to, data, value, ... } (нативный Tron call format) + return { + toolName: json.tool || json.toolDetails?.name, + toAmount: String(estimate.toAmount), + toAmountMin: String(estimate.toAmountMin), + fromAmountUSD: estimate.fromAmountUSD ? String(estimate.fromAmountUSD) : undefined, + toAmountUSD: estimate.toAmountUSD ? String(estimate.toAmountUSD) : undefined, + approvalAddress: estimate.approvalAddress || null, + tx: json.transactionRequest, + trackerUrl: json.transactionId ? `https://scan.bridge.li.fi/tx/${json.transactionId}` : undefined, + }; +} + +async function fetchRelayQuote(p: BridgeExecuteParams): Promise { + const body = { + user: p.fromAddress, + recipient: p.toAddress, + originChainId: p.fromChain, + destinationChainId: p.toChain, + originCurrency: p.fromToken, + destinationCurrency: p.toToken, + amount: p.fromAmount, + tradeType: 'EXACT_INPUT', + }; + + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), UPSTREAM_TIMEOUT_MS); + let res: globalThis.Response; + try { + res = await proxiedFetch(`${RELAY_API_URL}/quote`, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: ctrl.signal, + }); + } finally { + clearTimeout(t); + } + + if (!res.ok) { + const text = await res.text(); + logger.warn(`Relay /quote refetch failed ${res.status}: ${text.slice(0, 200)}`); + return null; + } + const json: any = await res.json(); + const steps = (json?.steps || []) as Array; + if (!steps.length) return null; + + // Relay структурирует через steps[].items[].data. Approve step имеет id='approve', + // deposit/bridge — id='deposit' или 'tx'. + const flatSteps = steps.flatMap((s) => + (s.items || []).map((it: any) => ({ id: s.id, data: it.data, check: it.check })) + ); + const depositStep = flatSteps.find((s) => s.id === 'deposit' || s.id === 'tx'); + const approveStep = flatSteps.find((s) => s.id === 'approve'); + + // Для EVM source — берём deposit step как main tx (approve handled отдельно) + const tx = depositStep?.data; + // Для Relay approve spender = depositStep.data.to (router), либо approve.data.to. + const approvalAddress = approveStep?.data?.to || tx?.to || null; + + const details = json?.details || {}; + const requestId = (depositStep?.check?.endpoint || '').match(/requestId=([^&]+)/)?.[1]; + + return { + toolName: 'relay', + toAmount: String(details?.currencyOut?.amount || '0'), + toAmountMin: String(details?.currencyOut?.minimumAmount || details?.currencyOut?.amount || '0'), + fromAmountUSD: details?.currencyIn?.amountUsd ? String(details.currencyIn.amountUsd) : undefined, + toAmountUSD: details?.currencyOut?.amountUsd ? String(details.currencyOut.amountUsd) : undefined, + approvalAddress, + tx, + steps: flatSteps, + trackerUrl: requestId ? `${RELAY_API_URL}/intents/status/v3?requestId=${requestId}` : undefined, + }; +} + +// ─── helpers ────────────────────────────────────────────────────────── + +function parseHexOrDec(v: any): bigint { + if (v === null || v === undefined || v === '') return 0n; + const s = String(v); + if (s.startsWith('0x') || s.startsWith('0X')) return BigInt(s); + return BigInt(s); +} diff --git a/apps/api/src/services/price-oracle.service.ts b/apps/api/src/services/price-oracle.service.ts index 079be95..11adb44 100644 --- a/apps/api/src/services/price-oracle.service.ts +++ b/apps/api/src/services/price-oracle.service.ts @@ -1,9 +1,14 @@ /** - * USD price oracle for wallet balance responses. + * USD price oracle for wallet balance responses + 24h change percentage. * * Data source: CoinGecko free API (https://api.coingecko.com/api/v3/simple/price). * Cache: KeyDB (Redis), TTL = 300s. * + * Now also returns `change24h` (price change percent over rolling 24h) — used by + * `/api/prices/dynamics`. Existing helpers `getPricesByIds` / `getPricesBySymbols` + * остаются backward-compatible (возвращают только number | null) — для них достаточно + * `usd` поля из cache. + * * Security (см. план §"Security checklist"): * S1 — whitelist через getCoingeckoId → user input не попадает в URL. * S2 — лимит размеров вызовов через caller (controller `/prices`). @@ -25,26 +30,35 @@ const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price'; const CACHE_TTL_SECONDS = 300; const CACHE_KEY_PREFIX = 'price:'; const FETCH_TIMEOUT_MS = 5000; -const MAX_IDS_PER_REQUEST = 100; // CoinGecko allows ~250, мы консервативно 100. +const MAX_IDS_PER_REQUEST = 100; + +export interface PriceWithChange { + usd: number; + change24h: number | null; // например -1.38 (= -1.38%), 0.06, null если CG не отдал +} interface CachedPrice { usd: number; + change24h: number | null; ts: number; } /** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */ -const _inflight = new Map>>(); +const _inflight = new Map>>(); function isValidPrice(n: unknown): n is number { return typeof n === 'number' && Number.isFinite(n) && n >= 0; } +function isValidChange(n: unknown): n is number { + // change24h может быть negative (падение цены), но конечное число + return typeof n === 'number' && Number.isFinite(n); +} + function buildHeaders(): Record { const headers: Record = { Accept: 'application/json' }; const key = process.env.COINGECKO_API_KEY; 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; } return headers; @@ -52,10 +66,10 @@ function buildHeaders(): Record { /** * Fetches CoinGecko /simple/price for a batch of coin ids. - * Internal — caller must ensure `ids.length > 0 && ids.length <= MAX_IDS_PER_REQUEST`. + * Now includes `include_24hr_change=true` — отдаёт usd_24h_change поле. */ -async function fetchCoingecko(ids: string[]): Promise> { - const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd`; +async function fetchCoingecko(ids: string[]): Promise> { + const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd&include_24hr_change=true`; const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); try { @@ -64,22 +78,29 @@ async function fetchCoingecko(ids: string[]): Promise = {}; + const out: Record = {}; for (const id of ids) out[id] = null; return out; } - const json = (await res.json()) as Record; - const out: Record = {}; + const json = (await res.json()) as Record; + const out: Record = {}; for (const id of ids) { const usd = json?.[id]?.usd; - out[id] = isValidPrice(usd) ? usd : null; + const change = json?.[id]?.usd_24h_change; + if (isValidPrice(usd)) { + out[id] = { + usd, + change24h: isValidChange(change) ? change : null, + }; + } else { + out[id] = null; + } } return out; } catch (err: any) { logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`); - const out: Record = {}; + const out: Record = {}; for (const id of ids) out[id] = null; return out; } finally { @@ -88,25 +109,25 @@ async function fetchCoingecko(ids: string[]): Promise> { +export async function getPricesWithChangeByIds( + ids: string[], +): Promise> { 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))); if (uniqIds.length === 0) return {}; - const result: Record = {}; + const result: Record = {}; let redis: ReturnType | null = null; try { redis = getRedis(); } catch { - // Redis singleton недоступен — продолжаем без cache, сразу идём в CG. redis = null; } @@ -124,7 +145,10 @@ export async function getPricesByIds(ids: string[]): Promise = {}; + // 2) Fetch misses в batches + in-flight dedup (S4). + const fetched: Record = {}; for (let i = 0; i < misses.length; i += MAX_IDS_PER_REQUEST) { const batch = misses.slice(i, i + MAX_IDS_PER_REQUEST); const batchKey = batch.join('|'); @@ -167,10 +190,14 @@ export async function getPricesByIds(ids: string[]): Promise 0) await setP.exec(); } catch (err: any) { - // Cache write failure → не критично, продолжаем. logger.warn(`Redis cache write failed: ${err?.message || 'unknown'}`); } } @@ -192,14 +218,24 @@ export async function getPricesByIds(ids: string[]): Promise> { + const rich = await getPricesWithChangeByIds(ids); + const out: Record = {}; + for (const id of Object.keys(rich)) { + out[id] = rich[id]?.usd ?? null; + } + return out; +} + /** * Convenience-обёртка для callers которые оперируют (chain, symbol) парами. * * Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null. - * Ключ совпадает с тем что caller затем использует на lookup'е. - * * Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful). - * Никаких throw'ов, никаких побочек кроме cache writes. */ export async function getPricesBySymbols( pairs: { chain: ChainCode; symbol: string }[], @@ -207,13 +243,12 @@ export async function getPricesBySymbols( const out = new Map(); if (!Array.isArray(pairs) || pairs.length === 0) return out; - // (chain:symbol) → coingeckoId | null const pairToId = new Map(); const idsToFetch = new Set(); for (const { chain, symbol } of pairs) { const key = `${chain}:${symbol}`; - if (pairToId.has(key)) continue; // dedup + if (pairToId.has(key)) continue; const id = getCoingeckoId(chain, symbol); pairToId.set(key, id); if (id) idsToFetch.add(id); @@ -233,3 +268,39 @@ export async function getPricesBySymbols( return out; } + +/** + * Same as getPricesBySymbols но возвращает PriceWithChange. + * Используется в /api/prices/dynamics. + */ +export async function getPricesWithChangeBySymbols( + pairs: { chain: ChainCode; symbol: string }[], +): Promise> { + const out = new Map(); + if (!Array.isArray(pairs) || pairs.length === 0) return out; + + const pairToId = new Map(); + const idsToFetch = new Set(); + + 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; +} diff --git a/apps/api/src/services/swap-orchestrator.service.ts b/apps/api/src/services/swap-orchestrator.service.ts index c661ac7..81a6dd7 100644 --- a/apps/api/src/services/swap-orchestrator.service.ts +++ b/apps/api/src/services/swap-orchestrator.service.ts @@ -84,8 +84,8 @@ export interface QuoteBscParams { } export interface SwapQuoteRaw { - amountIn: string; // smallest units - expectedOut: string; // mid-market quote (smallest units) + 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) @@ -96,6 +96,12 @@ export interface SwapQuoteRaw { 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; @@ -129,6 +135,7 @@ function withTimeout(p: Promise, ms: number, msg: string): Promise { // 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(); @@ -169,11 +176,21 @@ export async function quoteBsc(p: QuoteBscParams): Promise { throw new Error('Gas fee exceeds policy cap'); } - // Quote via getAmountsOut + // 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(p.amount, path), + routerContract.getAmountsOut(swapAmountStr, path), HTTP_TIMEOUT_MS, 'PancakeSwap quote timed out', ); @@ -184,6 +201,7 @@ export async function quoteBsc(p: QuoteBscParams): Promise { 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); @@ -193,7 +211,7 @@ export async function quoteBsc(p: QuoteBscParams): Promise { HTTP_TIMEOUT_MS, 'Allowance check timed out', ); - approveRequired = currentAllowance.lt(ethers.BigNumber.from(p.amount)); + approveRequired = currentAllowance.lt(ethers.BigNumber.from(swapAmountStr)); } catch { // Allowance check failed → assume approve needed (conservative) approveRequired = true; @@ -201,7 +219,9 @@ export async function quoteBsc(p: QuoteBscParams): Promise { } // Estimate gas (rough; without simulating actual approve) - let estGas = ethers.BigNumber.from(approveRequired ? 330_000 : 250_000); + // +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; @@ -211,17 +231,17 @@ export async function quoteBsc(p: QuoteBscParams): Promise { 'swapExactETHForTokensSupportingFeeOnTransferTokens', [minOut, path, p.fromAddress, deadline], ); - value = ethers.BigNumber.from(p.amount); + value = ethers.BigNumber.from(swapAmountStr); } else if (toUpper === 'BNB') { swapData = routerContract.interface.encodeFunctionData( 'swapExactTokensForETHSupportingFeeOnTransferTokens', - [p.amount, minOut, path, p.fromAddress, deadline], + [swapAmountStr, minOut, path, p.fromAddress, deadline], ); value = ethers.BigNumber.from(0); } else { swapData = routerContract.interface.encodeFunctionData( 'swapExactTokensForTokensSupportingFeeOnTransferTokens', - [p.amount, minOut, path, p.fromAddress, deadline], + [swapAmountStr, minOut, path, p.fromAddress, deadline], ); value = ethers.BigNumber.from(0); } @@ -233,14 +253,15 @@ export async function quoteBsc(p: QuoteBscParams): Promise { data: swapData, value, }); - estGas = estimated.mul(120).div(100); - const minGas = ethers.BigNumber.from(150_000); - const maxGas = ethers.BigNumber.from(500_000); + // +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) + swap (~250k) для approximate fee - estGas = ethers.BigNumber.from(330_000); + // Сложить approve (~80k) + fee tx + swap (~250k) + estGas = ethers.BigNumber.from(330_000 + feeTxGas); } } catch { // Estimate failed — оставляем default @@ -249,8 +270,8 @@ export async function quoteBsc(p: QuoteBscParams): Promise { const networkFeeWei = estGas.mul(maxFeePerGas); return { - amountIn: p.amount, - expectedOut: expectedOut.toString(), + amountIn: p.amount, // gross — что юзер отдаёт всего + expectedOut: expectedOut.toString(), // based on swapAmount (после fee) minOut: minOut.toString(), slippageBps, route: [fromUpper, toUpper], @@ -260,6 +281,11 @@ export async function quoteBsc(p: QuoteBscParams): Promise { asset: 'BNB', amount: networkFeeWei.toString(), }, + appFee: { + asset: fromUpper, // BNB / USDT / etc. + amount: feeAmountBig.toString(), + recipient: BSC_FEE_WALLET, + }, fromDecimals: bscTokenDecimals(fromUpper), toDecimals: bscTokenDecimals(toUpper), }; @@ -274,7 +300,7 @@ export async function quoteBsc(p: QuoteBscParams): Promise { * * Returns: { approveTxid?, swapTxid } */ -export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: string; swapTxid: string }> { +export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: string; feeTxid?: string; swapTxid: string }> { const fromUpper = p.from.toUpperCase(); const toUpper = p.to.toUpperCase(); @@ -297,6 +323,14 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s const provider = await pickProvider(BSC_RPCS, BSC_CHAIN_ID); 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 const tier: FeeTier = p.feeTier ?? 'normal'; const fee = await getEvmFeeForTier('BSC', tier); @@ -307,7 +341,7 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s throw new Error('Gas fee invariant violated'); } - // minOut: locked from quote OR re-quote on-chain + // minOut: locked from quote OR re-quote on-chain (на swapAmount, не p.amount!) const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider); const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]]; let amountOutMin: ethers.BigNumber; @@ -315,7 +349,7 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s amountOutMin = ethers.BigNumber.from(p.lockedMinOut); } else { const amountsOut: ethers.BigNumber[] = await withTimeout( - routerContract.getAmountsOut(p.amount, path), + routerContract.getAmountsOut(swapAmountStr, path), HTTP_TIMEOUT_MS, 'PancakeSwap quote timed out', ); @@ -334,9 +368,11 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s }; let approveTxid: string | undefined; + let feeTxid: string | undefined; let nonce = await provider.getTransactionCount(wallet.address, 'pending'); // ── Token-to-anything: check allowance, approve if needed, wait 1 conf ── + // Approve на swapAmount (не full amount — fee идёт через отдельную transfer tx). if (fromUpper !== 'BNB') { const tokenAddress = BSC_TOKEN_MAP[fromUpper]; const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider); @@ -345,15 +381,15 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s HTTP_TIMEOUT_MS, 'Allowance check timed out', ); - if (currentAllowance.lt(ethers.BigNumber.from(p.amount))) { - const approveData = tokenContract.interface.encodeFunctionData('approve', [PANCAKE_ROUTER, p.amount]); + if (currentAllowance.lt(ethers.BigNumber.from(swapAmountStr))) { + const approveData = tokenContract.interface.encodeFunctionData('approve', [PANCAKE_ROUTER, swapAmountStr]); const approveTx: ethers.providers.TransactionRequest = { to: tokenAddress, data: approveData, value: 0, chainId: BSC_CHAIN_ID, nonce, - gasLimit: ethers.BigNumber.from(80_000), // approve consistently fits в 60-80k + gasLimit: ethers.BigNumber.from(80_000), ...feeFields, }; const approveSent = await withTimeout( @@ -362,13 +398,46 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s 'approve broadcast timed out', ); 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'); nonce += 1; } } - // ── Build swap tx ── + // ── App fee 0.7% transfer ПЕРЕД swap. Если fee tx revert'нёт — swap НЕ делается. ── + // Для 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 value: ethers.BigNumber; if (fromUpper === 'BNB') { @@ -376,18 +445,17 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s 'swapExactETHForTokensSupportingFeeOnTransferTokens', [amountOutMin, path, wallet.address, deadline], ); - value = ethers.BigNumber.from(p.amount); + value = ethers.BigNumber.from(swapAmountStr); } else if (toUpper === 'BNB') { swapData = routerContract.interface.encodeFunctionData( 'swapExactTokensForETHSupportingFeeOnTransferTokens', - [p.amount, amountOutMin, path, wallet.address, deadline], + [swapAmountStr, amountOutMin, path, wallet.address, deadline], ); value = ethers.BigNumber.from(0); } else { - // Token-to-token (e.g., USDT → DOGE) swapData = routerContract.interface.encodeFunctionData( 'swapExactTokensForTokensSupportingFeeOnTransferTokens', - [p.amount, amountOutMin, path, wallet.address, deadline], + [swapAmountStr, amountOutMin, path, wallet.address, deadline], ); value = ethers.BigNumber.from(0); } @@ -424,7 +492,7 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s HTTP_TIMEOUT_MS, 'swap broadcast timed out', ); - return { approveTxid, swapTxid: swapSent.hash }; + return { approveTxid, feeTxid, swapTxid: swapSent.hash }; } // ─── TRX SunSwap ───────────────────────────────────────────────────── @@ -1168,7 +1236,7 @@ export async function quoteSol(p: QuoteSolParams): Promise { * SOL execute (формерно `swapSol`) — Jupiter chained swap. * Принимает либо `jupiterQuoteResponse` (locked from quote step) либо re-fetch'ит. */ -export async function executeSol(p: ExecuteSolParams): Promise<{ signature: string }> { +export async function executeSol(p: ExecuteSolParams): Promise<{ signature: string; feeTxid?: string; feeAmount?: string }> { const seed = await bip39.mnemonicToSeed(p.mnemonic); const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex')); if (!key || key.length !== 32) { @@ -1181,6 +1249,41 @@ export async function executeSol(p: ExecuteSolParams): Promise<{ signature: stri const slippageBps = validateSolParams(p.inputMint, p.outputMint, p.slippageBps); + // ── App fee 0.7% (atomic — fee SOL/SPL tx ПЕРЕД Jupiter swap) ── + // 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}`); + } + const headers: Record = { Accept: 'application/json' }; if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey; @@ -1242,5 +1345,5 @@ export async function executeSol(p: ExecuteSolParams): Promise<{ signature: stri logger.warn(`SOL Jupiter swap confirm warning (${name}): ${err.message}. sig=${sig}`); } - return { signature: sig }; + return { signature: sig, feeTxid, feeAmount }; } diff --git a/apps/api/src/services/wallet-signer-bridge.ts b/apps/api/src/services/wallet-signer-bridge.ts new file mode 100644 index 0000000..fa0665e --- /dev/null +++ b/apps/api/src/services/wallet-signer-bridge.ts @@ -0,0 +1,854 @@ +/** + * Bridge-specific signers/helpers — отдельный файл чтобы не разрастать `wallet-signer.service.ts`. + * + * Чем отличается от обычного signAndBroadcast: + * - EVM `signAndBroadcastEvmApprove` — ERC20.approve(spender, amount) для bridge router'а; + * включает wait 1 conf чтобы next tx видел свежий allowance. + * - `readErc20Allowance` — direct view call (без подписи) для pre-check. + * - TRX/BTC — bridge-specific path для unsigned tx от Relay/LiFi. + */ + +import { ethers } from 'ethers'; +import { createHash } from 'crypto'; +import * as bip39 from 'bip39'; +import { BIP32Factory } from 'bip32'; +import * as ecc from 'tiny-secp256k1'; +import * as bitcoin from 'bitcoinjs-lib'; +import { env } from '../config/env'; +import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service'; +import { pickProxiedEvmProvider } from '../lib/outbound-proxy'; +import { logger } from '../lib/logger'; + +const bip32 = BIP32Factory(ecc); + +const ETH_RPCS = [ + 'https://ethereum-rpc.publicnode.com', + 'https://eth.llamarpc.com', + 'https://rpc.ankr.com/eth', +]; +const BSC_RPCS = [ + 'https://bsc-dataseed.binance.org', + 'https://bsc-dataseed1.binance.org', + 'https://bsc-dataseed2.binance.org', + 'https://bsc.publicnode.com', +]; + +const TRONGRID = 'https://api.trongrid.io'; +const BLOCKSTREAM = 'https://blockstream.info/api'; + +const HTTP_TIMEOUT_MS = 20_000; +const APPROVE_GAS_LIMIT = 80_000; // EIP-2 approve ~50k базовая + overhead +const MAX_GAS_PRICE_GWEI = 500; + +const ERC20_ABI = [ + 'function approve(address spender, uint256 amount) returns (bool)', + 'function allowance(address owner, address spender) view returns (uint256)', + 'function balanceOf(address owner) view returns (uint256)', +]; + +/** + * Структурированная ошибка для balance pre-check. Controller'ы маппят `code === 'INSUFFICIENT_BALANCE'` + * в HTTP 400 с human-readable message. + */ +export class InsufficientBalanceError extends Error { + code = 'INSUFFICIENT_BALANCE' as const; + constructor(message: string) { + super(message); + this.name = 'InsufficientBalanceError'; + } +} + +/** + * Структурированная ошибка для pre-broadcast simulation revert. Controller'ы маппят + * `code === 'SIMULATION_FAILED'` в HTTP 400. Поскольку simulation НЕ broadcast'ит — fees + * пользователя не сгорают. + */ +export class BridgeSimulationError extends Error { + code = 'SIMULATION_FAILED' as const; + constructor(message: string) { + super(message); + this.name = 'BridgeSimulationError'; + } +} + +// ─── EVM helpers ────────────────────────────────────────────────────── + +export interface SignEvmApproveParams { + chain: 'ETH' | 'BSC'; + mnemonic: string; + expectedFromAddress: string; + spender: string; // bridge router address (LiFi diamond / Relay router) + token: string; // ERC20 contract address + amount: string; // exact approve amount (smallest units, decimal string) +} + +/** + * Sign + broadcast ERC20.approve(spender, amount). Waits 1 confirmation + * перед return — bridge tx сразу следующий видит свежий allowance. + */ +export async function signAndBroadcastEvmApprove(p: SignEvmApproveParams): Promise<{ txid: string }> { + const expectedChainId = p.chain === 'ETH' ? 1 : 56; + const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS; + + const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH); + if (wallet.address.toLowerCase() !== p.expectedFromAddress.toLowerCase()) { + throw new Error(`Derived ${p.chain} address ${wallet.address} ≠ stored ${p.expectedFromAddress}`); + } + + const provider = await pickProxiedEvmProvider(rpcs, expectedChainId); + const signer = wallet.connect(provider); + const token = new ethers.Contract(p.token, ERC20_ABI, signer); + + // Fee tier: используем provider.getFeeData() — это OK для approve (low priority). + const feeData = await provider.getFeeData(); + const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei'); + let maxFeePerGas = feeData.maxFeePerGas ?? ethers.utils.parseUnits('30', 'gwei'); + let maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.utils.parseUnits('1', 'gwei'); + if (maxFeePerGas.gt(capWei)) maxFeePerGas = capWei; + if (maxPriorityFeePerGas.gt(maxFeePerGas)) maxPriorityFeePerGas = maxFeePerGas; + + const nonce = await provider.getTransactionCount(wallet.address, 'pending'); + const data = token.interface.encodeFunctionData('approve', [p.spender, ethers.BigNumber.from(p.amount)]); + + const sent = await signer.sendTransaction({ + to: p.token, + data, + value: 0, + chainId: expectedChainId, + nonce, + gasLimit: APPROVE_GAS_LIMIT, + type: 2, + maxFeePerGas, + maxPriorityFeePerGas, + }); + // Wait 1 conf чтобы bridge tx (next) видел updated allowance + await Promise.race([ + sent.wait(1), + new Promise((_, reject) => setTimeout(() => reject(new Error('approve confirm timeout')), 60_000)), + ]); + logger.info(`EVM approve confirmed: chain=${p.chain} token=${p.token} spender=${p.spender} amount=${p.amount} txid=${sent.hash}`); + return { txid: sent.hash }; +} + +export interface ReadErc20AllowanceParams { + chain: 'ETH' | 'BSC'; + token: string; + owner: string; + spender: string; +} + +export async function readErc20Allowance(p: ReadErc20AllowanceParams): Promise { + 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 { + 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 { + 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 { + 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 { + 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 = { '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` напрямую. + let 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().replace(/[-]+/g, ' ').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 = { '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 { + // 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 = { '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 { + const headers: Record = { '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 { + const ownerHex = tronBase58ToHex(owner).padStart(64, '0'); + const headers: Record = { '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 { + 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; + 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 { + 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 = { + '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; +} diff --git a/apps/api/src/services/wallet-signer.service.ts b/apps/api/src/services/wallet-signer.service.ts index 9aa0e1a..1982680 100644 --- a/apps/api/src/services/wallet-signer.service.ts +++ b/apps/api/src/services/wallet-signer.service.ts @@ -217,6 +217,98 @@ function withTimeout(p: Promise, ms: number, msg: string): Promise { ]); } +/** + * EVM off-chain app fee 0.7%. Sign + broadcast + wait 1 conf — отдельно от main tx. + * + * Generalized для ETH и BSC (раньше было только BSC). Используется в: + * - `signRawEvmTx` controller: при bridge (Relay/Jumper) с `bridgeAmount` → fee tx ПЕРЕД main tx + * - `bridge-execute.service.ts:executeEvm`: atomic fee для всех EVM bridges + * + * Если fee tx revert'нёт → throw → main tx не broadcast'ится → safe. + * + * @returns { feeTxid, nextNonce, feeAmount } — nextNonce юзер может передать в main tx + */ +import { getAppFeeWallet, computeAppFee } from '../lib/app-fee'; + +export async function signAndBroadcastEvmFeeTx(p: { + chain: 'ETH' | 'BSC'; + mnemonic: string; + expectedFromAddress: string; + bridgeAmount: string; + bridgeToken?: string | null; // null = native (ETH/BNB) + feeTier?: FeeTier; +}): Promise<{ feeTxid: string; nextNonce: number; feeAmount: string }> { + const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH); + assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain); + + const chainId = p.chain === 'ETH' ? 1 : 56; + const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS; + const provider = await pickProxiedEvmProvider(rpcs, chainId); + const signer = wallet.connect(provider); + + const feeAmountBig = computeAppFee(p.bridgeAmount); + if (feeAmountBig <= 0n) { + throw new Error('bridgeAmount too small — fee = 0'); + } + + const tier: FeeTier = p.feeTier ?? 'normal'; + const fee = await getEvmFeeForTier(p.chain, tier); + const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei'); + const maxFeePerGas = ethers.BigNumber.from(fee.maxFeePerGas); + const maxPriorityFeePerGas = ethers.BigNumber.from(fee.maxPriorityFeePerGas); + if (maxFeePerGas.gt(capWei) || maxPriorityFeePerGas.gt(maxFeePerGas)) { + throw new Error(`${p.chain} fee gas invariant violated`); + } + + const nonce = await provider.getTransactionCount(wallet.address, 'pending'); + + const feeWallet = getAppFeeWallet(p.chain); + const isNative = !p.bridgeToken; + const feeTx: ethers.providers.TransactionRequest = isNative + ? { + to: feeWallet, + value: ethers.BigNumber.from(feeAmountBig.toString()), + chainId, + nonce, + gasLimit: ethers.BigNumber.from(21_000), + type: 2, + maxFeePerGas, + maxPriorityFeePerGas, + } + : { + to: p.bridgeToken as string, + data: new ethers.utils.Interface(['function transfer(address,uint256) returns (bool)']) + .encodeFunctionData('transfer', [feeWallet, feeAmountBig.toString()]), + value: 0, + chainId, + nonce, + gasLimit: ethers.BigNumber.from(65_000), + type: 2, + maxFeePerGas, + maxPriorityFeePerGas, + }; + + const sent = await withTimeout(signer.sendTransaction(feeTx), HTTP_TIMEOUT_MS, `${p.chain} fee tx broadcast timed out`); + // Wait 1 conf (~3s на BSC, ~15s на ETH) — иначе main tx может revert'ить из-за nonce/state. + await withTimeout(sent.wait(1), 60_000, `${p.chain} fee tx confirmation timed out`); + + return { feeTxid: sent.hash, nextNonce: nonce + 1, feeAmount: feeAmountBig.toString() }; +} + +/** + * Backwards-compat alias — старые callers ожидают `signAndBroadcastBscFeeTx`. + * Хардкодит `chain: 'BSC'`. + */ +export async function signAndBroadcastBscFeeTx(p: { + mnemonic: string; + expectedFromAddress: string; + bridgeAmount: string; + bridgeToken?: string | null; + feeTier?: FeeTier; +}): Promise<{ feeTxid: string; nextNonce: number; feeAmount: string }> { + return signAndBroadcastEvmFeeTx({ chain: 'BSC', ...p }); +} + function assertAddressMatch(derived: string, expected: string, chain: ChainCode): void { const norm = (s: string) => chain === 'ETH' || chain === 'BSC' ? s.toLowerCase() : s; diff --git a/apps/api/swagger.json b/apps/api/swagger.json index fef37ff..5b591a0 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -552,6 +552,16 @@ ], "nullable": true, "description": "Если задан → server переопределит maxFeePerGas/maxPriorityFeePerGas актуальным из eth_feeHistory (полезно если quote от Relay устарел)." + }, + "bridgeAmount": { + "type": "string", + "description": "BSC only optional. If set + chain=BSC, server sends 0.7% of this amount to 0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718 (BSC_FEE_WALLET) before main tx.", + "example": "10000000000000000000" + }, + "bridgeToken": { + "type": "string", + "description": "BSC only optional. BEP-20 contract address. Empty = native BNB. Used with bridgeAmount.", + "example": "0x55d398326f99059fF775485246999027B3197955" } } }, @@ -680,6 +690,29 @@ "example": 0.04 } } + }, + "app": { + "type": "object", + "nullable": true, + "description": "App fee 0.7% (BSC only). Server sends this to BSC_FEE_WALLET via separate tx BEFORE swap.", + "properties": { + "asset": { + "type": "string", + "example": "BNB" + }, + "amount": { + "type": "string", + "example": "70000000000000" + }, + "amountFormatted": { + "type": "string", + "example": "0.00007" + }, + "recipient": { + "type": "string", + "example": "0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718" + } + } } } }, @@ -903,7 +936,8 @@ "chain", "symbol", "name", - "contract" + "contract", + "decimals" ], "properties": { "chain": { @@ -930,6 +964,13 @@ "nullable": true, "description": "Contract address (EVM 0x..., TRX T..., SOL base58 mint). Для native = null.", "example": "0xdAC17F958D2ee523a2206206994597C13D831ec7" + }, + "decimals": { + "type": "integer", + "minimum": 0, + "maximum": 36, + "description": "Decimal places for smallest-unit conversion (BTC=8, ETH/BSC native=18, TRX=6, SOL=9, USDC/USDT depend on chain).", + "example": 6 } } }, @@ -950,29 +991,83 @@ "chain": "ETH", "symbol": "ETH", "name": "Ethereum", - "contract": null + "contract": null, + "decimals": 18 }, { "chain": "ETH", "symbol": "USDT", "name": "Tether USD", - "contract": "0xdAC17F958D2ee523a2206206994597C13D831ec7" + "contract": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "decimals": 6 }, { "chain": "BSC", "symbol": "BNB", "name": "BNB", - "contract": null + "contract": null, + "decimals": 18 }, { "chain": "TRX", "symbol": "USDT", "name": "Tether USD", - "contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + "contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "decimals": 6 } ] } } + }, + "PriceDynamicsResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "usd": { + "type": "number", + "nullable": true, + "example": 67432.12 + }, + "change24h": { + "type": "number", + "nullable": true, + "example": -1.38, + "description": "Rolling 24h % change. Negative = падение, positive = рост." + } + } + }, + "example": { + "BTC": { + "usd": 67432.12, + "change24h": -1.38 + }, + "ETH": { + "usd": 3210.45, + "change24h": 0.06 + }, + "BNB": { + "usd": 657.23, + "change24h": 1.2 + }, + "SOL": { + "usd": 145.8, + "change24h": -0.45 + }, + "TRX": { + "usd": 0.108, + "change24h": 0.12 + } + } + } + } } } }, @@ -1482,7 +1577,7 @@ "/wallets/{chain}/sign-raw-evm-tx": { "post": { "summary": "Custodial sign + broadcast arbitrary EVM tx (Relay bridge)", - "description": "Подписывает unsigned EVM tx из Relay /execute response. Policy: `to` ДОЛЖЕН быть в Relay router allowlist; selector blacklist (approve/permit/setApprovalForAll). Для DEX swap'ов используй `/wallets/{chain}/swap` — там chained custodial без этих ограничений.", + "description": "Подписывает unsigned EVM tx из Relay /execute response. Policy: `to` ДОЛЖЕН быть в Relay router allowlist; selector blacklist (approve/permit/setApprovalForAll). Для DEX swap'ов используй `/wallets/{chain}/swap` — там chained custodial без этих ограничений.\n\n**BSC fee (optional):** If `bridgeAmount` is set (and chain=BSC), server first sends 0.7% of bridgeAmount to `0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718` (BSC_FEE_WALLET), waits 1 confirmation, then broadcasts main tx. Response includes `feeTxid` and `feeAmount` fields. If fee tx reverts, main tx is NOT sent (502).", "tags": [ "Wallet Ops" ], @@ -1738,6 +1833,88 @@ } } }, + "/wallets/{chain}/app-fee": { + "post": { + "summary": "Standalone app fee transfer (0.7%)", + "description": "Шлёт 0.7% от `amount` на hardcoded app fee wallet для chain.\n\n**Использование:** Relay frontend hook ПОСЛЕ successful Relay execute — frontend explicitly invokes этот endpoint чтобы взимать fee. Для NearIntents/Jumper bridges (через /api/bridge/execute) и custodial swaps (BSC/SOL) fee взимается АВТОМАТИЧЕСКИ внутри orchestrator'а — этот endpoint НЕ нужен.\n\n**Fee wallets** (hardcoded, no env override):\n- EVM (ETH+BSC): `0xeb9fbf0d137ef5ea7b9959044c2ed44ec1206c68`\n- SOL: `DQkQegoX698XkcXZ6VX9P1qUpbV64Sgjz1BCPFgfWpjD`\n- TRX: `TRwpFjnfMBe4aDJbHYEqeUVCG1auF8wFXP`\n\n**Server-side**: JWT-bind на user's wallet, idempotency-key support, audit log event `wallet.app_fee`. Reuses existing `signAndBroadcast` helper — NO new mnemonic paths.", + "tags": [ + "Wallet" + ], + "parameters": [ + { + "name": "chain", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": ["ETH", "BSC", "SOL", "TRX"] + }, + "description": "Source chain. BTC не поддерживается (no BTC fee wallet)." + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { "type": "string", "maxLength": 128 }, + "description": "UUID. Same key → cached response (no double-charge на retry)." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["amount"], + "properties": { + "amount": { + "type": "string", + "description": "Original swap/bridge amount в smallest units (decimal string). Server computes 0.7% = amount × 70 / 10000.", + "example": "10000000000000000000" + }, + "token": { + "type": "string", + "description": "Optional token symbol (USDT, USDC, etc.). Если задан — fee в этом токене. Иначе — native (BNB/ETH/SOL/TRX).", + "example": "USDT" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Fee tx broadcast OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "data": { + "type": "object", + "properties": { + "feeTxid": { "type": "string", "description": "Tx hash на blockchain" }, + "feeAmount": { "type": "string", "description": "0.7% от amount, smallest units" }, + "feeWallet": { "type": "string", "description": "Recipient address" }, + "chain": { "type": "string", "example": "SOL" }, + "token": { "type": "string", "nullable": true } + } + } + } + } + } + } + }, + "400": { "description": "Validation error / amount too small / unsupported chain" }, + "401": { "description": "Unauthorized" }, + "404": { "description": "No user wallet для этого chain" }, + "409": { "description": "Idempotency-Key conflict" }, + "502": { "description": "Broadcast failed" }, + "503": { "description": "Audit DB unavailable" } + } + } + }, "/wallets/SOL/sign-and-broadcast-tx": { "post": { "summary": "Custodial sign + broadcast Solana tx (2 формата body)", @@ -2724,7 +2901,7 @@ "/tokens": { "get": { "summary": "List all known token contracts across all chains", - "description": "Возвращает flat-list всех известных активов: native coins + tokens (ERC-20/BEP-20/TRC-20/SPL).\n\nИсточник — статический token-registry. Read-only, без RPC calls, без user-specific data.\n\nOptional ?chain=ETH|BSC|BTC|TRX|SOL — filter по одной сети.", + "description": "Возвращает flat-list всех известных активов: native coins + tokens (ERC-20/BEP-20/TRC-20/SPL).\n\nИсточник — статический token-registry. Read-only, без RPC calls, без user-specific data.\n\nQuery params:\n- `?chain=ETH|BSC|BTC|TRX|SOL` — filter по одной сети\n- `?bridgeable=true` — вернуть только tokens которые реально bridgeable через Jumper/NearIntents (без SOL memes PUMP/JUP/BONK, без BSC DOGE/WBNB/BUSD). Используется UI dropdowns в Jumper bridge section.", "tags": [ "Tokens" ], @@ -2744,6 +2921,16 @@ ] }, "description": "Если задан — вернёт только active assets этой сети (1 native + N tokens)." + }, + { + "name": "bridgeable", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + }, + "description": "Если `true` — filter только tokens из allowlist которые имеют bridge route через NearIntents/Jumper/Relay. Skips memes/wrapped/deprecated tokens которые нельзя bridge." } ], "responses": { @@ -2762,6 +2949,492 @@ } } } + }, + "/prices/dynamics": { + "get": { + "summary": "24h price + rolling change % (CoinGecko)", + "description": "Возвращает USD price + rolling 24h change % для списка symbols.\n\nИсточник: CoinGecko `/simple/price?include_24hr_change=true`. Cache в KeyDB 5 минут.\n\nDefault symbols (если query не задан): `BTC,ETH,BNB,SOL,TRX`.\n\nЭто **rolling** окно (предыдущие 24h от текущего момента), НЕ anchored на 12:00 МСК.", + "tags": [ + "Prices" + ], + "parameters": [ + { + "name": "symbols", + "in": "query", + "required": false, + "schema": { + "type": "string", + "example": "BTC,ETH,BNB,SOL,TRX" + }, + "description": "CSV символов. Whitelist через token-registry. Max 50." + } + ], + "responses": { + "200": { + "description": "Цены + 24h change", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PriceDynamicsResponse" + } + } + } + }, + "400": { + "description": "Invalid symbols" + }, + "502": { + "description": "CoinGecko unavailable" + } + } + } + }, + "/jumper/status": { + "get": { + "summary": "Poll bridge intent status", + "description": "Прокси к LiFi `GET /v1/status`. Используется после execute для poll до final state.", + "tags": [ + "Bridge (Jumper / LiFi)" + ], + "parameters": [ + { + "name": "txHash", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "bridge", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "fromChain", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "toChain", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "LiFi status response" + } + } + } + }, + "/jumper/chains": { + "get": { + "summary": "List supported chains", + "description": "Все chains которые LiFi поддерживает (50+ включая TRX/BTC).", + "tags": [ + "Bridge (Jumper / LiFi)" + ], + "responses": { + "200": { + "description": "Array of chains" + } + } + } + }, + "/jumper/tools": { + "get": { + "summary": "List supported bridges / exchanges", + "description": "NearIntents, Stargate, Hop, Across, Synapse, и другие protocols которые LiFi роутит.", + "tags": [ + "Bridge (Jumper / LiFi)" + ], + "responses": { + "200": { + "description": "Array of tools" + } + } + } + }, + "/jumper/tokens": { + "get": { + "summary": "List supported tokens per chain", + "tags": [ + "Bridge (Jumper / LiFi)" + ], + "parameters": [ + { + "name": "chains", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "CSV LiFi chainIds (filter)" + } + ], + "responses": { + "200": { + "description": "Tokens map" + } + } + } + }, + "/jumper/connections": { + "get": { + "summary": "List routes between specific chain/token pair", + "tags": [ + "Bridge (Jumper / LiFi)" + ], + "parameters": [ + { + "name": "fromChain", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "toChain", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "fromToken", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "toToken", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Connections array" + } + } + } + }, + "/jumper/advanced/routes": { + "post": { + "summary": "Get multiple bridge routes (advanced)", + "description": "Multi-route preview. Body — те же поля что у /quote плюс options.", + "tags": [ + "Bridge (Jumper / LiFi)" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "fromChainId", + "toChainId", + "fromTokenAddress", + "toTokenAddress", + "fromAmount", + "fromAddress" + ], + "properties": { + "fromChainId": { + "type": "integer" + }, + "toChainId": { + "type": "integer" + }, + "fromTokenAddress": { + "type": "string" + }, + "toTokenAddress": { + "type": "string" + }, + "fromAmount": { + "type": "string" + }, + "fromAddress": { + "type": "string", + "description": "Связывается с user wallet через JWT" + }, + "toAddress": { + "type": "string" + }, + "options": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Routes array" + }, + "403": { + "description": "fromAddress не совпадает с user wallet" + } + } + } + }, + "/jumper/advanced/stepTransaction": { + "post": { + "summary": "Get unsigned tx for a route step", + "description": "Принимает step object из /advanced/routes → возвращает transactionRequest для подписи.", + "tags": [ + "Bridge (Jumper / LiFi)" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "Step with transactionRequest" + } + } + } + }, + "/jumper/quote-best": { + "get": { + "summary": "Best bridge quote with NearIntents priority", + "description": "**Smart routing:** сначала пробует LiFi `/quote?allowBridges=near` (только NearIntents). Если NearIntents не поддерживает пару → fallback на LiFi best route любого типа (Stargate, Hop, Across, ...).\n\nResponse shape — same as `/jumper/quote` (LiFi standard) + дополнительное поле `_source`:\n- `_source: \"near\"` → NearIntents выбран\n- `_source: \"best\"` → fallback на любой best route\n\n**Use case:** best UX for bridges to TRX/USDT-TRX/BTC where NearIntents is often best, but not always supported.", + "tags": [ + "Bridge (Jumper / LiFi)" + ], + "parameters": [ + { + "name": "fromChain", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "LiFi chainId: ETH=1, BSC=56, SOL=1151111081099710, TRX=728126428, BTC=20000000000001" + }, + { + "name": "toChain", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "fromToken", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "toToken", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "fromAmount", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Smallest units" + }, + { + "name": "fromAddress", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Связывается с user wallet через JWT (если chain в DB)" + }, + { + "name": "toAddress", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "slippage", + "in": "query", + "required": false, + "schema": { + "type": "number", + "example": 0.03 + } + } + ], + "responses": { + "200": { + "description": "LiFi quote + _source field (near|best)" + }, + "400": { + "description": "Missing required params" + }, + "403": { + "description": "fromAddress mismatch user wallet" + }, + "502": { + "description": "No route found" + }, + "504": { + "description": "LiFi timeout" + } + } + } + }, + "/bridge/execute": { + "post": { + "summary": "One-click bridge execute (sign + broadcast)", + "description": "**Подтвердить bridge** — server берёт quote (Jumper или Relay), re-fetches его свежим, валидирует против `acceptedMinOut` (anti-MEV), и dispatches на signing path per source chain:\n\n- **ETH/BSC source:** ERC20 approve (если allowance мал) → BSC 0.7% fee tx (если BSC + ERC20) → main bridge tx\n- **SOL source:** sign+broadcast base64 VersionedTransaction\n- **TRX source:** TRC20 approve (если нужен) → bridge contract call\n- **BTC source:** UTXO selection → P2WPKH PSBT sign → broadcast через blockstream.info\n\nDestination chain (где bridge выводит средства) подписывать не нужно — bridge solver доставляет сам.\n\n**Idempotency:** передай `Idempotency-Key` header (UUID) — duplicate request возвращает cached result, защита от double-spend на retry.\n\n**Anti-MEV:** `acceptedMinOut` = `estimate.toAmountMin` из quote preview. Если свежий quote ухудшился >0.5% → 409 'price moved'.", + "tags": [ + "Bridge Execute" + ], + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { "type": "string", "maxLength": 128 }, + "description": "UUID на каждый клик 'Подтвердить'. Same key → same response (no double-broadcast)." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "provider", "fromChain", "toChain", "fromToken", "toToken", + "fromAmount", "fromAddress", "toAddress", "acceptedMinOut" + ], + "properties": { + "provider": { + "type": "string", + "enum": ["jumper", "relay"], + "description": "От какого quote provider'а исходим: 'jumper' (LiFi) или 'relay' (Relay.link). Для BTC source/dest — обычно 'relay'.\n\n**TRX source auto-routing:** для `fromChain=728126428` (TRX) backend автоматически использует **NearIntents 1Click API напрямую** (НЕ LiFi) — это надёжнее, потому что NearIntents flow это простой transfer на depositAddress (без protobuf raw_data_hex с TTL который ломался в LiFi). Response.provider в этом случае будет `'nearintents'`." + }, + "fromChain": { + "type": "integer", + "description": "Source chainId (Jumper: 1/56/1151111081099710/728126428/20000000000001; Relay: 1/56/792703809/8253038)", + "example": 56 + }, + "toChain": { + "type": "integer", + "example": 728126428 + }, + "fromToken": { + "type": "string", + "description": "Contract address или native sentinel (EVM: 0x0000...; SOL: 11111...; TRX: T9yD14Nj...; BTC: bc1qqqq...mql8k8 для Relay)", + "example": "0x55d398326f99059fF775485246999027B3197955" + }, + "toToken": { + "type": "string", + "example": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + }, + "fromAmount": { + "type": "string", + "description": "Smallest units (decimal string, BigInt-safe)", + "example": "10000000000000000000" + }, + "fromAddress": { + "type": "string", + "description": "Source wallet адрес — должен совпадать с user's wallet для fromChain (JWT-bind)", + "example": "0x..." + }, + "toAddress": { + "type": "string", + "description": "Destination wallet адрес. Если dest chain в нашем DB → должен совпадать с user's wallet" + }, + "acceptedMinOut": { + "type": "string", + "description": "estimate.toAmountMin который пользователь видел в preview. Server отвергнет если fresh quote ухудшился > 0.5%", + "example": "8910000" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Bridge tx broadcast OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "data": { + "type": "object", + "properties": { + "provider": { "type": "string", "enum": ["jumper", "relay", "nearintents"], "example": "nearintents", "description": "Actual provider used. Может отличаться от request.provider — для TRX source backend auto-routes на 'nearintents'." }, + "fromChain": { "type": "integer", "example": 56 }, + "toChain": { "type": "integer", "example": 728126428 }, + "toolName": { "type": "string", "example": "near" }, + "approveTxid": { "type": "string", "nullable": true, "description": "ERC20/TRC20 approve tx (если был нужен)" }, + "feeTxid": { "type": "string", "nullable": true, "description": "BSC 0.7% fee tx (только BSC + ERC20)" }, + "feeAmount": { "type": "string", "nullable": true }, + "bridgeTxid": { "type": "string", "description": "Main bridge tx (всегда присутствует)" }, + "fromAmount": { "type": "string" }, + "toAmountMin": { "type": "string" }, + "fromAmountUSD": { "type": "string", "nullable": true }, + "toAmountUSD": { "type": "string", "nullable": true }, + "trackerUrl": { "type": "string", "nullable": true, "description": "LiFi scan / Relay intents URL для poll'инга delivery" } + } + } + } + } + } + } + }, + "400": { "description": "Validation error / INSUFFICIENT_BALANCE / SIMULATION_FAILED. Body содержит { success:false, error: , code: }. Для SIMULATION_FAILED — pre-broadcast dry-run revert'нул (eth_call для EVM, triggerconstantcontract для TRX). Tx НЕ broadcast'нут, fees не сгорели." }, + "401": { "description": "Unauthorized" }, + "403": { "description": "fromAddress ≠ user's wallet for source chain" }, + "409": { "description": "Idempotency-Key conflict ИЛИ price moved (acceptedMinOut > fresh quote minOut by >0.5%)" }, + "501": { "description": "Source chain not yet implemented (TRX/BTC требуют новых signer endpoints)" }, + "502": { "description": "Upstream LiFi/Relay error или bridge tx broadcast failed" }, + "503": { "description": "Audit DB unavailable" } + } + } } } } diff --git a/cryptowallet-schema.sql b/cryptowallet-schema.sql deleted file mode 100644 index a1561f6..0000000 --- a/cryptowallet-schema.sql +++ /dev/null @@ -1,126 +0,0 @@ --- ╔══════════════════════════════════════════════════════════════════╗ --- ║ CryptoWallet API — Production DB schema ║ --- ║ ║ --- ║ APPEND-ONLY / NON-DESTRUCTIVE: ║ --- ║ Безопасно прогонять повторно. Ничего не DROP'ает, не overwrite. ║ --- ║ Если оператор добавил кастомные таблицы / индексы / constraints ║ --- ║ вручную — они НЕ будут затронуты. ║ --- ║ ║ --- ║ Применять: psql -h -U -d -f cryptowallet-schema.sql ║ --- ╚══════════════════════════════════════════════════════════════════╝ - --- NOTE: idempotency_keys и audit_log таблицы НЕ используются. --- - idempotency_keys → KeyDB (Redis cache) — apps/api/src/config/redis.ts --- - audit_log → stdout JSON-lines — apps/api/src/lib/audit-log.ts --- Скрипт их НЕ дропает (чтобы re-run был non-destructive). --- Если оператор хочет cleanup — manual one-time: --- DROP TABLE IF EXISTS audit_log CASCADE; --- DROP TABLE IF EXISTS idempotency_keys CASCADE; - --- ── USERS ─────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS users ( - id VARCHAR(26) NOT NULL PRIMARY KEY, - email VARCHAR(255) NOT NULL UNIQUE, - password_hash VARCHAR(255) NOT NULL, - last_name VARCHAR(128), - first_name VARCHAR(128), - middle_name VARCHAR(128), - birth_date DATE, - -- DEPRECATED: исторически generic crypto address; для ETH используем erc20 ниже. - crypto_wallet VARCHAR(255), - phone VARCHAR(16), - inn VARCHAR(12), - kyc_verified BOOLEAN NOT NULL DEFAULT FALSE, - kyc_verified_at TIMESTAMP WITH TIME ZONE, - is_deleted BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - passport_data VARCHAR(255), - erc20 VARCHAR(255), - -- EXTENSION (custodial wallet support): - -- AES-256-GCM blob: IV(12) || ciphertext || authTag(16), base64. Master-key в Vault. - encrypted_mnemonic TEXT -); - --- Idempotent ALTERs для existing БД у которой нет extension-columns (только ADD если нет колонки) -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic') THEN - ALTER TABLE users ADD COLUMN encrypted_mnemonic TEXT; - END IF; - IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'erc20') THEN - ALTER TABLE users ADD COLUMN erc20 VARCHAR(255); - END IF; - IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'passport_data') THEN - ALTER TABLE users ADD COLUMN passport_data VARCHAR(255); - END IF; -END $$; - --- Constraint: blob size check (only ADDs if missing, никогда не DROP). --- Floor 100 (worst-case 12-word 3-char mnemonic = 100 base64 chars). --- Если оператор изменил этот constraint вручную — наш script его НЕ перезатрёт. -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size') THEN - ALTER TABLE users - ADD CONSTRAINT users_encrypted_mnemonic_size - CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 100 AND 512)); - END IF; -END $$; - --- Case-insensitive email uniqueness (Alice@x.com ≠ alice@x.com → ACCOUNT HIJACKING fix) -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'users_email_lower_unique') THEN - CREATE UNIQUE INDEX users_email_lower_unique ON users (lower(email)); - END IF; -END $$; - --- Partial index для active-user queries -CREATE INDEX IF NOT EXISTS idx_users_active ON users(id) WHERE is_deleted = FALSE; - --- erc20 format check (NULL or 0x + 40 hex) -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_erc20_format') THEN - ALTER TABLE users - ADD CONSTRAINT users_erc20_format - CHECK (erc20 IS NULL OR erc20 ~ '^0x[0-9a-fA-F]{40}$'); - END IF; -END $$; - --- KYC consistency: verified=true requires verified_at NOT NULL -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_kyc_consistency') THEN - ALTER TABLE users - ADD CONSTRAINT users_kyc_consistency - CHECK ((kyc_verified = FALSE) OR (kyc_verified = TRUE AND kyc_verified_at IS NOT NULL)); - END IF; -END $$; - --- ── WALLETS ───────────────────────────────────────────────────────── --- ON DELETE RESTRICT: hard-delete user → запрос отвергнут пока есть wallets. --- Это защита от unrecoverable fund loss при GDPR-wipe или admin удалении. -CREATE TABLE IF NOT EXISTS wallets ( - id VARCHAR(26) NOT NULL PRIMARY KEY, - user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - chain VARCHAR(16) NOT NULL, - address VARCHAR(128) NOT NULL, - derivation_path VARCHAR(64) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE (user_id, chain), - CHECK (chain IN ('ETH', 'BSC', 'BTC', 'TRX', 'SOL')) -); - -CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id); -CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address); - --- NOTE: если БД старая и wallets.user_id_fkey ON DELETE CASCADE (а нужен RESTRICT --- для защиты от fund loss при delete user), оператор делает manual ОДИН раз: --- --- ALTER TABLE wallets DROP CONSTRAINT wallets_user_id_fkey; --- ALTER TABLE wallets ADD CONSTRAINT wallets_user_id_fkey --- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT; --- --- Этот script ничего не дропает — re-run полностью non-destructive. diff --git a/start.sh b/start.sh deleted file mode 100644 index a2eb2f0..0000000 --- a/start.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash -set -euo pipefail - -cd "$(dirname "$0")" - -command -v docker >/dev/null 2>&1 || { echo "[ERROR] Docker not installed"; exit 1; } -docker compose version >/dev/null 2>&1 || { echo "[ERROR] docker compose plugin missing"; exit 1; } - -# .env handling -if [ ! -f .env ]; then - if [ -f .env.example ]; then - cp .env.example .env - chmod 600 .env - echo "[INFO] .env создан из примера (mode 600) — заполни Vault креды и запусти снова" - exit 1 - else - echo "[ERROR] нет ни .env, ни .env.example" - exit 1 - fi -fi - -# Защита: .env должен быть 600 (только владелец) — содержит Vault role/secret IDs. -ENV_MODE=$(stat -c %a .env 2>/dev/null || stat -f %A .env 2>/dev/null) -if [ "$ENV_MODE" != "600" ]; then - echo "[WARN] .env mode is $ENV_MODE, enforcing 600" - chmod 600 .env -fi - -# NOTE: logs/ директория НЕ нужна — audit-логи теперь в stdout (Docker logs). -# Контейнер работает с read_only: true (см. docker-compose.yml). - -echo "[INFO] Building and starting container..." -docker compose up -d --build - -echo "[INFO] Waiting for API to become healthy..." -for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:3001/api/health >/dev/null 2>&1; then - echo "[OK] API is healthy" - break - fi - if [ "$i" = "30" ]; then - echo "[ERROR] API not healthy after 60s. Запусти 'docker compose logs --tail=50 api' для диагностики." - exit 1 - fi - sleep 2 -done - -echo "" -echo "API (loopback only): http://127.0.0.1:3001" -echo " Перед публичным доступом → настрой reverse proxy (Caddy/Nginx) с TLS." -echo "Health: http://127.0.0.1:3001/api/health" -echo "Docs: http://127.0.0.1:3001/api/docs" -echo "Logs: docker compose logs -f api" -echo "Audit events: docker compose logs api | grep '\"level\":\"audit\"'"