init
This commit is contained in:
34
.env
Normal file
34
.env
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Local .env for docker compose ${REDIS_PASSWORD} interpolation.
|
||||||
|
# DO NOT COMMIT (already in .gitignore). На прод-боксе оператор создаёт свой через `cp .env.example .env`.
|
||||||
|
|
||||||
|
VAULT_ADDR=
|
||||||
|
VAULT_ROLE_ID=
|
||||||
|
VAULT_SECRET_ID=
|
||||||
|
VAULT_MOUNT_POINT=dev-secrets
|
||||||
|
VAULT_SECRET_PATH=database
|
||||||
|
VAULT_JWT_KID_PATH=jwt/kid
|
||||||
|
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
||||||
|
VAULT_CSRF_PATH=csrf
|
||||||
|
VAULT_CRYPTO_KEY_PATH=crypto/master
|
||||||
|
|
||||||
|
JWT_ALGORITHM=RS256
|
||||||
|
JWT_ISSUER=bitok
|
||||||
|
JWT_AUDIENCE=elcsa
|
||||||
|
|
||||||
|
API_PORT=3001
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
CORS_ORIGINS=*
|
||||||
|
CORS_ALLOW_CREDENTIALS=false
|
||||||
|
|
||||||
|
REDIS_HOST=keydb
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=0O7klMYUvwwR19UORSzEtsRn9kUPnDyfkJ9GDH2yMERYV0vRCU
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# Price oracle (CoinGecko free tier — без ключа работает).
|
||||||
|
COINGECKO_API_KEY=
|
||||||
|
|
||||||
|
# Outbound proxy для swap + bridge endpoints.
|
||||||
|
# Если задан — Jupiter/Relay/RPC calls идут через proxy. Read-only direct.
|
||||||
|
OUTBOUND_PROXY_URL=http://37.220.84.34:3128
|
||||||
79
.env.example
79
.env.example
@@ -1,79 +0,0 @@
|
|||||||
# ── Vault (AppRole) ────────────────────────────────────────────────
|
|
||||||
VAULT_ADDR=
|
|
||||||
VAULT_ROLE_ID=
|
|
||||||
VAULT_SECRET_ID=
|
|
||||||
VAULT_MOUNT_POINT=dev-secrets
|
|
||||||
VAULT_SECRET_PATH=database
|
|
||||||
VAULT_JWT_KID_PATH=jwt/kid
|
|
||||||
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
|
||||||
|
|
||||||
# CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF)
|
|
||||||
VAULT_CSRF_PATH=csrf
|
|
||||||
|
|
||||||
# Crypto master-key для шифрования мнемоник юзеров (AES-256-GCM).
|
|
||||||
# В Vault лежит hex-строка длиной 64 (32 байта).
|
|
||||||
# Положить: vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32)
|
|
||||||
VAULT_CRYPTO_KEY_PATH=crypto/master
|
|
||||||
|
|
||||||
# ── JWT (внешний bitok issuer) ─────────────────────────────────────
|
|
||||||
# bitok-сервис подписывает JWT своим приватником, public key регистрируется
|
|
||||||
# в Vault под kid'ом (см. VAULT_JWT_KIDS_PREFIX).
|
|
||||||
# Allowed alg: RS256 / RS384 / RS512 / ES256 / ES384 / ES512 / EdDSA / PS256 / PS384 / PS512
|
|
||||||
JWT_ALGORITHM=RS256
|
|
||||||
JWT_ISSUER=bitok
|
|
||||||
JWT_AUDIENCE=elcsa
|
|
||||||
|
|
||||||
# ── Server ─────────────────────────────────────────────────────────
|
|
||||||
API_PORT=3001
|
|
||||||
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_ALLOW_CREDENTIALS=true
|
|
||||||
|
|
||||||
# ── External API keys (optional, fallback если Vault их не выдаёт) ─
|
|
||||||
RELAY_API_KEY=
|
|
||||||
TRON_API_KEY=
|
|
||||||
JUPITER_API_KEY=
|
|
||||||
JUPITER_REFERRAL_ACCOUNT=
|
|
||||||
JUPITER_FEE_BPS=70
|
|
||||||
|
|
||||||
# ── Block explorers (optional, для tx history) ─────────────────────
|
|
||||||
ETHERSCAN_API_KEY=
|
|
||||||
BSCSCAN_API_KEY=
|
|
||||||
|
|
||||||
# ── Price oracle (optional) ─────────────────────────────────────────
|
|
||||||
# CoinGecko Demo API key. Без ключа работает free tier (~10-30 req/min).
|
|
||||||
# Если задан → передаётся через header `x-cg-demo-api-key`.
|
|
||||||
# Используется в /api/wallets/{chain}/balance (для usdPrice/usdValue)
|
|
||||||
# и /api/prices?symbols=... KeyDB cache: 5 минут.
|
|
||||||
COINGECKO_API_KEY=
|
|
||||||
|
|
||||||
# ── 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)
|
|
||||||
OUTBOUND_PROXY_URL=
|
|
||||||
|
|
||||||
# ── DB fallback (если Vault недоступен при старте) ─────────────────
|
|
||||||
DB_HOST=
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_USER=
|
|
||||||
DB_PASSWORD=
|
|
||||||
DB_NAME=
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,11 +0,0 @@
|
|||||||
.env
|
|
||||||
.env.local
|
|
||||||
node_modules/
|
|
||||||
**/node_modules/
|
|
||||||
dist/
|
|
||||||
**/dist/
|
|
||||||
*.log
|
|
||||||
logs/
|
|
||||||
.DS_Store
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
64
README.md
64
README.md
@@ -54,6 +54,7 @@ ssh server@<host> -p 2222 'cd cryptowallet && docker compose up -d --build'
|
|||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
|
### Core wallet management
|
||||||
| Method | Path | Описание |
|
| Method | Path | Описание |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/api/health` | Liveness (public) |
|
| GET | `/api/health` | Liveness (public) |
|
||||||
@@ -61,13 +62,61 @@ ssh server@<host> -p 2222 'cd cryptowallet && docker compose up -d --build'
|
|||||||
| GET | `/api/docs/swagger.json` | OpenAPI JSON |
|
| GET | `/api/docs/swagger.json` | OpenAPI JSON |
|
||||||
| POST | **`/api/wallets/create`** | Сервер создаёт коша (no body, returns 5 addresses) |
|
| POST | **`/api/wallets/create`** | Сервер создаёт коша (no body, returns 5 addresses) |
|
||||||
| GET | `/api/wallets` | Список адресов юзера |
|
| 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) |
|
| POST | **`/api/wallets/mnemonic/reveal`** | Reveal seed (body confirm + 5/час rate-limit) |
|
||||||
| GET | `/api/wallets/{chain}/balance` | Баланс (native + все известные токены чейна) |
|
| GET | `/api/wallets/{chain}/balance` | Баланс (native + все известные токены чейна) |
|
||||||
| GET | `/api/wallets/{chain}/transactions` | История tx |
|
| GET | `/api/wallets/{chain}/transactions` | История tx |
|
||||||
| POST | **`/api/wallets/{chain}/send`** | Сервер подписывает + broadcast. Body: `{to, amount, token?, feeTier?}` |
|
| 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) |
|
| 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) |
|
| 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). |
|
||||||
| — | `/api/btc/*` `/api/tron/*` `/api/sol/*` `/api/bsc/*` `/api/relay/*` | Proxy endpoints (swap quote/build, bridge quote/execute/status) |
|
| 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
|
## Security highlights
|
||||||
|
|
||||||
@@ -87,12 +136,19 @@ ssh server@<host> -p 2222 'cd cryptowallet && docker compose up -d --build'
|
|||||||
- **BTC** P2WPKH bech32, dust 294, broadcast 20s timeout
|
- **BTC** P2WPKH bech32, dust 294, broadcast 20s timeout
|
||||||
- **POST mnemonic/reveal** + CSRF + body confirm token + 5/час rate-limit + audit-log
|
- **POST mnemonic/reveal** + CSRF + body confirm token + 5/час rate-limit + audit-log
|
||||||
- **Logger sanitization** — password/token/mnemonic/hex64/BIP39-phrase patterns маскируются
|
- **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 НЕ ротируется)
|
- **Hourly key rotation** — JWT keys + CSRF secret из Vault (master-key НЕ ротируется)
|
||||||
- **Fail-fast** — сервис не стартует без master-key, JWT_ISSUER, JWT_AUDIENCE
|
- **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
|
- **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'ов
|
- **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
|
## Schema is non-destructive
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter }
|
|||||||
import { errorHandler } from './middleware/error-handler';
|
import { errorHandler } from './middleware/error-handler';
|
||||||
import walletRoutes from './routes/wallet.routes';
|
import walletRoutes from './routes/wallet.routes';
|
||||||
import relayProxyRoutes from './routes/relay-proxy.routes';
|
import relayProxyRoutes from './routes/relay-proxy.routes';
|
||||||
|
import jumperProxyRoutes from './routes/jumper-proxy.routes';
|
||||||
import tronProxyRoutes from './routes/tron-proxy.routes';
|
import tronProxyRoutes from './routes/tron-proxy.routes';
|
||||||
import btcProxyRoutes from './routes/btc-proxy.routes';
|
import btcProxyRoutes from './routes/btc-proxy.routes';
|
||||||
import pricesRoutes from './routes/prices.routes';
|
import pricesRoutes from './routes/prices.routes';
|
||||||
import tokensRoutes from './routes/tokens.routes';
|
import tokensRoutes from './routes/tokens.routes';
|
||||||
|
import bridgeRoutes from './routes/bridge.routes';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -102,6 +104,9 @@ app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter);
|
|||||||
// Mutating (proxy + read endpoints) — повышенный лимит
|
// Mutating (proxy + read endpoints) — повышенный лимит
|
||||||
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
|
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
|
||||||
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes);
|
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes);
|
||||||
|
// Jumper.xyz — LiFi-backed bridge aggregator (50+ chains: ETH/BSC/SOL/TRX/BTC + others).
|
||||||
|
// Используется когда Relay не поддерживает направление (TRX/BTC bridges).
|
||||||
|
app.use('/api/jumper', ...protect, mutateLimiter, jumperProxyRoutes);
|
||||||
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes);
|
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes);
|
||||||
app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes);
|
app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes);
|
||||||
// Legacy non-custodial swap proxies (/api/bsc/swap, /api/sol/swap, /api/tron/swap)
|
// 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.
|
// Token registry — всех известных contracts/mints по всем chain'ам. GET-only, auth required.
|
||||||
app.use('/api/tokens', ...protect, mutateLimiter, tokensRoutes);
|
app.use('/api/tokens', ...protect, mutateLimiter, tokensRoutes);
|
||||||
|
|
||||||
|
// Bridge execute — one-click "Подтвердить" для bridge через Jumper (LiFi) / Relay.
|
||||||
|
// Dispatcher по source chain: EVM (approve+fee+bridge) / SOL (versioned tx) / TRX (TRC20 approve+bridge) / BTC (PSBT deposit).
|
||||||
|
// Sign + broadcast custodial через server (mnemonic не покидает API).
|
||||||
|
app.use('/api/bridge', ...protect, mutateLimiter, bridgeRoutes);
|
||||||
|
|
||||||
// 404 для всего что не сматчилось выше — единый JSON-ответ, не express default text
|
// 404 для всего что не сматчилось выше — единый JSON-ответ, не express default text
|
||||||
app.use((_req, res) => {
|
app.use((_req, res) => {
|
||||||
res.status(404).json({ success: false, error: 'Not found' });
|
res.status(404).json({ success: false, error: 'Not found' });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Request, Response } from 'express';
|
|||||||
import { getCoingeckoId } from '../lib/token-registry';
|
import { getCoingeckoId } from '../lib/token-registry';
|
||||||
import { ALL_CHAINS } from '../services/wallet-generator.service';
|
import { ALL_CHAINS } from '../services/wallet-generator.service';
|
||||||
import { getPricesBySymbols } from '../services/price-oracle.service';
|
import { getPricesBySymbols } from '../services/price-oracle.service';
|
||||||
|
// getPricesWithChangeByIds импортируется dynamic'но в getDynamics handler ниже.
|
||||||
import type { ChainCode } from '../lib/address-validators';
|
import type { ChainCode } from '../lib/address-validators';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
@@ -135,4 +136,87 @@ export const PricesController = {
|
|||||||
res.status(502).json({ success: false, error: 'Upstream price oracle error' });
|
res.status(502).json({ success: false, error: 'Upstream price oracle error' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/prices/dynamics?symbols=BTC,ETH,BNB,SOL,TRX
|
||||||
|
*
|
||||||
|
* Возвращает USD-цену + 24h % изменения для списка symbols.
|
||||||
|
* Default symbols (если query не задан): BTC,ETH,BNB,SOL,TRX.
|
||||||
|
* Source: CoinGecko `include_24hr_change=true` (rolling 24h, не anchored).
|
||||||
|
*
|
||||||
|
* Response 200:
|
||||||
|
* { success: true, data: { "BTC": { "usd": 67432.12, "change24h": -1.38 }, ... } }
|
||||||
|
*/
|
||||||
|
async getDynamics(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const rawSymbols = String(req.query.symbols || '').trim();
|
||||||
|
const symbols = rawSymbols
|
||||||
|
? rawSymbols.split(',').map((s) => s.trim().toUpperCase()).filter((s) => s.length > 0)
|
||||||
|
: ['BTC', 'ETH', 'BNB', 'SOL', 'TRX'];
|
||||||
|
|
||||||
|
if (symbols.length === 0) {
|
||||||
|
res.status(400).json({ success: false, error: 'symbols list is empty' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (symbols.length > MAX_SYMBOLS_PER_REQUEST) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Too many symbols (max ${MAX_SYMBOLS_PER_REQUEST})`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const s of symbols) {
|
||||||
|
if (!SYMBOL_RE.test(s)) {
|
||||||
|
res.status(400).json({ success: false, error: `Invalid symbol: ${s}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve каждый symbol в CoinGecko id напрямую.
|
||||||
|
// Native tickers: BTC=bitcoin, ETH=ethereum, BNB=binancecoin, SOL=solana, TRX=tron.
|
||||||
|
// Для non-native: пытаемся getCoingeckoId через chain fallback.
|
||||||
|
const NATIVE_TICKER_TO_COINGECKO: Record<string, string> = {
|
||||||
|
BTC: 'bitcoin',
|
||||||
|
ETH: 'ethereum',
|
||||||
|
BNB: 'binancecoin',
|
||||||
|
SOL: 'solana',
|
||||||
|
TRX: 'tron',
|
||||||
|
};
|
||||||
|
const fallbackChains: ChainCode[] = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC'];
|
||||||
|
|
||||||
|
const symbolToCgId = new Map<string, string>();
|
||||||
|
for (const sym of symbols) {
|
||||||
|
let cgId: string | null = NATIVE_TICKER_TO_COINGECKO[sym] ?? null;
|
||||||
|
if (!cgId) {
|
||||||
|
for (const c of fallbackChains) {
|
||||||
|
const id = getCoingeckoId(c, sym);
|
||||||
|
if (id) { cgId = id; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cgId) {
|
||||||
|
res.status(400).json({ success: false, error: `Unknown symbol: ${sym}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
symbolToCgId.set(sym, cgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getPricesWithChangeByIds } = await import('../services/price-oracle.service');
|
||||||
|
const rich = await getPricesWithChangeByIds(Array.from(new Set(symbolToCgId.values())));
|
||||||
|
|
||||||
|
const data: Record<string, { usd: number | null; change24h: number | null }> = {};
|
||||||
|
for (const sym of symbols) {
|
||||||
|
const cgId = symbolToCgId.get(sym)!;
|
||||||
|
const v = rich[cgId];
|
||||||
|
data[sym] = {
|
||||||
|
usd: v?.usd ?? null,
|
||||||
|
change24h: v?.change24h ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`getDynamics failed: ${err?.stack || err?.message || 'unknown'}`);
|
||||||
|
res.status(502).json({ success: false, error: 'Upstream price oracle error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getBalance, getTransactions, getPortfolio as getPortfolioService } from
|
|||||||
import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
|
import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
|
||||||
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
|
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
|
||||||
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.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 {
|
import {
|
||||||
quoteBsc, executeBsc,
|
quoteBsc, executeBsc,
|
||||||
quoteTrx, executeTrx,
|
quoteTrx, executeTrx,
|
||||||
@@ -659,7 +659,8 @@ export const WalletController = {
|
|||||||
return;
|
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;
|
let normalizedFeeTier: FeeTier | undefined;
|
||||||
if (feeTier !== undefined && feeTier !== null) {
|
if (feeTier !== undefined && feeTier !== null) {
|
||||||
@@ -670,6 +671,25 @@ export const WalletController = {
|
|||||||
normalizedFeeTier = feeTier;
|
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'а.
|
// Базовая структурная валидация — детальные cap'ы внутри signer'а.
|
||||||
if (typeof to !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(to)) {
|
if (typeof to !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(to)) {
|
||||||
res.status(400).json({ success: false, error: 'Invalid "to" address' });
|
res.status(400).json({ success: false, error: 'Invalid "to" address' });
|
||||||
@@ -756,6 +776,35 @@ export const WalletController = {
|
|||||||
|
|
||||||
mnemonic = decryptMnemonic(blob);
|
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 };
|
let result: { txid: string };
|
||||||
try {
|
try {
|
||||||
result = await signAndBroadcastRawEvm({
|
result = await signAndBroadcastRawEvm({
|
||||||
@@ -778,8 +827,8 @@ export const WalletController = {
|
|||||||
throw signErr;
|
throw signErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
await completeAudit(auditId, 'success', { txid: result.txid });
|
await completeAudit(auditId, 'success', { txid: result.txid, feeTxid });
|
||||||
res.json({ success: true, data: { txid: result.txid, chain } });
|
res.json({ success: true, data: { txid: result.txid, chain, feeTxid, feeAmount: feeAmountStr } });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error(`signRawEvmTx failed for user ${userId} chain ${chain}: ${err.stack || err.message}`);
|
logger.error(`signRawEvmTx failed for user ${userId} chain ${chain}: ${err.stack || err.message}`);
|
||||||
await auditLog({
|
await auditLog({
|
||||||
@@ -987,8 +1036,14 @@ export const WalletController = {
|
|||||||
amountFormatted: fmtUnits(raw.networkFee.amount, networkFeeAssetDecimals),
|
amountFormatted: fmtUnits(raw.networkFee.amount, networkFeeAssetDecimals),
|
||||||
amountUsd: networkFeeUsd,
|
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).
|
// dex fee включён в expectedOut (Pancake 0.25%, SunSwap 0.3%+0.7% fee router, Jupiter platform varied).
|
||||||
// Не вычисляем отдельно — слишком много moving parts.
|
|
||||||
total: { amountUsd: networkFeeUsd },
|
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_<chain>, 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
83
apps/api/src/lib/app-fee.ts
Normal file
83
apps/api/src/lib/app-fee.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
26
apps/api/src/lib/bsc-fee.ts
Normal file
26
apps/api/src/lib/bsc-fee.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* BSC fee — backwards-compat re-export shim для `app-fee.ts`.
|
||||||
|
*
|
||||||
|
* Раньше (до multi-chain fee feature) этот файл содержал hardcoded
|
||||||
|
* `BSC_FEE_WALLET = '0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718'` и `computeBscFee`.
|
||||||
|
* Теперь fee унифицирован — `app-fee.ts` source of truth с тремя wallets (EVM/SOL/TRX),
|
||||||
|
* а здесь — shim чтобы существующие callers (swap-orchestrator, wallet-signer.service,
|
||||||
|
* wallet.controller) продолжали компилироваться без edit'ов.
|
||||||
|
*
|
||||||
|
* Behavior change: после этого rebuild старый wallet `0xeDEb...` НЕ используется. Все
|
||||||
|
* EVM fees идут на новый `0xeb9fbf0d137ef5ea7b9959044c2ed44ec1206c68` (см. app-fee.ts).
|
||||||
|
*
|
||||||
|
* Существующие callers НЕ нуждаются в code change — они импортируют `BSC_FEE_WALLET`
|
||||||
|
* который теперь = `APP_FEE_WALLET_EVM`. Single source of truth.
|
||||||
|
*
|
||||||
|
* Note: пока оставляем shim для backwards-compat. Если позже захотим — refactor callers
|
||||||
|
* на прямой import из `app-fee.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
APP_FEE_WALLET_EVM as BSC_FEE_WALLET,
|
||||||
|
APP_FEE_BPS as BSC_FEE_BPS,
|
||||||
|
APP_FEE_DENOMINATOR as BSC_FEE_DENOMINATOR,
|
||||||
|
computeAppFee as computeBscFee,
|
||||||
|
computeAmountAfterFee as computeSwapAmountAfterFee,
|
||||||
|
} from './app-fee';
|
||||||
304
apps/api/src/lib/nearintents-client.ts
Normal file
304
apps/api/src/lib/nearintents-client.ts
Normal file
@@ -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_<id>, а не nep141:bsc.omft.near
|
||||||
|
// как я предположил). Теперь fetch /v0/tokens напрямую от NearIntents — authoritative source.
|
||||||
|
//
|
||||||
|
// Cache: in-memory, TTL 1 час, lazy refresh на miss. NearIntents tokens list стабилен
|
||||||
|
// (обновляется при добавлении новых chains, не часто).
|
||||||
|
|
||||||
|
interface NearIntentsToken {
|
||||||
|
blockchain: string; // 'tron', 'sol', 'eth', 'bsc', 'btc', 'aptos', 'arb', etc.
|
||||||
|
symbol: string;
|
||||||
|
contractAddress: string | null; // null для native; lowercased contract для EVM/SOL/TRX
|
||||||
|
decimals: number;
|
||||||
|
assetId: string; // 'nep141:...' или 'nep245:...' (depends on bridge provider)
|
||||||
|
price?: number;
|
||||||
|
priceUpdatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetMapCache {
|
||||||
|
map: Map<string, string>; // key = '<blockchain>:<contract|native>' → assetId
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _assetMapCache: AssetMapCache | null = null;
|
||||||
|
const ASSET_MAP_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
// Наш ChainCode → NearIntents `blockchain` field.
|
||||||
|
const CHAIN_CODE_TO_NEARINTENTS_BLOCKCHAIN: Partial<Record<ChainCode, string>> = {
|
||||||
|
ETH: 'eth',
|
||||||
|
BSC: 'bsc',
|
||||||
|
TRX: 'tron',
|
||||||
|
SOL: 'sol',
|
||||||
|
BTC: 'btc',
|
||||||
|
};
|
||||||
|
|
||||||
|
function assetMapKey(blockchain: string, contract: string | null): string {
|
||||||
|
return `${blockchain.toLowerCase()}:${(contract ?? 'native').toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch full asset map от NearIntents. Cached in-memory 1h.
|
||||||
|
* On cache miss или expiry — refetch. On HTTP error — throws.
|
||||||
|
*/
|
||||||
|
async function fetchAssetMap(): Promise<Map<string, string>> {
|
||||||
|
if (_assetMapCache && _assetMapCache.expiresAt > Date.now()) {
|
||||||
|
return _assetMapCache.map;
|
||||||
|
}
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), NEARINTENTS_TIMEOUT_MS);
|
||||||
|
let res: globalThis.Response;
|
||||||
|
try {
|
||||||
|
res = await proxiedFetch(`${NEARINTENTS_API_URL}/v0/tokens`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`NearIntents /v0/tokens fetch failed (${res.status}): ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const tokens = (await res.json()) as NearIntentsToken[];
|
||||||
|
if (!Array.isArray(tokens)) {
|
||||||
|
throw new Error('NearIntents /v0/tokens returned non-array');
|
||||||
|
}
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (!t.blockchain || !t.assetId) continue;
|
||||||
|
map.set(assetMapKey(t.blockchain, t.contractAddress), t.assetId);
|
||||||
|
}
|
||||||
|
_assetMapCache = { map, expiresAt: Date.now() + ASSET_MAP_TTL_MS };
|
||||||
|
logger.info(`NearIntents asset map loaded: ${map.size} entries across ${new Set(tokens.map((t) => t.blockchain)).size} chains`);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve ChainCode + token → NearIntents assetId via dynamic /v0/tokens lookup.
|
||||||
|
* Returns null если pair не supported (caller должен throw clear error).
|
||||||
|
*
|
||||||
|
* `token === null` → native. Contract addresses lowercased internally для matching.
|
||||||
|
*/
|
||||||
|
export async function resolveAsset(chain: ChainCode, token: string | null): Promise<string | null> {
|
||||||
|
const blockchain = CHAIN_CODE_TO_NEARINTENTS_BLOCKCHAIN[chain];
|
||||||
|
if (!blockchain) {
|
||||||
|
logger.warn(`NearIntents: chain ${chain} not in CHAIN_CODE_TO_NEARINTENTS_BLOCKCHAIN map`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const map = await fetchAssetMap();
|
||||||
|
return map.get(assetMapKey(blockchain, token)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force refresh asset map cache (для тестов / admin debug). Не используется в production flow.
|
||||||
|
*/
|
||||||
|
export function _invalidateAssetMapCache(): void {
|
||||||
|
_assetMapCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Quote ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface NearIntentsQuoteInput {
|
||||||
|
/** Pre-resolved NearIntents assetId (e.g. 'nep141:tron.omft.near'). Используй `resolveAsset()` для получения. */
|
||||||
|
originAssetId: string;
|
||||||
|
/** Pre-resolved NearIntents assetId для destination. */
|
||||||
|
destinationAssetId: string;
|
||||||
|
amount: string; // smallest units of origin asset
|
||||||
|
/** Slippage tolerance в bps (basis points). 50 = 0.5%. Hard-cap 500 (5%) на server. */
|
||||||
|
slippageBps: number;
|
||||||
|
/** User's wallet на origin chain — куда NearIntents вернёт средства если intent fails */
|
||||||
|
refundTo: string;
|
||||||
|
/** User's wallet на destination chain — куда solver доставит destination asset */
|
||||||
|
recipient: string;
|
||||||
|
/** Сколько минут (от сейчас) intent остаётся valid. Default 30, max 60. */
|
||||||
|
deadlineMinutes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NearIntentsQuoteResult {
|
||||||
|
/** Tron base58 address куда юзеру отправить amount */
|
||||||
|
depositAddress: string;
|
||||||
|
amountIn: string;
|
||||||
|
amountInFormatted: string;
|
||||||
|
amountInUsd?: string;
|
||||||
|
minAmountIn: string;
|
||||||
|
amountOut: string;
|
||||||
|
amountOutFormatted: string;
|
||||||
|
amountOutUsd?: string;
|
||||||
|
minAmountOut: string;
|
||||||
|
deadline: string; // ISO timestamp
|
||||||
|
deadlineMs: number; // parsed convenience
|
||||||
|
timeWhenInactive: string; // ISO — когда solver перестаёт обрабатывать
|
||||||
|
timeWhenInactiveMs: number;
|
||||||
|
timeEstimateSec: number;
|
||||||
|
signature: string; // ed25519:... — anti-MEV proof, store в audit
|
||||||
|
correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a NearIntents 1Click quote (real, not dry-run — returns depositAddress).
|
||||||
|
*/
|
||||||
|
export async function fetchNearIntentsQuote(input: NearIntentsQuoteInput): Promise<NearIntentsQuoteResult> {
|
||||||
|
if (!input.originAssetId || !input.destinationAssetId) {
|
||||||
|
throw new Error('NearIntents: originAssetId + destinationAssetId required (use resolveAsset() first)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side slippage cap (anti-foot-gun)
|
||||||
|
const slippageBps = Math.min(Math.max(input.slippageBps, 10), 500);
|
||||||
|
|
||||||
|
// Deadline — мы выбираем сами, NearIntents примет любой reasonable timestamp.
|
||||||
|
// 30 минут default (хватает на user reaction + broadcast + solver delivery).
|
||||||
|
const deadlineMinutes = Math.min(Math.max(input.deadlineMinutes ?? 30, 5), 60);
|
||||||
|
const deadline = new Date(Date.now() + deadlineMinutes * 60_000).toISOString();
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
dry: false,
|
||||||
|
swapType: 'EXACT_INPUT',
|
||||||
|
slippageTolerance: slippageBps,
|
||||||
|
originAsset: input.originAssetId,
|
||||||
|
depositType: 'ORIGIN_CHAIN',
|
||||||
|
destinationAsset: input.destinationAssetId,
|
||||||
|
amount: input.amount,
|
||||||
|
refundTo: input.refundTo,
|
||||||
|
refundType: 'ORIGIN_CHAIN',
|
||||||
|
recipient: input.recipient,
|
||||||
|
recipientType: 'DESTINATION_CHAIN',
|
||||||
|
deadline,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), NEARINTENTS_TIMEOUT_MS);
|
||||||
|
let res: globalThis.Response;
|
||||||
|
try {
|
||||||
|
res = await proxiedFetch(`${NEARINTENTS_API_URL}/v0/quote`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn(`NearIntents /v0/quote ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
throw new Error(`NearIntents quote failed (${res.status}): ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: any;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error('NearIntents returned non-JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = json.quote;
|
||||||
|
if (!q || !q.depositAddress) {
|
||||||
|
throw new Error(`NearIntents quote missing depositAddress: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
depositAddress: String(q.depositAddress),
|
||||||
|
amountIn: String(q.amountIn),
|
||||||
|
amountInFormatted: String(q.amountInFormatted || ''),
|
||||||
|
amountInUsd: q.amountInUsd ? String(q.amountInUsd) : undefined,
|
||||||
|
minAmountIn: String(q.minAmountIn || q.amountIn),
|
||||||
|
amountOut: String(q.amountOut),
|
||||||
|
amountOutFormatted: String(q.amountOutFormatted || ''),
|
||||||
|
amountOutUsd: q.amountOutUsd ? String(q.amountOutUsd) : undefined,
|
||||||
|
minAmountOut: String(q.minAmountOut),
|
||||||
|
deadline: String(q.deadline),
|
||||||
|
deadlineMs: new Date(q.deadline).getTime(),
|
||||||
|
timeWhenInactive: String(q.timeWhenInactive || q.deadline),
|
||||||
|
timeWhenInactiveMs: new Date(q.timeWhenInactive || q.deadline).getTime(),
|
||||||
|
timeEstimateSec: Number(q.timeEstimate || 60),
|
||||||
|
signature: String(json.signature || ''),
|
||||||
|
correlationId: String(json.correlationId || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deposit notification ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort: notify NearIntents что мы отправили tx. Solver всё равно мониторит
|
||||||
|
* на-chain — поэтому если этот POST fails, intent всё ещё процессится.
|
||||||
|
*
|
||||||
|
* Errors NOT thrown (caller should ignore failures).
|
||||||
|
*/
|
||||||
|
export async function submitNearIntentsDeposit(depositAddress: string, txHash: string): Promise<void> {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), NEARINTENTS_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await proxiedFetch(`${NEARINTENTS_API_URL}/v0/deposit/submit`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({ depositAddress, txHash }),
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text().catch(() => '');
|
||||||
|
logger.warn(`NearIntents deposit submit ${res.status}: ${txt.slice(0, 200)}`);
|
||||||
|
} else {
|
||||||
|
logger.info(`NearIntents deposit submitted: ${depositAddress} ← ${txHash}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.warn(`NearIntents deposit submit network error: ${err?.message}`);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Status polling helper (для frontend tracker) ────────────────────────────
|
||||||
|
|
||||||
|
export function nearIntentsTrackerUrl(depositAddress: string): string {
|
||||||
|
return `${NEARINTENTS_API_URL}/v0/status?depositAddress=${encodeURIComponent(depositAddress)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── depositAddress validation (security: ensure NearIntents возвращает
|
||||||
|
// валидный Tron address, не attacker-controlled garbage)
|
||||||
|
|
||||||
|
const TRON_BASE58_REGEX = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
@@ -35,14 +35,33 @@ export interface SolToken {
|
|||||||
/**
|
/**
|
||||||
* Flat shape для GET /api/tokens.
|
* Flat shape для GET /api/tokens.
|
||||||
* Native coins имеют contract = null.
|
* 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 {
|
export interface TokenListEntry {
|
||||||
chain: ChainCode;
|
chain: ChainCode;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
name: string;
|
name: string;
|
||||||
contract: string | null;
|
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<ChainCode, number> = {
|
||||||
|
ETH: 18,
|
||||||
|
BSC: 18,
|
||||||
|
BTC: 8,
|
||||||
|
TRX: 6,
|
||||||
|
SOL: 9,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CoinGecko coin IDs для native монет каждой chain.
|
* CoinGecko coin IDs для native монет каждой chain.
|
||||||
* Используется в `price-oracle.service.ts` для USD-цен в `/balance`.
|
* Используется в `price-oracle.service.ts` для USD-цен в `/balance`.
|
||||||
@@ -115,37 +134,78 @@ export const SOL_TOKENS: SolToken[] = [
|
|||||||
|
|
||||||
const ALL_CHAINS_ORDERED: ChainCode[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL'];
|
const ALL_CHAINS_ORDERED: ChainCode[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whitelist of tokens которые реально bridgeable через наш Jumper/NearIntents/Relay path.
|
||||||
|
* Если token не в этом set'е — UI dropdown'ы не показывают его (frontend filter via
|
||||||
|
* `/api/tokens?chain=X&bridgeable=true`).
|
||||||
|
*
|
||||||
|
* Source: cross-reference нашего registry с (а) NearIntents /v0/tokens supported list
|
||||||
|
* (167 assets), (б) LiFi major tokens (USDT/USDC/native + select DeFi), (в) Relay coverage.
|
||||||
|
*
|
||||||
|
* Tokens NOT here (потому что нет ликвидности в bridges):
|
||||||
|
* - SOL: PUMP, JUP, POPCAT, PYTH, JTO, W, BONK, ORCA, RAY — memecoins / DeFi не в NearIntents/LiFi
|
||||||
|
* - BSC: DOGE (BSC-wrapped), WBNB, BUSD — deprecated / no bridge
|
||||||
|
*
|
||||||
|
* Format: 'CHAIN:SYMBOL'. Native всегда included.
|
||||||
|
*/
|
||||||
|
const BRIDGEABLE_TOKENS: Set<string> = new Set([
|
||||||
|
// ETH
|
||||||
|
'ETH:ETH', 'ETH:USDT', 'ETH:USDC', 'ETH:DAI', 'ETH:LINK', 'ETH:UNI', 'ETH:WBTC',
|
||||||
|
// BSC
|
||||||
|
'BSC:BNB', 'BSC:USDT', 'BSC:USDC',
|
||||||
|
// TRX
|
||||||
|
'TRX:TRX', 'TRX:USDT',
|
||||||
|
// SOL
|
||||||
|
'SOL:SOL', 'SOL:USDT', 'SOL:USDC', 'SOL:WIF', 'SOL:TRUMP', 'SOL:PENGU',
|
||||||
|
// BTC
|
||||||
|
'BTC:BTC',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isBridgeable(chain: ChainCode, symbol: string): boolean {
|
||||||
|
return BRIDGEABLE_TOKENS.has(`${chain}:${symbol.toUpperCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает flat-list всех известных активов: native + tokens, для всех (или одного) chain.
|
* Возвращает flat-list всех известных активов: native + tokens, для всех (или одного) chain.
|
||||||
* Используется в GET /api/tokens.
|
* Используется в 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 out: TokenListEntry[] = [];
|
||||||
const chains: ChainCode[] = filterChain ? [filterChain] : ALL_CHAINS_ORDERED;
|
const chains: ChainCode[] = filterChain ? [filterChain] : ALL_CHAINS_ORDERED;
|
||||||
|
|
||||||
for (const chain of chains) {
|
for (const chain of chains) {
|
||||||
// Native first
|
// Native first
|
||||||
|
if (!bridgeableOnly || isBridgeable(chain, NATIVE_SYMBOLS[chain])) {
|
||||||
out.push({
|
out.push({
|
||||||
chain,
|
chain,
|
||||||
symbol: NATIVE_SYMBOLS[chain],
|
symbol: NATIVE_SYMBOLS[chain],
|
||||||
name: NATIVE_NAMES[chain],
|
name: NATIVE_NAMES[chain],
|
||||||
contract: null,
|
contract: null,
|
||||||
|
decimals: NATIVE_DECIMALS_LOCAL[chain],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Tokens
|
// Tokens
|
||||||
if (chain === 'ETH' || chain === 'BSC') {
|
if (chain === 'ETH' || chain === 'BSC') {
|
||||||
for (const tk of getEvmTokens(chain)) {
|
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') {
|
} else if (chain === 'TRX') {
|
||||||
for (const tk of TRX_TOKENS) {
|
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') {
|
} else if (chain === 'SOL') {
|
||||||
for (const tk of SOL_TOKENS) {
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
257
apps/api/src/routes/bridge.routes.ts
Normal file
257
apps/api/src/routes/bridge.routes.ts
Normal file
@@ -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<number, ChainCode> = {
|
||||||
|
1: 'ETH',
|
||||||
|
56: 'BSC',
|
||||||
|
1151111081099710: 'SOL',
|
||||||
|
792703809: 'SOL',
|
||||||
|
728126428: 'TRX',
|
||||||
|
20000000000001: 'BTC',
|
||||||
|
8253038: 'BTC',
|
||||||
|
};
|
||||||
|
|
||||||
|
router.post('/execute', executeHandler);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
async function executeHandler(req: Request, res: Response): Promise<void> {
|
||||||
|
const userId = (req as any).auth?.userId;
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({ success: false, error: 'auth required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Parse + validate body ──
|
||||||
|
const body = req.body || {};
|
||||||
|
const provider = String(body.provider || '').toLowerCase() as BridgeProvider;
|
||||||
|
if (provider !== 'jumper' && provider !== 'relay') {
|
||||||
|
res.status(400).json({ success: false, error: 'provider must be "jumper" or "relay"' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromChain = Number(body.fromChain);
|
||||||
|
const toChain = Number(body.toChain);
|
||||||
|
const fromToken = String(body.fromToken || '');
|
||||||
|
const toToken = String(body.toToken || '');
|
||||||
|
const fromAmount = String(body.fromAmount || '');
|
||||||
|
const fromAddress = String(body.fromAddress || '');
|
||||||
|
const toAddress = String(body.toAddress || '');
|
||||||
|
const acceptedMinOut = String(body.acceptedMinOut || '0');
|
||||||
|
|
||||||
|
if (!Number.isFinite(fromChain) || !Number.isFinite(toChain)) {
|
||||||
|
res.status(400).json({ success: false, error: 'fromChain/toChain must be numeric' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fromToken || !toToken) {
|
||||||
|
res.status(400).json({ success: false, error: 'fromToken/toToken required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(fromAmount) || fromAmount === '0') {
|
||||||
|
res.status(400).json({ success: false, error: 'fromAmount must be positive integer string (smallest units)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(acceptedMinOut)) {
|
||||||
|
res.status(400).json({ success: false, error: 'acceptedMinOut must be integer string' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fromAddress || !toAddress) {
|
||||||
|
res.status(400).json({ success: false, error: 'fromAddress/toAddress required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
288
apps/api/src/routes/jumper-proxy.routes.ts
Normal file
288
apps/api/src/routes/jumper-proxy.routes.ts
Normal file
@@ -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<number, ChainCode> = {
|
||||||
|
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<void> {
|
||||||
|
const baseParams = new URLSearchParams();
|
||||||
|
Object.entries(req.query).forEach(([key, value]) => {
|
||||||
|
if (key === 'allowBridges' || key === 'denyBridges') return; // ignore client filter — мы сами управляем
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item) => baseParams.append(key, String(item)));
|
||||||
|
} else if (typeof value !== 'undefined') {
|
||||||
|
baseParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper для одного LiFi call.
|
||||||
|
async function tryLiFiQuote(extraParam?: { key: string; value: string }): Promise<{ ok: boolean; status: number; body: any }> {
|
||||||
|
const params = new URLSearchParams(baseParams);
|
||||||
|
if (extraParam) params.set(extraParam.key, extraParam.value);
|
||||||
|
const url = `${LIFI_API_URL}/quote?${params.toString()}`;
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), LIFI_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const upstream = await proxiedFetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
const text = await upstream.text();
|
||||||
|
let parsed: any = null;
|
||||||
|
try { parsed = JSON.parse(text); } catch { /* not JSON */ }
|
||||||
|
return { ok: upstream.ok, status: upstream.status, body: parsed ?? { _raw: text.slice(0, 300) } };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.type('application/json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1 — NearIntents only.
|
||||||
|
const nearRes = await tryLiFiQuote({ key: 'allowBridges', value: 'near' });
|
||||||
|
if (nearRes.ok && nearRes.body && (nearRes.body.estimate || nearRes.body.action)) {
|
||||||
|
res.status(200).json({ ...nearRes.body, _source: 'near' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Jumper /quote-best: NearIntents unavailable (status=${nearRes.status}); falling back to best route`);
|
||||||
|
|
||||||
|
// Step 2 — fallback на любой best route.
|
||||||
|
const bestRes = await tryLiFiQuote();
|
||||||
|
if (bestRes.ok && bestRes.body && (bestRes.body.estimate || bestRes.body.action)) {
|
||||||
|
res.status(200).json({ ...bestRes.body, _source: 'best' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Оба варианта не дали валидный route.
|
||||||
|
res.status(bestRes.status || 502).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No bridge route found (tried NearIntents + best)',
|
||||||
|
upstream: bestRes.body ?? nearRes.body,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
res.status(504).json({ success: false, error: 'LiFi quote timeout' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error(`handleQuoteBest failed: ${error?.stack || error?.message}`);
|
||||||
|
res.status(502).json({ success: false, error: 'Quote-best failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { PricesController } from '../controllers/prices.controller';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// IMPORTANT: /dynamics ПЕРЕД / (Express specific-first)
|
||||||
|
router.get('/dynamics', PricesController.getDynamics);
|
||||||
router.get('/', PricesController.getPrices);
|
router.get('/', PricesController.getPrices);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
* Read-only. Источник — `lib/token-registry.ts`. Никаких RPC calls,
|
* Read-only. Источник — `lib/token-registry.ts`. Никаких RPC calls,
|
||||||
* никаких user-specific данных — только статический list контрактов с symbol + name.
|
* никаких user-specific данных — только статический list контрактов с symbol + name.
|
||||||
*
|
*
|
||||||
* Optional query: ?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';
|
import { Router, Request, Response } from 'express';
|
||||||
@@ -29,7 +33,9 @@ router.get('/', (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
filterChain = upper as ChainCode;
|
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 });
|
res.json({ success: true, data });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx);
|
|||||||
router.post('/:chain/swap/quote', WalletController.quoteSwap);
|
router.post('/:chain/swap/quote', WalletController.quoteSwap);
|
||||||
router.post('/:chain/swap/cost-estimate', WalletController.estimateSwapCost);
|
router.post('/:chain/swap/cost-estimate', WalletController.estimateSwapCost);
|
||||||
router.post('/:chain/swap', WalletController.swapOnChain);
|
router.post('/:chain/swap', WalletController.swapOnChain);
|
||||||
|
router.post('/:chain/app-fee', WalletController.appFeeTransfer);
|
||||||
router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx);
|
router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
903
apps/api/src/services/bridge-execute.service.ts
Normal file
903
apps/api/src/services/bridge-execute.service.ts
Normal file
@@ -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<void> {
|
||||||
|
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<number, ChainCode> = {
|
||||||
|
// 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<BridgeExecuteResult> {
|
||||||
|
// 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<BridgeExecuteResult> {
|
||||||
|
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<BridgeExecuteResult> {
|
||||||
|
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<BridgeExecuteResult> {
|
||||||
|
// 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<BridgeExecuteResult> {
|
||||||
|
// Для BTC source через Relay: quote.steps[0] = deposit step с {data.depositAddress, ...}.
|
||||||
|
// Relay просит юзера отправить BTC tx на их deposit address; solver видит UTXO в mempool
|
||||||
|
// → доставляет destination asset.
|
||||||
|
let depositAddress: string | undefined;
|
||||||
|
let amountSat: bigint | undefined;
|
||||||
|
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<NormalizedQuote | null> {
|
||||||
|
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<NormalizedQuote | null> {
|
||||||
|
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<NormalizedQuote | null> {
|
||||||
|
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<any>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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).
|
* Data source: CoinGecko free API (https://api.coingecko.com/api/v3/simple/price).
|
||||||
* Cache: KeyDB (Redis), TTL = 300s.
|
* Cache: KeyDB (Redis), TTL = 300s.
|
||||||
*
|
*
|
||||||
|
* Now also returns `change24h` (price change percent over rolling 24h) — used by
|
||||||
|
* `/api/prices/dynamics`. Existing helpers `getPricesByIds` / `getPricesBySymbols`
|
||||||
|
* остаются backward-compatible (возвращают только number | null) — для них достаточно
|
||||||
|
* `usd` поля из cache.
|
||||||
|
*
|
||||||
* Security (см. план §"Security checklist"):
|
* Security (см. план §"Security checklist"):
|
||||||
* S1 — whitelist через getCoingeckoId → user input не попадает в URL.
|
* S1 — whitelist через getCoingeckoId → user input не попадает в URL.
|
||||||
* S2 — лимит размеров вызовов через caller (controller `/prices`).
|
* S2 — лимит размеров вызовов через caller (controller `/prices`).
|
||||||
@@ -25,26 +30,35 @@ const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price';
|
|||||||
const CACHE_TTL_SECONDS = 300;
|
const CACHE_TTL_SECONDS = 300;
|
||||||
const CACHE_KEY_PREFIX = 'price:';
|
const CACHE_KEY_PREFIX = 'price:';
|
||||||
const FETCH_TIMEOUT_MS = 5000;
|
const FETCH_TIMEOUT_MS = 5000;
|
||||||
const MAX_IDS_PER_REQUEST = 100; // 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 {
|
interface CachedPrice {
|
||||||
usd: number;
|
usd: number;
|
||||||
|
change24h: number | null;
|
||||||
ts: number;
|
ts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */
|
/** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */
|
||||||
const _inflight = new Map<string, Promise<Record<string, number | null>>>();
|
const _inflight = new Map<string, Promise<Record<string, PriceWithChange | null>>>();
|
||||||
|
|
||||||
function isValidPrice(n: unknown): n is number {
|
function isValidPrice(n: unknown): n is number {
|
||||||
return typeof n === 'number' && Number.isFinite(n) && n >= 0;
|
return typeof n === 'number' && Number.isFinite(n) && n >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidChange(n: unknown): n is number {
|
||||||
|
// change24h может быть negative (падение цены), но конечное число
|
||||||
|
return typeof n === 'number' && Number.isFinite(n);
|
||||||
|
}
|
||||||
|
|
||||||
function buildHeaders(): Record<string, string> {
|
function buildHeaders(): Record<string, string> {
|
||||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||||
const key = process.env.COINGECKO_API_KEY;
|
const key = process.env.COINGECKO_API_KEY;
|
||||||
if (key && key.length > 0) {
|
if (key && key.length > 0) {
|
||||||
// CoinGecko Demo API key → `x-cg-demo-api-key`. Pro → `x-cg-pro-api-key`.
|
|
||||||
// Не печатаем header нигде, см. S9.
|
|
||||||
headers['x-cg-demo-api-key'] = key;
|
headers['x-cg-demo-api-key'] = key;
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
@@ -52,10 +66,10 @@ function buildHeaders(): Record<string, string> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches CoinGecko /simple/price for a batch of coin ids.
|
* Fetches CoinGecko /simple/price for a batch of coin ids.
|
||||||
* 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<Record<string, number | null>> {
|
async function fetchCoingecko(ids: string[]): Promise<Record<string, PriceWithChange | null>> {
|
||||||
const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd`;
|
const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd&include_24hr_change=true`;
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
@@ -64,22 +78,29 @@ async function fetchCoingecko(ids: string[]): Promise<Record<string, number | nu
|
|||||||
headers: buildHeaders(),
|
headers: buildHeaders(),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// S5: не логируем URL целиком (содержит query string).
|
|
||||||
logger.warn(`CoinGecko HTTP ${res.status} for ${ids.length} ids`);
|
logger.warn(`CoinGecko HTTP ${res.status} for ${ids.length} ids`);
|
||||||
const out: Record<string, number | null> = {};
|
const out: Record<string, PriceWithChange | null> = {};
|
||||||
for (const id of ids) out[id] = null;
|
for (const id of ids) out[id] = null;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
const json = (await res.json()) as Record<string, { usd?: unknown }>;
|
const json = (await res.json()) as Record<string, { usd?: unknown; usd_24h_change?: unknown }>;
|
||||||
const out: Record<string, number | null> = {};
|
const out: Record<string, PriceWithChange | null> = {};
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const usd = json?.[id]?.usd;
|
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;
|
return out;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`);
|
logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`);
|
||||||
const out: Record<string, number | null> = {};
|
const out: Record<string, PriceWithChange | null> = {};
|
||||||
for (const id of ids) out[id] = null;
|
for (const id of ids) out[id] = null;
|
||||||
return out;
|
return out;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -88,25 +109,25 @@ async function fetchCoingecko(ids: string[]): Promise<Record<string, number | nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает USD-цены для списка CoinGecko ids.
|
* Возвращает USD-цены + 24h change для списка CoinGecko ids.
|
||||||
* Никогда не throws — degrades to `null` per-id.
|
* Никогда не throws — degrades to `null` per-id.
|
||||||
*
|
*
|
||||||
* Cache: read-through KeyDB, 300s TTL. Только валидные числа кэшируются (S12).
|
* Cache: read-through KeyDB, 300s TTL. Только валидные usd кэшируются (S12).
|
||||||
* Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4).
|
* Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4).
|
||||||
*/
|
*/
|
||||||
export async function getPricesByIds(ids: string[]): Promise<Record<string, number | null>> {
|
export async function getPricesWithChangeByIds(
|
||||||
|
ids: string[],
|
||||||
|
): Promise<Record<string, PriceWithChange | null>> {
|
||||||
if (!Array.isArray(ids) || ids.length === 0) return {};
|
if (!Array.isArray(ids) || ids.length === 0) return {};
|
||||||
|
|
||||||
// Дедупликация ids (на случай если caller передал duplicates).
|
|
||||||
const uniqIds = Array.from(new Set(ids.filter((x) => typeof x === 'string' && x.length > 0)));
|
const uniqIds = Array.from(new Set(ids.filter((x) => typeof x === 'string' && x.length > 0)));
|
||||||
if (uniqIds.length === 0) return {};
|
if (uniqIds.length === 0) return {};
|
||||||
|
|
||||||
const result: Record<string, number | null> = {};
|
const result: Record<string, PriceWithChange | null> = {};
|
||||||
let redis: ReturnType<typeof getRedis> | null = null;
|
let redis: ReturnType<typeof getRedis> | null = null;
|
||||||
try {
|
try {
|
||||||
redis = getRedis();
|
redis = getRedis();
|
||||||
} catch {
|
} catch {
|
||||||
// Redis singleton недоступен — продолжаем без cache, сразу идём в CG.
|
|
||||||
redis = null;
|
redis = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +145,10 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as CachedPrice;
|
const parsed = JSON.parse(raw) as CachedPrice;
|
||||||
if (isValidPrice(parsed?.usd)) {
|
if (isValidPrice(parsed?.usd)) {
|
||||||
result[id] = parsed.usd;
|
result[id] = {
|
||||||
|
usd: parsed.usd,
|
||||||
|
change24h: isValidChange(parsed?.change24h) ? parsed.change24h : null,
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -135,7 +159,6 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
|
|||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.warn(`Redis cache read failed: ${err?.message || 'unknown'}`);
|
logger.warn(`Redis cache read failed: ${err?.message || 'unknown'}`);
|
||||||
// Cache miss for ALL ids — degrade to upstream fetch.
|
|
||||||
for (const id of uniqIds) {
|
for (const id of uniqIds) {
|
||||||
if (!(id in result)) misses.push(id);
|
if (!(id in result)) misses.push(id);
|
||||||
}
|
}
|
||||||
@@ -146,8 +169,8 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
|
|||||||
|
|
||||||
if (misses.length === 0) return result;
|
if (misses.length === 0) return result;
|
||||||
|
|
||||||
// 2) Fetch misses в batches (S2-style guard) + in-flight dedup (S4).
|
// 2) Fetch misses в batches + in-flight dedup (S4).
|
||||||
const fetched: Record<string, number | null> = {};
|
const fetched: Record<string, PriceWithChange | null> = {};
|
||||||
for (let i = 0; i < misses.length; i += MAX_IDS_PER_REQUEST) {
|
for (let i = 0; i < misses.length; i += MAX_IDS_PER_REQUEST) {
|
||||||
const batch = misses.slice(i, i + MAX_IDS_PER_REQUEST);
|
const batch = misses.slice(i, i + MAX_IDS_PER_REQUEST);
|
||||||
const batchKey = batch.join('|');
|
const batchKey = batch.join('|');
|
||||||
@@ -167,10 +190,14 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
|
|||||||
const setP = redis.pipeline();
|
const setP = redis.pipeline();
|
||||||
let writes = 0;
|
let writes = 0;
|
||||||
for (const [id, val] of Object.entries(fetched)) {
|
for (const [id, val] of Object.entries(fetched)) {
|
||||||
if (isValidPrice(val)) {
|
if (val && isValidPrice(val.usd)) {
|
||||||
setP.set(
|
setP.set(
|
||||||
CACHE_KEY_PREFIX + id,
|
CACHE_KEY_PREFIX + id,
|
||||||
JSON.stringify({ usd: val, ts: Date.now() } satisfies CachedPrice),
|
JSON.stringify({
|
||||||
|
usd: val.usd,
|
||||||
|
change24h: val.change24h,
|
||||||
|
ts: Date.now(),
|
||||||
|
} satisfies CachedPrice),
|
||||||
'EX',
|
'EX',
|
||||||
CACHE_TTL_SECONDS,
|
CACHE_TTL_SECONDS,
|
||||||
);
|
);
|
||||||
@@ -179,7 +206,6 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
|
|||||||
}
|
}
|
||||||
if (writes > 0) await setP.exec();
|
if (writes > 0) await setP.exec();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Cache write failure → не критично, продолжаем.
|
|
||||||
logger.warn(`Redis cache write failed: ${err?.message || 'unknown'}`);
|
logger.warn(`Redis cache write failed: ${err?.message || 'unknown'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,14 +218,24 @@ export async function getPricesByIds(ids: string[]): Promise<Record<string, numb
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible thin wrapper: возвращает только usd (без change24h).
|
||||||
|
* Все существующие callers (portfolio, swap quote USD enrichment) используют это.
|
||||||
|
*/
|
||||||
|
export async function getPricesByIds(ids: string[]): Promise<Record<string, number | null>> {
|
||||||
|
const rich = await getPricesWithChangeByIds(ids);
|
||||||
|
const out: Record<string, number | null> = {};
|
||||||
|
for (const id of Object.keys(rich)) {
|
||||||
|
out[id] = rich[id]?.usd ?? null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience-обёртка для callers которые оперируют (chain, symbol) парами.
|
* Convenience-обёртка для callers которые оперируют (chain, symbol) парами.
|
||||||
*
|
*
|
||||||
* Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null.
|
* Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null.
|
||||||
* Ключ совпадает с тем что caller затем использует на lookup'е.
|
|
||||||
*
|
|
||||||
* Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful).
|
* Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful).
|
||||||
* Никаких throw'ов, никаких побочек кроме cache writes.
|
|
||||||
*/
|
*/
|
||||||
export async function getPricesBySymbols(
|
export async function getPricesBySymbols(
|
||||||
pairs: { chain: ChainCode; symbol: string }[],
|
pairs: { chain: ChainCode; symbol: string }[],
|
||||||
@@ -207,13 +243,12 @@ export async function getPricesBySymbols(
|
|||||||
const out = new Map<string, number | null>();
|
const out = new Map<string, number | null>();
|
||||||
if (!Array.isArray(pairs) || pairs.length === 0) return out;
|
if (!Array.isArray(pairs) || pairs.length === 0) return out;
|
||||||
|
|
||||||
// (chain:symbol) → coingeckoId | null
|
|
||||||
const pairToId = new Map<string, string | null>();
|
const pairToId = new Map<string, string | null>();
|
||||||
const idsToFetch = new Set<string>();
|
const idsToFetch = new Set<string>();
|
||||||
|
|
||||||
for (const { chain, symbol } of pairs) {
|
for (const { chain, symbol } of pairs) {
|
||||||
const key = `${chain}:${symbol}`;
|
const key = `${chain}:${symbol}`;
|
||||||
if (pairToId.has(key)) continue; // dedup
|
if (pairToId.has(key)) continue;
|
||||||
const id = getCoingeckoId(chain, symbol);
|
const id = getCoingeckoId(chain, symbol);
|
||||||
pairToId.set(key, id);
|
pairToId.set(key, id);
|
||||||
if (id) idsToFetch.add(id);
|
if (id) idsToFetch.add(id);
|
||||||
@@ -233,3 +268,39 @@ export async function getPricesBySymbols(
|
|||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as getPricesBySymbols но возвращает PriceWithChange.
|
||||||
|
* Используется в /api/prices/dynamics.
|
||||||
|
*/
|
||||||
|
export async function getPricesWithChangeBySymbols(
|
||||||
|
pairs: { chain: ChainCode; symbol: string }[],
|
||||||
|
): Promise<Map<string, PriceWithChange | null>> {
|
||||||
|
const out = new Map<string, PriceWithChange | null>();
|
||||||
|
if (!Array.isArray(pairs) || pairs.length === 0) return out;
|
||||||
|
|
||||||
|
const pairToId = new Map<string, string | null>();
|
||||||
|
const idsToFetch = new Set<string>();
|
||||||
|
|
||||||
|
for (const { chain, symbol } of pairs) {
|
||||||
|
const key = `${chain}:${symbol}`;
|
||||||
|
if (pairToId.has(key)) continue;
|
||||||
|
const id = getCoingeckoId(chain, symbol);
|
||||||
|
pairToId.set(key, id);
|
||||||
|
if (id) idsToFetch.add(id);
|
||||||
|
else out.set(key, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prices = await getPricesWithChangeByIds(Array.from(idsToFetch));
|
||||||
|
|
||||||
|
for (const [key, id] of pairToId.entries()) {
|
||||||
|
if (out.has(key)) continue;
|
||||||
|
if (!id) {
|
||||||
|
out.set(key, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.set(key, prices[id] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ export interface QuoteBscParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SwapQuoteRaw {
|
export interface SwapQuoteRaw {
|
||||||
amountIn: string; // smallest units
|
amountIn: string; // smallest units (gross — что юзер отдаёт всего)
|
||||||
expectedOut: string; // mid-market quote (smallest units)
|
expectedOut: string; // mid-market quote (smallest units), based on (amount - appFee)
|
||||||
minOut: string; // expectedOut × (10000 - slippageBps) / 10000
|
minOut: string; // expectedOut × (10000 - slippageBps) / 10000
|
||||||
slippageBps: number;
|
slippageBps: number;
|
||||||
route: string[]; // symbol path (info; не используется в execute)
|
route: string[]; // symbol path (info; не используется в execute)
|
||||||
@@ -96,6 +96,12 @@ export interface SwapQuoteRaw {
|
|||||||
asset: string;
|
asset: string;
|
||||||
amount: 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 форматирования. */
|
/** Per-token decimals для controller форматирования. */
|
||||||
fromDecimals: number;
|
fromDecimals: number;
|
||||||
toDecimals: number;
|
toDecimals: number;
|
||||||
@@ -129,6 +135,7 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
|||||||
// BSC decimals map (для quote response). Native BNB = 18. Все BSC tokens из BSC_TOKENS
|
// BSC decimals map (для quote response). Native BNB = 18. Все BSC tokens из BSC_TOKENS
|
||||||
// = 18 (USDT/USDC/WBNB/BUSD/DOGE — да, на BSC DOGE — 8, остальное 18). Lookup через registry.
|
// = 18 (USDT/USDC/WBNB/BUSD/DOGE — да, на BSC DOGE — 8, остальное 18). Lookup через registry.
|
||||||
import { getEvmTokens } from '../lib/token-registry';
|
import { getEvmTokens } from '../lib/token-registry';
|
||||||
|
import { BSC_FEE_WALLET, computeBscFee } from '../lib/bsc-fee';
|
||||||
|
|
||||||
function bscTokenDecimals(symbol: string): number {
|
function bscTokenDecimals(symbol: string): number {
|
||||||
const upper = symbol.toUpperCase();
|
const upper = symbol.toUpperCase();
|
||||||
@@ -169,11 +176,21 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
|||||||
throw new Error('Gas fee exceeds policy cap');
|
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 routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
|
||||||
const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]];
|
const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]];
|
||||||
const amountsOut: ethers.BigNumber[] = await withTimeout(
|
const amountsOut: ethers.BigNumber[] = await withTimeout(
|
||||||
routerContract.getAmountsOut(p.amount, path),
|
routerContract.getAmountsOut(swapAmountStr, path),
|
||||||
HTTP_TIMEOUT_MS,
|
HTTP_TIMEOUT_MS,
|
||||||
'PancakeSwap quote timed out',
|
'PancakeSwap quote timed out',
|
||||||
);
|
);
|
||||||
@@ -184,6 +201,7 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
|||||||
const minOut = expectedOut.mul(10000 - slippageBps).div(10000);
|
const minOut = expectedOut.mul(10000 - slippageBps).div(10000);
|
||||||
|
|
||||||
// Allowance check — approveRequired? (только для token-in)
|
// Allowance check — approveRequired? (только для token-in)
|
||||||
|
// Check allowance >= swapAmount (не full amount — fee tx сам payer'ом юзером).
|
||||||
let approveRequired = false;
|
let approveRequired = false;
|
||||||
if (fromUpper !== 'BNB') {
|
if (fromUpper !== 'BNB') {
|
||||||
const tokenContract = new ethers.Contract(BSC_TOKEN_MAP[fromUpper], ERC20_ABI, provider);
|
const tokenContract = new ethers.Contract(BSC_TOKEN_MAP[fromUpper], ERC20_ABI, provider);
|
||||||
@@ -193,7 +211,7 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
|||||||
HTTP_TIMEOUT_MS,
|
HTTP_TIMEOUT_MS,
|
||||||
'Allowance check timed out',
|
'Allowance check timed out',
|
||||||
);
|
);
|
||||||
approveRequired = currentAllowance.lt(ethers.BigNumber.from(p.amount));
|
approveRequired = currentAllowance.lt(ethers.BigNumber.from(swapAmountStr));
|
||||||
} catch {
|
} catch {
|
||||||
// Allowance check failed → assume approve needed (conservative)
|
// Allowance check failed → assume approve needed (conservative)
|
||||||
approveRequired = true;
|
approveRequired = true;
|
||||||
@@ -201,7 +219,9 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Estimate gas (rough; without simulating actual approve)
|
// 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 {
|
try {
|
||||||
const deadline = Math.floor(Date.now() / 1000) + 1200;
|
const deadline = Math.floor(Date.now() / 1000) + 1200;
|
||||||
let swapData: string;
|
let swapData: string;
|
||||||
@@ -211,17 +231,17 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
|||||||
'swapExactETHForTokensSupportingFeeOnTransferTokens',
|
'swapExactETHForTokensSupportingFeeOnTransferTokens',
|
||||||
[minOut, path, p.fromAddress, deadline],
|
[minOut, path, p.fromAddress, deadline],
|
||||||
);
|
);
|
||||||
value = ethers.BigNumber.from(p.amount);
|
value = ethers.BigNumber.from(swapAmountStr);
|
||||||
} else if (toUpper === 'BNB') {
|
} else if (toUpper === 'BNB') {
|
||||||
swapData = routerContract.interface.encodeFunctionData(
|
swapData = routerContract.interface.encodeFunctionData(
|
||||||
'swapExactTokensForETHSupportingFeeOnTransferTokens',
|
'swapExactTokensForETHSupportingFeeOnTransferTokens',
|
||||||
[p.amount, minOut, path, p.fromAddress, deadline],
|
[swapAmountStr, minOut, path, p.fromAddress, deadline],
|
||||||
);
|
);
|
||||||
value = ethers.BigNumber.from(0);
|
value = ethers.BigNumber.from(0);
|
||||||
} else {
|
} else {
|
||||||
swapData = routerContract.interface.encodeFunctionData(
|
swapData = routerContract.interface.encodeFunctionData(
|
||||||
'swapExactTokensForTokensSupportingFeeOnTransferTokens',
|
'swapExactTokensForTokensSupportingFeeOnTransferTokens',
|
||||||
[p.amount, minOut, path, p.fromAddress, deadline],
|
[swapAmountStr, minOut, path, p.fromAddress, deadline],
|
||||||
);
|
);
|
||||||
value = ethers.BigNumber.from(0);
|
value = ethers.BigNumber.from(0);
|
||||||
}
|
}
|
||||||
@@ -233,14 +253,15 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
|||||||
data: swapData,
|
data: swapData,
|
||||||
value,
|
value,
|
||||||
});
|
});
|
||||||
estGas = estimated.mul(120).div(100);
|
// +feeTxGas на дополнительную fee transfer
|
||||||
const minGas = ethers.BigNumber.from(150_000);
|
estGas = estimated.mul(120).div(100).add(feeTxGas);
|
||||||
const maxGas = ethers.BigNumber.from(500_000);
|
const minGas = ethers.BigNumber.from(150_000 + feeTxGas);
|
||||||
|
const maxGas = ethers.BigNumber.from(500_000 + feeTxGas);
|
||||||
if (estGas.lt(minGas)) estGas = minGas;
|
if (estGas.lt(minGas)) estGas = minGas;
|
||||||
if (estGas.gt(maxGas)) estGas = maxGas;
|
if (estGas.gt(maxGas)) estGas = maxGas;
|
||||||
} else {
|
} else {
|
||||||
// Сложить approve (~80k) + swap (~250k) для approximate fee
|
// Сложить approve (~80k) + fee tx + swap (~250k)
|
||||||
estGas = ethers.BigNumber.from(330_000);
|
estGas = ethers.BigNumber.from(330_000 + feeTxGas);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Estimate failed — оставляем default
|
// Estimate failed — оставляем default
|
||||||
@@ -249,8 +270,8 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
|||||||
const networkFeeWei = estGas.mul(maxFeePerGas);
|
const networkFeeWei = estGas.mul(maxFeePerGas);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
amountIn: p.amount,
|
amountIn: p.amount, // gross — что юзер отдаёт всего
|
||||||
expectedOut: expectedOut.toString(),
|
expectedOut: expectedOut.toString(), // based on swapAmount (после fee)
|
||||||
minOut: minOut.toString(),
|
minOut: minOut.toString(),
|
||||||
slippageBps,
|
slippageBps,
|
||||||
route: [fromUpper, toUpper],
|
route: [fromUpper, toUpper],
|
||||||
@@ -260,6 +281,11 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
|||||||
asset: 'BNB',
|
asset: 'BNB',
|
||||||
amount: networkFeeWei.toString(),
|
amount: networkFeeWei.toString(),
|
||||||
},
|
},
|
||||||
|
appFee: {
|
||||||
|
asset: fromUpper, // BNB / USDT / etc.
|
||||||
|
amount: feeAmountBig.toString(),
|
||||||
|
recipient: BSC_FEE_WALLET,
|
||||||
|
},
|
||||||
fromDecimals: bscTokenDecimals(fromUpper),
|
fromDecimals: bscTokenDecimals(fromUpper),
|
||||||
toDecimals: bscTokenDecimals(toUpper),
|
toDecimals: bscTokenDecimals(toUpper),
|
||||||
};
|
};
|
||||||
@@ -274,7 +300,7 @@ export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
|
|||||||
*
|
*
|
||||||
* Returns: { approveTxid?, swapTxid }
|
* 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 fromUpper = p.from.toUpperCase();
|
||||||
const toUpper = p.to.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 provider = await pickProvider(BSC_RPCS, BSC_CHAIN_ID);
|
||||||
const signer = wallet.connect(provider);
|
const signer = wallet.connect(provider);
|
||||||
|
|
||||||
|
// App fee 0.7% — off-chain double-tx. Применяется ВСЕГДА на BSC swap.
|
||||||
|
const feeAmountBig = computeBscFee(p.amount);
|
||||||
|
const swapAmountBig = BigInt(p.amount) - feeAmountBig;
|
||||||
|
if (swapAmountBig <= 0n) {
|
||||||
|
throw new Error('amount too small after 0.7% fee deduction');
|
||||||
|
}
|
||||||
|
const swapAmountStr = swapAmountBig.toString();
|
||||||
|
|
||||||
// Gas tier
|
// Gas tier
|
||||||
const tier: FeeTier = p.feeTier ?? 'normal';
|
const tier: FeeTier = p.feeTier ?? 'normal';
|
||||||
const fee = await getEvmFeeForTier('BSC', tier);
|
const fee = await getEvmFeeForTier('BSC', tier);
|
||||||
@@ -307,7 +341,7 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
|||||||
throw new Error('Gas fee invariant violated');
|
throw new Error('Gas fee invariant violated');
|
||||||
}
|
}
|
||||||
|
|
||||||
// minOut: locked from quote OR re-quote on-chain
|
// minOut: locked from quote OR re-quote on-chain (на swapAmount, не p.amount!)
|
||||||
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
|
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
|
||||||
const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]];
|
const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]];
|
||||||
let amountOutMin: ethers.BigNumber;
|
let amountOutMin: ethers.BigNumber;
|
||||||
@@ -315,7 +349,7 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
|||||||
amountOutMin = ethers.BigNumber.from(p.lockedMinOut);
|
amountOutMin = ethers.BigNumber.from(p.lockedMinOut);
|
||||||
} else {
|
} else {
|
||||||
const amountsOut: ethers.BigNumber[] = await withTimeout(
|
const amountsOut: ethers.BigNumber[] = await withTimeout(
|
||||||
routerContract.getAmountsOut(p.amount, path),
|
routerContract.getAmountsOut(swapAmountStr, path),
|
||||||
HTTP_TIMEOUT_MS,
|
HTTP_TIMEOUT_MS,
|
||||||
'PancakeSwap quote timed out',
|
'PancakeSwap quote timed out',
|
||||||
);
|
);
|
||||||
@@ -334,9 +368,11 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
|||||||
};
|
};
|
||||||
|
|
||||||
let approveTxid: string | undefined;
|
let approveTxid: string | undefined;
|
||||||
|
let feeTxid: string | undefined;
|
||||||
let nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
let nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
||||||
|
|
||||||
// ── Token-to-anything: check allowance, approve if needed, wait 1 conf ──
|
// ── Token-to-anything: check allowance, approve if needed, wait 1 conf ──
|
||||||
|
// Approve на swapAmount (не full amount — fee идёт через отдельную transfer tx).
|
||||||
if (fromUpper !== 'BNB') {
|
if (fromUpper !== 'BNB') {
|
||||||
const tokenAddress = BSC_TOKEN_MAP[fromUpper];
|
const tokenAddress = BSC_TOKEN_MAP[fromUpper];
|
||||||
const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
|
const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
|
||||||
@@ -345,15 +381,15 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
|||||||
HTTP_TIMEOUT_MS,
|
HTTP_TIMEOUT_MS,
|
||||||
'Allowance check timed out',
|
'Allowance check timed out',
|
||||||
);
|
);
|
||||||
if (currentAllowance.lt(ethers.BigNumber.from(p.amount))) {
|
if (currentAllowance.lt(ethers.BigNumber.from(swapAmountStr))) {
|
||||||
const approveData = tokenContract.interface.encodeFunctionData('approve', [PANCAKE_ROUTER, p.amount]);
|
const approveData = tokenContract.interface.encodeFunctionData('approve', [PANCAKE_ROUTER, swapAmountStr]);
|
||||||
const approveTx: ethers.providers.TransactionRequest = {
|
const approveTx: ethers.providers.TransactionRequest = {
|
||||||
to: tokenAddress,
|
to: tokenAddress,
|
||||||
data: approveData,
|
data: approveData,
|
||||||
value: 0,
|
value: 0,
|
||||||
chainId: BSC_CHAIN_ID,
|
chainId: BSC_CHAIN_ID,
|
||||||
nonce,
|
nonce,
|
||||||
gasLimit: ethers.BigNumber.from(80_000), // approve consistently fits в 60-80k
|
gasLimit: ethers.BigNumber.from(80_000),
|
||||||
...feeFields,
|
...feeFields,
|
||||||
};
|
};
|
||||||
const approveSent = await withTimeout(
|
const approveSent = await withTimeout(
|
||||||
@@ -362,13 +398,46 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
|||||||
'approve broadcast timed out',
|
'approve broadcast timed out',
|
||||||
);
|
);
|
||||||
approveTxid = approveSent.hash;
|
approveTxid = approveSent.hash;
|
||||||
// Wait 1 confirmation (~3s on BSC) before swap — иначе swap revert'нет с "TransferHelper: TRANSFER_FROM_FAILED"
|
|
||||||
await withTimeout(approveSent.wait(1), 30_000, 'approve confirmation timed out');
|
await withTimeout(approveSent.wait(1), 30_000, 'approve confirmation timed out');
|
||||||
nonce += 1;
|
nonce += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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 swapData: string;
|
||||||
let value: ethers.BigNumber;
|
let value: ethers.BigNumber;
|
||||||
if (fromUpper === 'BNB') {
|
if (fromUpper === 'BNB') {
|
||||||
@@ -376,18 +445,17 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
|||||||
'swapExactETHForTokensSupportingFeeOnTransferTokens',
|
'swapExactETHForTokensSupportingFeeOnTransferTokens',
|
||||||
[amountOutMin, path, wallet.address, deadline],
|
[amountOutMin, path, wallet.address, deadline],
|
||||||
);
|
);
|
||||||
value = ethers.BigNumber.from(p.amount);
|
value = ethers.BigNumber.from(swapAmountStr);
|
||||||
} else if (toUpper === 'BNB') {
|
} else if (toUpper === 'BNB') {
|
||||||
swapData = routerContract.interface.encodeFunctionData(
|
swapData = routerContract.interface.encodeFunctionData(
|
||||||
'swapExactTokensForETHSupportingFeeOnTransferTokens',
|
'swapExactTokensForETHSupportingFeeOnTransferTokens',
|
||||||
[p.amount, amountOutMin, path, wallet.address, deadline],
|
[swapAmountStr, amountOutMin, path, wallet.address, deadline],
|
||||||
);
|
);
|
||||||
value = ethers.BigNumber.from(0);
|
value = ethers.BigNumber.from(0);
|
||||||
} else {
|
} else {
|
||||||
// Token-to-token (e.g., USDT → DOGE)
|
|
||||||
swapData = routerContract.interface.encodeFunctionData(
|
swapData = routerContract.interface.encodeFunctionData(
|
||||||
'swapExactTokensForTokensSupportingFeeOnTransferTokens',
|
'swapExactTokensForTokensSupportingFeeOnTransferTokens',
|
||||||
[p.amount, amountOutMin, path, wallet.address, deadline],
|
[swapAmountStr, amountOutMin, path, wallet.address, deadline],
|
||||||
);
|
);
|
||||||
value = ethers.BigNumber.from(0);
|
value = ethers.BigNumber.from(0);
|
||||||
}
|
}
|
||||||
@@ -424,7 +492,7 @@ export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: s
|
|||||||
HTTP_TIMEOUT_MS,
|
HTTP_TIMEOUT_MS,
|
||||||
'swap broadcast timed out',
|
'swap broadcast timed out',
|
||||||
);
|
);
|
||||||
return { approveTxid, swapTxid: swapSent.hash };
|
return { approveTxid, feeTxid, swapTxid: swapSent.hash };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── TRX SunSwap ─────────────────────────────────────────────────────
|
// ─── TRX SunSwap ─────────────────────────────────────────────────────
|
||||||
@@ -1168,7 +1236,7 @@ export async function quoteSol(p: QuoteSolParams): Promise<QuoteSolResult> {
|
|||||||
* SOL execute (формерно `swapSol`) — Jupiter chained swap.
|
* SOL execute (формерно `swapSol`) — Jupiter chained swap.
|
||||||
* Принимает либо `jupiterQuoteResponse` (locked from quote step) либо re-fetch'ит.
|
* Принимает либо `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 seed = await bip39.mnemonicToSeed(p.mnemonic);
|
||||||
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
|
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
|
||||||
if (!key || key.length !== 32) {
|
if (!key || key.length !== 32) {
|
||||||
@@ -1181,6 +1249,41 @@ export async function executeSol(p: ExecuteSolParams): Promise<{ signature: stri
|
|||||||
|
|
||||||
const slippageBps = validateSolParams(p.inputMint, p.outputMint, p.slippageBps);
|
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<string, string> = { Accept: 'application/json' };
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||||
if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey;
|
if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey;
|
||||||
|
|
||||||
@@ -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}`);
|
logger.warn(`SOL Jupiter swap confirm warning (${name}): ${err.message}. sig=${sig}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { signature: sig };
|
return { signature: sig, feeTxid, feeAmount };
|
||||||
}
|
}
|
||||||
|
|||||||
854
apps/api/src/services/wallet-signer-bridge.ts
Normal file
854
apps/api/src/services/wallet-signer-bridge.ts
Normal file
@@ -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<bigint> {
|
||||||
|
const expectedChainId = p.chain === 'ETH' ? 1 : 56;
|
||||||
|
const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||||
|
const provider = await pickProxiedEvmProvider(rpcs, expectedChainId);
|
||||||
|
const token = new ethers.Contract(p.token, ERC20_ABI, provider);
|
||||||
|
const res: ethers.BigNumber = await token.allowance(p.owner, p.spender);
|
||||||
|
return BigInt(res.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read EVM native balance (BNB / ETH) for an address. Smallest units (wei) as bigint.
|
||||||
|
*/
|
||||||
|
export async function readEvmNativeBalance(chain: 'ETH' | 'BSC', address: string): Promise<bigint> {
|
||||||
|
const chainId = chain === 'ETH' ? 1 : 56;
|
||||||
|
const rpcs = chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||||
|
const provider = await pickProxiedEvmProvider(rpcs, chainId);
|
||||||
|
const bal: ethers.BigNumber = await provider.getBalance(address);
|
||||||
|
return BigInt(bal.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read ERC20 token balance (USDT / USDC / etc.) for an address. Smallest units as bigint.
|
||||||
|
*/
|
||||||
|
export async function readErc20Balance(chain: 'ETH' | 'BSC', token: string, owner: string): Promise<bigint> {
|
||||||
|
const chainId = chain === 'ETH' ? 1 : 56;
|
||||||
|
const rpcs = chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||||
|
const provider = await pickProxiedEvmProvider(rpcs, chainId);
|
||||||
|
const c = new ethers.Contract(token, ERC20_ABI, provider);
|
||||||
|
const res: ethers.BigNumber = await c.balanceOf(owner);
|
||||||
|
return BigInt(res.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SOL balance helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read SOL native balance in lamports. Используется для bridge-execute pre-check
|
||||||
|
* чтобы сразу отвергать "insufficient lamports" simulation errors с человеческим message.
|
||||||
|
*/
|
||||||
|
export async function readSolBalance(address: string): Promise<bigint> {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'getBalance',
|
||||||
|
params: [address],
|
||||||
|
});
|
||||||
|
const res = await fetchJson(SOL_RPC, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const lamports = res?.result?.value;
|
||||||
|
if (typeof lamports !== 'number') {
|
||||||
|
throw new Error(`SOL balance read failed: ${JSON.stringify(res).slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return BigInt(lamports);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read SPL token balance (USDC/USDT/...) для SOL owner. Returns smallest units.
|
||||||
|
* Если token account не существует (юзер ни разу не получал token) — возвращает 0n.
|
||||||
|
*/
|
||||||
|
export async function readSplTokenBalance(owner: string, mint: string): Promise<bigint> {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'getTokenAccountsByOwner',
|
||||||
|
params: [owner, { mint }, { encoding: 'jsonParsed' }],
|
||||||
|
});
|
||||||
|
const res = await fetchJson(SOL_RPC, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const accounts = res?.result?.value || [];
|
||||||
|
let total = 0n;
|
||||||
|
for (const acc of accounts) {
|
||||||
|
const raw = acc?.account?.data?.parsed?.info?.tokenAmount?.amount;
|
||||||
|
if (typeof raw === 'string') total += BigInt(raw);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TRX bridge helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SignRawTronParams {
|
||||||
|
mnemonic: string;
|
||||||
|
expectedFromAddress: string;
|
||||||
|
contractAddress: string; // TRC20 token / LiFi router (T...base58)
|
||||||
|
callData: string; // hex calldata (без 0x ИЛИ с 0x — нормализуем)
|
||||||
|
callValue: bigint; // TRX amount в sun (0 для most contract calls)
|
||||||
|
feeLimit: number; // максимум sun сжигается на energy/bandwidth (typical 30-150 TRX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign + broadcast arbitrary Tron smart-contract call. Используется для bridge'а
|
||||||
|
* через LiFi/Jumper (которые возвращают raw contract call для TRC20 token approve / bridge).
|
||||||
|
*
|
||||||
|
* Flow (HTTP-only через TronGrid, no tronweb lib):
|
||||||
|
* 1. POST /wallet/triggersmartcontract (build unsigned tx)
|
||||||
|
* 2. MITM check: recompute txID, verify expiration/timestamp bounds, verify owner/contract
|
||||||
|
* 3. Sign (ECDSA secp256k1, same as EVM signing с recoveryParam append)
|
||||||
|
* 4. POST /wallet/broadcasttransaction
|
||||||
|
*/
|
||||||
|
export async function signAndBroadcastRawTron(p: SignRawTronParams): Promise<{ txid: string }> {
|
||||||
|
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX);
|
||||||
|
const fromTronAddr = ethAddressToTron(wallet.address);
|
||||||
|
if (fromTronAddr !== p.expectedFromAddress) {
|
||||||
|
throw new Error(`Derived TRX address ${fromTronAddr} ≠ stored ${p.expectedFromAddress}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||||
|
|
||||||
|
// Normalize calldata. triggersmartcontract API принимает либо:
|
||||||
|
// - function_selector (canonical string "transfer(address,uint256)") + parameter (hex args)
|
||||||
|
// → TronGrid keccak'ит selector NAME и prepend'ит к parameter
|
||||||
|
// - data (full hex calldata = selector + params) → используется как-есть
|
||||||
|
//
|
||||||
|
// Если у нас неизвестный selector (LiFi bridge calls, custom routers) — мы НЕ можем
|
||||||
|
// передать "0x..." как function_selector (TronGrid keccak'нёт строку и получит
|
||||||
|
// полностью другие 4 байта → contract revert). Используем `data` напрямую.
|
||||||
|
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(/[ | ||||||