init
This commit is contained in:
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();
|
||||
|
||||
// IMPORTANT: /dynamics ПЕРЕД / (Express specific-first)
|
||||
router.get('/dynamics', PricesController.getDynamics);
|
||||
router.get('/', PricesController.getPrices);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user