This commit is contained in:
ZOMBIIIIIII
2026-05-28 13:51:30 +03:00
parent d2086b86e3
commit e86ff7c063
25 changed files with 4214 additions and 437 deletions

View 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 = '';
}
}

View 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' });
}
}

View File

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

View File

@@ -4,7 +4,11 @@
* Read-only. Источник — `lib/token-registry.ts`. Никаких RPC calls,
* никаких user-specific данных — только статический list контрактов с symbol + name.
*
* Optional query: ?chain=ETH|BSC|BTC|TRX|SOL — filter одной сетью.
* Optional query params:
* ?chain=ETH|BSC|BTC|TRX|SOL — filter одной сетью.
* ?bridgeable=true — возвращает только tokens которые реально bridgeable
* через Jumper/NearIntents (без SOL memes, BSC wrapped, и т.п.).
* Используется UI dropdowns в Jumper bridge section.
*/
import { Router, Request, Response } from 'express';
@@ -29,7 +33,9 @@ router.get('/', (req: Request, res: Response) => {
}
filterChain = upper as ChainCode;
}
const data = getAllTokens(filterChain);
// ?bridgeable=true → filter только bridgeable tokens
const bridgeableOnly = String(req.query.bridgeable || '').toLowerCase() === 'true';
const data = getAllTokens(filterChain, bridgeableOnly);
res.json({ success: true, data });
});

View File

@@ -23,6 +23,7 @@ router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx);
router.post('/:chain/swap/quote', WalletController.quoteSwap);
router.post('/:chain/swap/cost-estimate', WalletController.estimateSwapCost);
router.post('/:chain/swap', WalletController.swapOnChain);
router.post('/:chain/app-fee', WalletController.appFeeTransfer);
router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx);
export default router;