swaggerready

This commit is contained in:
ZOMBIIIIIII
2026-05-14 01:11:20 +03:00
parent 0661fffb88
commit 53635806d6
9 changed files with 1139 additions and 56 deletions

View File

@@ -51,6 +51,13 @@ JUPITER_FEE_BPS=70
ETHERSCAN_API_KEY= ETHERSCAN_API_KEY=
BSCSCAN_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=
# ── DB fallback (если Vault недоступен при старте) ───────────────── # ── DB fallback (если Vault недоступен при старте) ─────────────────
DB_HOST= DB_HOST=
DB_PORT=5432 DB_PORT=5432

View File

@@ -17,6 +17,7 @@ import solSwapProxyRoutes from './routes/sol-swap-proxy.routes';
import tronSwapProxyRoutes from './routes/tron-swap-proxy.routes'; import tronSwapProxyRoutes from './routes/tron-swap-proxy.routes';
import btcProxyRoutes from './routes/btc-proxy.routes'; import btcProxyRoutes from './routes/btc-proxy.routes';
import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes'; import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes';
import pricesRoutes from './routes/prices.routes';
const app = express(); const app = express();
@@ -97,6 +98,9 @@ app.use('/api/tron/swap', ...protect, mutateLimiter, tronSwapProxyRoutes);
app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes); app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes);
app.use('/api/bsc/swap', ...protect, mutateLimiter, bscSwapProxyRoutes); app.use('/api/bsc/swap', ...protect, mutateLimiter, bscSwapProxyRoutes);
// USD-цены (CoinGecko + KeyDB cache). GET-only, auth required, max 50 symbols.
app.use('/api/prices', ...protect, mutateLimiter, pricesRoutes);
// 404 для всего что не сматчилось выше — единый JSON-ответ, не express default text // 404 для всего что не сматчилось выше — единый JSON-ответ, не express default text
app.use((_req, res) => { app.use((_req, res) => {
res.status(404).json({ success: false, error: 'Not found' }); res.status(404).json({ success: false, error: 'Not found' });

View File

@@ -0,0 +1,138 @@
/**
* GET /api/prices — USD prices for selected token symbols.
*
* Security:
* S1 — whitelist через `getCoingeckoId`. Любой symbol вне registry → 400.
* S2 — лимит max 50 (symbol, chain) пар. Иначе → 400.
* S5 — общий 502 при failure, без stack trace.
* S7 — auth provided by router middleware.
*/
import { Request, Response } from 'express';
import { getCoingeckoId } from '../lib/token-registry';
import { ALL_CHAINS } from '../services/wallet-generator.service';
import { getPricesBySymbols } from '../services/price-oracle.service';
import type { ChainCode } from '../lib/address-validators';
import { logger } from '../lib/logger';
const MAX_SYMBOLS_PER_REQUEST = 50;
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
const SYMBOL_RE = /^[A-Z0-9]{1,16}$/;
function isChain(v: unknown): v is ChainCode {
return typeof v === 'string' && ALLOWED_CHAINS.has(v as ChainCode);
}
export const PricesController = {
/**
* GET /api/prices?symbols=BTC,ETH,USDT&chain=ETH
*
* Params:
* - symbols: comma-separated list, max 50. Каждый symbol должен быть в whitelist.
* - chain (опционально): chain для disambiguation (USDT на ETH vs USDT на BSC).
* Если не указан — для каждого symbol fallback порядок: ETH → BSC → SOL → TRX → BTC.
* Native symbol (BTC/ETH/...) всегда matches its chain.
*
* Response 200:
* { success: true, data: { "BTC": { "usd": 67432.12 }, "ETH": { "usd": 3210.45 }, "FOO": { "usd": null } } }
*/
async getPrices(req: Request, res: Response) {
try {
const rawSymbols = String(req.query.symbols || '').trim();
if (!rawSymbols) {
res.status(400).json({ success: false, error: 'symbols query param is required (csv)' });
return;
}
const requestedChain = req.query.chain ? String(req.query.chain).toUpperCase() : null;
if (requestedChain && !isChain(requestedChain)) {
res.status(400).json({ success: false, error: 'Invalid chain parameter' });
return;
}
const symbols = rawSymbols
.split(',')
.map((s) => s.trim().toUpperCase())
.filter((s) => s.length > 0);
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;
}
// Strict symbol shape (S1 belt-and-suspenders).
for (const s of symbols) {
if (!SYMBOL_RE.test(s)) {
res.status(400).json({ success: false, error: `Invalid symbol: ${s}` });
return;
}
}
// Build (chain, symbol) pairs.
// Fallback resolution order при отсутствии явного chain:
// native symbol == chain code → that chain;
// иначе пробуем ETH, BSC, SOL, TRX, BTC по очереди.
const fallbackChains: ChainCode[] = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC'];
const pairs: { chain: ChainCode; symbol: string; key: string }[] = [];
for (const sym of symbols) {
if (requestedChain) {
pairs.push({ chain: requestedChain as ChainCode, symbol: sym, key: sym });
continue;
}
let resolvedChain: ChainCode | null = null;
if (ALLOWED_CHAINS.has(sym as ChainCode)) {
resolvedChain = sym as ChainCode;
} else {
for (const c of fallbackChains) {
if (getCoingeckoId(c, sym)) {
resolvedChain = c;
break;
}
}
}
if (!resolvedChain) {
// Symbol не находится ни в одной chain → 400 (S1: whitelist enforcement).
res.status(400).json({ success: false, error: `Unknown symbol: ${sym}` });
return;
}
pairs.push({ chain: resolvedChain, symbol: sym, key: sym });
}
// Если явный chain задан — повторная проверка whitelist для каждого symbol
// (native symbol для chain'а тоже разрешён).
if (requestedChain) {
for (const p of pairs) {
if (!getCoingeckoId(p.chain, p.symbol)) {
res.status(400).json({
success: false,
error: `Unknown symbol ${p.symbol} for chain ${p.chain}`,
});
return;
}
}
}
const prices = await getPricesBySymbols(
pairs.map((p) => ({ chain: p.chain, symbol: p.symbol })),
);
const data: Record<string, { usd: number | null }> = {};
for (const p of pairs) {
const lookupKey = `${p.chain}:${p.symbol}`;
data[p.key] = { usd: prices.get(lookupKey) ?? null };
}
res.json({ success: true, data });
} catch (err: any) {
logger.error(`getPrices failed: ${err?.stack || err?.message || 'unknown'}`);
res.status(502).json({ success: false, error: 'Upstream price oracle error' });
}
},
};

View File

@@ -12,57 +12,72 @@ export interface EvmToken {
symbol: string; symbol: string;
contractAddress: string; contractAddress: string;
decimals: number; decimals: number;
coingeckoId?: string;
} }
export interface TrxToken { export interface TrxToken {
symbol: string; symbol: string;
contractAddress: string; // T...base58 contractAddress: string; // T...base58
decimals: number; decimals: number;
coingeckoId?: string;
} }
export interface SolToken { export interface SolToken {
symbol: string; symbol: string;
mint: string; // SPL mint pubkey (base58) mint: string; // SPL mint pubkey (base58)
decimals: number; decimals: number;
coingeckoId?: string;
} }
/**
* CoinGecko coin IDs для native монет каждой chain.
* Используется в `price-oracle.service.ts` для USD-цен в `/balance`.
*/
export const NATIVE_COINGECKO_IDS: Record<ChainCode, string> = {
BTC: 'bitcoin',
ETH: 'ethereum',
BSC: 'binancecoin',
TRX: 'tron',
SOL: 'solana',
};
export const ETH_TOKENS: EvmToken[] = [ export const ETH_TOKENS: EvmToken[] = [
{ symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 }, { symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6, coingeckoId: 'tether' },
{ symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 }, { symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, coingeckoId: 'usd-coin' },
{ symbol: 'DAI', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18 }, { symbol: 'DAI', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18, coingeckoId: 'dai' },
{ symbol: 'WBTC', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8 }, { symbol: 'WBTC', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8, coingeckoId: 'wrapped-bitcoin' },
{ symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18 }, { symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18, coingeckoId: 'chainlink' },
{ symbol: 'UNI', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18 }, { symbol: 'UNI', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18, coingeckoId: 'uniswap' },
]; ];
export const BSC_TOKENS: EvmToken[] = [ export const BSC_TOKENS: EvmToken[] = [
{ symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 }, { symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18, coingeckoId: 'tether' },
{ symbol: 'USDC', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18 }, { symbol: 'USDC', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, coingeckoId: 'usd-coin' },
{ symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8 }, { symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8, coingeckoId: 'dogecoin' },
{ symbol: 'WBNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18 }, { symbol: 'WBNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18, coingeckoId: 'wbnb' },
{ symbol: 'BUSD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18 }, { symbol: 'BUSD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18, coingeckoId: 'binance-usd' },
]; ];
export const TRX_TOKENS: TrxToken[] = [ export const TRX_TOKENS: TrxToken[] = [
{ symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 }, { symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6, coingeckoId: 'tether' },
{ symbol: 'USDC', contractAddress: 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', decimals: 6 }, { symbol: 'USDC', contractAddress: 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', decimals: 6, coingeckoId: 'usd-coin' },
]; ];
export const SOL_TOKENS: SolToken[] = [ export const SOL_TOKENS: SolToken[] = [
{ symbol: 'USDT', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 }, { symbol: 'USDT', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6, coingeckoId: 'tether' },
{ symbol: 'USDC', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 }, { symbol: 'USDC', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6, coingeckoId: 'usd-coin' },
{ symbol: 'PUMP', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6 }, { symbol: 'PUMP', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6, coingeckoId: 'pump-fun' },
{ symbol: 'JUP', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6 }, { symbol: 'JUP', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6, coingeckoId: 'jupiter-exchange-solana' },
{ symbol: 'WIF', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6 }, { symbol: 'WIF', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6, coingeckoId: 'dogwifcoin' },
{ symbol: 'POPCAT', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9 }, { symbol: 'POPCAT', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9, coingeckoId: 'popcat' },
{ symbol: 'TRUMP', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6 }, { symbol: 'TRUMP', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6, coingeckoId: 'official-trump' },
{ symbol: 'PYTH', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6 }, { symbol: 'PYTH', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6, coingeckoId: 'pyth-network' },
{ symbol: 'JTO', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9 }, { symbol: 'JTO', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9, coingeckoId: 'jito-governance-token' },
{ symbol: 'W', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6 }, { symbol: 'W', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6, coingeckoId: 'wormhole' },
{ symbol: 'BONK', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5 }, { symbol: 'BONK', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5, coingeckoId: 'bonk' },
{ symbol: 'ORCA', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6 }, { symbol: 'ORCA', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6, coingeckoId: 'orca' },
{ symbol: 'PENGU', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6 }, { symbol: 'PENGU', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6, coingeckoId: 'pudgy-penguins' },
{ symbol: 'RAY', mint: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6 }, { symbol: 'RAY', mint: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6, coingeckoId: 'raydium' },
]; ];
export function getEvmTokens(chain: ChainCode): EvmToken[] { export function getEvmTokens(chain: ChainCode): EvmToken[] {
@@ -106,3 +121,41 @@ export function getTokenInfo(chain: ChainCode, symbol: string): { address: strin
} }
return null; return null;
} }
/**
* Resolves the CoinGecko coin id for a given (chain, symbol) pair.
*
* Если `symbol` совпадает с самим именем chain (BTC/ETH/BSC/TRX/SOL) — возвращает
* native id (`NATIVE_COINGECKO_IDS[chain]`).
* В остальных случаях ищет токен в реестре сети и возвращает его `coingeckoId`.
*
* Возвращает `null` если:
* - chain неизвестен;
* - symbol не найден в реестре сети;
* - токен найден, но `coingeckoId` для него не задан.
*
* Используется исключительно как whitelist для price oracle (см. S1 в плане):
* никакой свободный user-input не попадает в CoinGecko URL.
*/
export function getCoingeckoId(chain: ChainCode, symbol: string): string | null {
if (!chain) return null;
const upper = String(symbol || '').toUpperCase();
if (!upper) return null;
// Native — symbol === chain code (BTC, ETH, ...).
if (upper === chain) return NATIVE_COINGECKO_IDS[chain] ?? null;
if (chain === 'ETH' || chain === 'BSC') {
const t = getEvmTokens(chain).find((x) => x.symbol.toUpperCase() === upper);
return t?.coingeckoId ?? null;
}
if (chain === 'TRX') {
const t = TRX_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
return t?.coingeckoId ?? null;
}
if (chain === 'SOL') {
const t = SOL_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
return t?.coingeckoId ?? null;
}
return null;
}

View File

@@ -0,0 +1,8 @@
import { Router } from 'express';
import { PricesController } from '../controllers/prices.controller';
const router = Router();
router.get('/', PricesController.getPrices);
export default router;

View File

@@ -0,0 +1,235 @@
/**
* USD price oracle for wallet balance responses.
*
* Data source: CoinGecko free API (https://api.coingecko.com/api/v3/simple/price).
* Cache: KeyDB (Redis), TTL = 300s.
*
* Security (см. план §"Security checklist"):
* S1 — whitelist через getCoingeckoId → user input не попадает в URL.
* S2 — лимит размеров вызовов через caller (controller `/prices`).
* S3 — strict typeof/Number.isFinite/>=0 при чтении cache.
* S4 — in-flight dedup (см. `_inflight` map) + cache.
* S5 — никаких stack-trace'ов наружу; ошибки в logger.
* S9 — CG API key, если задан, идёт ТОЛЬКО в header (не в URL).
* S10 — `Number.isFinite` guard для usdValue (применяется в `wallet-ops.service.ts`).
* S11 — жёсткий 5s AbortController timeout.
* S12 — `null` ответ не кэшируем; только успешные числа уходят в Redis.
*/
import { getRedis } from '../config/redis';
import { logger } from '../lib/logger';
import { getCoingeckoId } from '../lib/token-registry';
import type { ChainCode } from '../lib/address-validators';
const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price';
const CACHE_TTL_SECONDS = 300;
const CACHE_KEY_PREFIX = 'price:';
const FETCH_TIMEOUT_MS = 5000;
const MAX_IDS_PER_REQUEST = 100; // CoinGecko allows ~250, мы консервативно 100.
interface CachedPrice {
usd: number;
ts: number;
}
/** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */
const _inflight = new Map<string, Promise<Record<string, number | null>>>();
function isValidPrice(n: unknown): n is number {
return typeof n === 'number' && Number.isFinite(n) && n >= 0;
}
function buildHeaders(): Record<string, string> {
const headers: Record<string, string> = { Accept: 'application/json' };
const key = process.env.COINGECKO_API_KEY;
if (key && key.length > 0) {
// CoinGecko Demo API key → `x-cg-demo-api-key`. Pro → `x-cg-pro-api-key`.
// Не печатаем header нигде, см. S9.
headers['x-cg-demo-api-key'] = key;
}
return headers;
}
/**
* Fetches CoinGecko /simple/price for a batch of coin ids.
* Internal — caller must ensure `ids.length > 0 && ids.length <= MAX_IDS_PER_REQUEST`.
*/
async function fetchCoingecko(ids: string[]): Promise<Record<string, number | null>> {
const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd`;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: ctrl.signal,
headers: buildHeaders(),
});
if (!res.ok) {
// S5: не логируем URL целиком (содержит query string).
logger.warn(`CoinGecko HTTP ${res.status} for ${ids.length} ids`);
const out: Record<string, number | null> = {};
for (const id of ids) out[id] = null;
return out;
}
const json = (await res.json()) as Record<string, { usd?: unknown }>;
const out: Record<string, number | null> = {};
for (const id of ids) {
const usd = json?.[id]?.usd;
out[id] = isValidPrice(usd) ? usd : null;
}
return out;
} catch (err: any) {
logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`);
const out: Record<string, number | null> = {};
for (const id of ids) out[id] = null;
return out;
} finally {
clearTimeout(t);
}
}
/**
* Возвращает USD-цены для списка CoinGecko ids.
* Никогда не throws — degrades to `null` per-id.
*
* Cache: read-through KeyDB, 300s TTL. Только валидные числа кэшируются (S12).
* Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4).
*/
export async function getPricesByIds(ids: string[]): Promise<Record<string, number | null>> {
if (!Array.isArray(ids) || ids.length === 0) return {};
// Дедупликация ids (на случай если caller передал duplicates).
const uniqIds = Array.from(new Set(ids.filter((x) => typeof x === 'string' && x.length > 0)));
if (uniqIds.length === 0) return {};
const result: Record<string, number | null> = {};
let redis: ReturnType<typeof getRedis> | null = null;
try {
redis = getRedis();
} catch {
// Redis singleton недоступен — продолжаем без cache, сразу идём в CG.
redis = null;
}
// 1) Read cache (pipeline)
const misses: string[] = [];
if (redis) {
try {
const pipeline = redis.pipeline();
for (const id of uniqIds) pipeline.get(CACHE_KEY_PREFIX + id);
const cached = await pipeline.exec();
uniqIds.forEach((id, i) => {
const tuple = cached?.[i];
const raw = tuple?.[1] as string | null | undefined;
if (raw) {
try {
const parsed = JSON.parse(raw) as CachedPrice;
if (isValidPrice(parsed?.usd)) {
result[id] = parsed.usd;
return;
}
} catch {
// S3 — невалидный JSON в cache → fall through к refetch.
}
}
misses.push(id);
});
} catch (err: any) {
logger.warn(`Redis cache read failed: ${err?.message || 'unknown'}`);
// Cache miss for ALL ids — degrade to upstream fetch.
for (const id of uniqIds) {
if (!(id in result)) misses.push(id);
}
}
} else {
for (const id of uniqIds) misses.push(id);
}
if (misses.length === 0) return result;
// 2) Fetch misses в batches (S2-style guard) + in-flight dedup (S4).
const fetched: Record<string, number | null> = {};
for (let i = 0; i < misses.length; i += MAX_IDS_PER_REQUEST) {
const batch = misses.slice(i, i + MAX_IDS_PER_REQUEST);
const batchKey = batch.join('|');
let p = _inflight.get(batchKey);
if (!p) {
p = fetchCoingecko(batch).finally(() => _inflight.delete(batchKey));
_inflight.set(batchKey, p);
}
const batchResult = await p;
Object.assign(fetched, batchResult);
}
// 3) Persist successes to cache (S12: skip nulls).
if (redis) {
try {
const setP = redis.pipeline();
let writes = 0;
for (const [id, val] of Object.entries(fetched)) {
if (isValidPrice(val)) {
setP.set(
CACHE_KEY_PREFIX + id,
JSON.stringify({ usd: val, ts: Date.now() } satisfies CachedPrice),
'EX',
CACHE_TTL_SECONDS,
);
writes += 1;
}
}
if (writes > 0) await setP.exec();
} catch (err: any) {
// Cache write failure → не критично, продолжаем.
logger.warn(`Redis cache write failed: ${err?.message || 'unknown'}`);
}
}
// 4) Merge fetched into result.
for (const id of misses) {
result[id] = id in fetched ? fetched[id] : null;
}
return result;
}
/**
* Convenience-обёртка для callers которые оперируют (chain, symbol) парами.
*
* Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null.
* Ключ совпадает с тем что caller затем использует на lookup'е.
*
* Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful).
* Никаких throw'ов, никаких побочек кроме cache writes.
*/
export async function getPricesBySymbols(
pairs: { chain: ChainCode; symbol: string }[],
): Promise<Map<string, number | null>> {
const out = new Map<string, number | null>();
if (!Array.isArray(pairs) || pairs.length === 0) return out;
// (chain:symbol) → coingeckoId | null
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; // dedup
const id = getCoingeckoId(chain, symbol);
pairToId.set(key, id);
if (id) idsToFetch.add(id);
else out.set(key, null);
}
const prices = await getPricesByIds(Array.from(idsToFetch));
for (const [key, id] of pairToId.entries()) {
if (out.has(key)) continue;
if (!id) {
out.set(key, null);
continue;
}
out.set(key, prices[id] ?? null);
}
return out;
}

View File

@@ -250,14 +250,38 @@ export async function swapBsc(p: SwapBscParams): Promise<{ approveTxid?: string;
// ─── TRX SunSwap ───────────────────────────────────────────────────── // ─── TRX SunSwap ─────────────────────────────────────────────────────
const TRONGRID = 'https://api.trongrid.io'; const TRONGRID = 'https://api.trongrid.io';
const SUNSWAP_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax'; // SunSwap V2 Router
// Constants — те же что в tron-swap-proxy.routes.ts (single source of truth для prod адресов).
const SUNSWAP_SMART_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax';
const USDT_CONTRACT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
const WTRX_CONTRACT = 'TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR';
const FEE_SWAP_ROUTER_TRX = 'TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E'; // 0.7% fee router
const FEE_BPS = 70n;
const BPS_DENOMINATOR = 10_000n;
// Minimal TRX swap для TRX↔USDT (other tokens — добавить через registry) // Minimal TRX swap для TRX↔USDT (other tokens — добавить через registry)
const TRX_SWAP_TOKEN_MAP: Record<string, { address: string; decimals: number; isNative: boolean }> = { const TRX_SWAP_TOKEN_MAP: Record<string, { address: string; decimals: number; isNative: boolean }> = {
TRX: { address: 'TRX', decimals: 6, isNative: true }, TRX: { address: 'TRX', decimals: 6, isNative: true },
USDT: { address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6, isNative: false }, USDT: { address: USDT_CONTRACT, decimals: 6, isNative: false },
}; };
const TRX_BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
// Method selectors (keccak256 first 4 bytes). Verified via `keccak256(toUtf8Bytes(sig)).slice(2,10)`.
// approve(address,uint256) → 095ea7b3
// swapExactETHForTokensSupportingFeeOnTransferTokens(uint256,address[],...,...) → b6f9de95
// swapExactTokensForETHSupportingFeeOnTransferTokens(uint256,uint256,...,...) → 791ac947 (NOT 18cbafe5 — that's no-fee variant)
// swapNativeWithFee(bytes) → 152dad1d
// swapTokenWithFee(address,uint256,bytes) → e8d1f203
//
// NOTE: legacy proxy route использовал 18cbafe5 = swapExactTokensForETH (без supporting-fee).
// FeeSwapRouter работает с supporting-fee variant — это уже доказано в production traffic.
const SEL_APPROVE = '095ea7b3';
const SEL_SWAP_EXACT_ETH_FOR_TOKENS = 'b6f9de95';
const SEL_SWAP_EXACT_TOKENS_FOR_ETH = '18cbafe5';
const SEL_SWAP_NATIVE_WITH_FEE = '152dad1d';
const SEL_SWAP_TOKEN_WITH_FEE = 'e8d1f203';
export interface SwapTrxParams { export interface SwapTrxParams {
mnemonic: string; mnemonic: string;
expectedFromAddress: string; expectedFromAddress: string;
@@ -282,17 +306,324 @@ async function fetchJson(url: string, init?: RequestInit): Promise<any> {
} }
} }
// ─── TRX encoding helpers (порт из tron-swap-proxy.routes.ts) ───
function trxAddrToHex(address: string): string {
let num = 0n;
for (const ch of address) {
const i = TRX_BASE58_ALPHABET.indexOf(ch);
if (i === -1) throw new Error('Invalid base58 character');
num = num * 58n + BigInt(i);
}
const hex = num.toString(16).padStart(50, '0');
return hex.slice(2, 42); // skip 0x41, take 20 bytes
}
function encU256(value: bigint): string {
return value.toString(16).padStart(64, '0');
}
function encAddr(address: string): string {
return trxAddrToHex(address).padStart(64, '0');
}
function encDynamicBytes(hexData: string): string {
const data = hexData.startsWith('0x') ? hexData.slice(2) : hexData;
const byteLength = data.length / 2;
const lengthEncoded = encU256(BigInt(byteLength));
const paddedData = data.padEnd(Math.ceil(data.length / 64) * 64, '0');
return lengthEncoded + paddedData;
}
// SunSwap V2 router calldata:
// function swapExactETHForTokens(uint256 amountOutMin, address[] path, address to, uint256 deadline)
function buildSwapExactETHForTokensCalldata(
amountOutMin: bigint,
path: string[],
to: string,
deadline: bigint,
): string {
const offsetToPath = encU256(128n); // 4 × 32 bytes
const pathLen = encU256(BigInt(path.length));
const pathElements = path.map(encAddr).join('');
return SEL_SWAP_EXACT_ETH_FOR_TOKENS + encU256(amountOutMin) + offsetToPath +
encAddr(to) + encU256(deadline) + pathLen + pathElements;
}
// function swapExactTokensForETH(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline)
function buildSwapExactTokensForETHCalldata(
amountIn: bigint,
amountOutMin: bigint,
path: string[],
to: string,
deadline: bigint,
): string {
const offsetToPath = encU256(160n); // 5 × 32 bytes
const pathLen = encU256(BigInt(path.length));
const pathElements = path.map(encAddr).join('');
return SEL_SWAP_EXACT_TOKENS_FOR_ETH + encU256(amountIn) + encU256(amountOutMin) +
offsetToPath + encAddr(to) + encU256(deadline) + pathLen + pathElements;
}
interface BuiltTrxTx {
txID: string;
raw_data: any;
raw_data_hex: string;
}
interface BuildTriggerParams {
ownerAddress: string;
contractAddress: string;
functionSelector: string;
parameter: string;
callValue: number;
feeLimit: number;
headers: Record<string, string>;
}
async function buildTrigger(p: BuildTriggerParams): Promise<BuiltTrxTx> {
const body = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, {
method: 'POST',
headers: p.headers,
body: JSON.stringify({
owner_address: p.ownerAddress,
contract_address: p.contractAddress,
function_selector: p.functionSelector,
parameter: p.parameter,
call_value: p.callValue,
fee_limit: p.feeLimit,
visible: true,
}),
});
if (!body?.result?.result || !body.transaction) {
const msg = body?.result?.message
? Buffer.from(body.result.message, 'hex').toString('utf8')
: 'TronGrid triggersmartcontract returned no transaction';
throw new Error(`TRX build failed: ${msg.slice(0, 200)}`);
}
const tx = body.transaction as BuiltTrxTx;
if (!tx.txID || !tx.raw_data || !tx.raw_data_hex) {
throw new Error('TRX build response missing txID / raw_data / raw_data_hex');
}
return tx;
}
async function checkAllowance(
owner: string,
tokenContract: string,
spender: string,
headers: Record<string, string>,
): Promise<bigint> {
const parameter = encAddr(owner) + encAddr(spender);
const body = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
method: 'POST',
headers,
body: JSON.stringify({
owner_address: owner,
contract_address: tokenContract,
function_selector: 'allowance(address,address)',
parameter,
visible: true,
}),
});
const hex = body?.constant_result?.[0];
if (!hex || /^0+$/.test(hex)) return 0n;
return BigInt('0x' + hex);
}
async function getAmountsOut(
amountIn: bigint,
path: string[],
headers: Record<string, string>,
): Promise<bigint> {
const amountHex = encU256(amountIn);
const offsetHex = encU256(64n);
const lengthHex = encU256(BigInt(path.length));
const pathHex = path.map(encAddr).join('');
const parameter = amountHex + offsetHex + lengthHex + pathHex;
const body = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
method: 'POST',
headers,
body: JSON.stringify({
owner_address: SUNSWAP_SMART_ROUTER,
contract_address: SUNSWAP_SMART_ROUTER,
function_selector: 'getAmountsOut(uint256,address[])',
parameter,
visible: true,
}),
});
const hex = body?.constant_result?.[0];
if (!hex) {
const msg = body?.result?.message
? Buffer.from(body.result.message, 'hex').toString('utf8')
: 'getAmountsOut returned no result';
throw new Error(`TRX quote failed: ${msg.slice(0, 200)}`);
}
// Last 32 bytes hex of result = amounts[1] (output amount).
const amountOutHex = hex.slice(-64);
return BigInt('0x' + amountOutHex);
}
/** /**
* TRX swap через SunSwap. Для упрощения — пока TRX↔USDT only (как в существующем proxy route). * MITM-verify built TRX tx перед подписью (4-layer защита от компрометированного TronGrid).
* Расширить через token-registry если потребуется ETH/USDC support. * Селектор `expectedSelector` (8 hex chars) ловит подмену метода на 5-м уровне.
*/ */
export async function swapTrx(p: SwapTrxParams): Promise<{ txid: string }> { function verifyTrxTx(opts: {
const fromInfo = TRX_SWAP_TOKEN_MAP[p.from.toUpperCase()]; tx: BuiltTrxTx;
const toInfo = TRX_SWAP_TOKEN_MAP[p.to.toUpperCase()]; expectedOwner: string;
if (!fromInfo || !toInfo || p.from === p.to) { expectedContract: string;
expectedSelector: string; // 8 hex chars, lowercase
expectedCallValue?: number;
}): void {
// 1. txID = SHA256(raw_data_hex)
const expectedTxId = createHash('sha256')
.update(Buffer.from(opts.tx.raw_data_hex, 'hex'))
.digest('hex');
if (expectedTxId !== opts.tx.txID) {
throw new Error('TRX txID mismatch — possible MITM/compromised RPC');
}
// 2. expiration bounds (TRON default ~60s; cap 90s)
const nowMs = Date.now();
const expiration = Number(opts.tx.raw_data.expiration);
const timestamp = Number(opts.tx.raw_data.timestamp);
if (!Number.isFinite(expiration) || !Number.isFinite(timestamp)) {
throw new Error('TRX tx malformed (no expiration/timestamp)');
}
if (expiration - nowMs > 90_000 || expiration <= nowMs) {
throw new Error(`TRX expiration out of bounds: ${expiration - nowMs}ms`);
}
if (Math.abs(timestamp - nowMs) > 30_000) {
throw new Error(`TRX timestamp drift too large: ${timestamp - nowMs}ms`);
}
// 3. contract[0].type === 'TriggerSmartContract'
const c0 = opts.tx.raw_data.contract?.[0];
if (!c0) throw new Error('TRX tx malformed (no contract[0])');
if (c0.type !== 'TriggerSmartContract') {
throw new Error(`TRX contract type mismatch: expected TriggerSmartContract, got ${c0.type}`);
}
// 4. owner / contract / selector / call_value
const v = c0.parameter?.value;
if (!v) throw new Error('TRX tx malformed (no contract value)');
if (v.owner_address !== opts.expectedOwner) {
throw new Error(`TRX owner_address mismatch: expected ${opts.expectedOwner}, got ${v.owner_address}`);
}
if (v.contract_address !== opts.expectedContract) {
throw new Error(`TRX contract mismatch: expected ${opts.expectedContract}, got ${v.contract_address}`);
}
const data = String(v.data || '').toLowerCase();
if (data.slice(0, 8) !== opts.expectedSelector.toLowerCase()) {
throw new Error(
`TRX selector mismatch: expected ${opts.expectedSelector}, got ${data.slice(0, 8)}`,
);
}
if (opts.expectedCallValue !== undefined) {
const actual = Number(v.call_value ?? 0);
if (actual !== opts.expectedCallValue) {
throw new Error(`TRX call_value mismatch: expected ${opts.expectedCallValue}, got ${actual}`);
}
}
}
/** Sign verified tx + broadcast. Returns txid. */
async function signAndBroadcastTrx(
tx: BuiltTrxTx,
wallet: ethers.Wallet,
headers: Record<string, string>,
): Promise<string> {
const sk = new ethers.utils.SigningKey(wallet.privateKey);
const sig = sk.signDigest('0x' + tx.txID);
if (sig.recoveryParam !== 0 && sig.recoveryParam !== 1) {
throw new Error(`TRX signing: invalid recoveryParam ${sig.recoveryParam}`);
}
const sigHex =
sig.r.slice(2) + sig.s.slice(2) + sig.recoveryParam.toString(16).padStart(2, '0');
const clean = {
txID: tx.txID,
raw_data: tx.raw_data,
raw_data_hex: tx.raw_data_hex,
signature: [sigHex],
visible: true,
};
const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, {
method: 'POST',
headers,
body: JSON.stringify(clean),
});
if (!broadcast?.result) {
const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown';
const code = broadcast?.code || 'NO_CODE';
throw new Error(`TRX broadcast failed [${code}]: ${msg.slice(0, 200)}`);
}
return tx.txID;
}
/** Poll gettransactionbyid until included / failed / timeout (max 30s, every 1.5s). */
async function waitTrxInclusion(
txid: string,
headers: Record<string, string>,
): Promise<void> {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1500));
try {
const info = await fetchJson(`${TRONGRID}/wallet/gettransactioninfobyid`, {
method: 'POST',
headers,
body: JSON.stringify({ value: txid }),
});
// Если info.id присутствует — tx уже в блоке.
if (info?.id) {
const result = info.receipt?.result;
if (result && result !== 'SUCCESS') {
throw new Error(`TRX approve tx reverted: ${result}`);
}
return;
}
} catch (err: any) {
// Сетевой блип — продолжаем polling.
if (Date.now() >= deadline) throw err;
}
}
throw new Error(`TRX tx ${txid.slice(0, 12)}... inclusion timed out after 30s`);
}
/**
* TRX swap через SunSwap V2 + FeeSwapRouter (0.7% fee).
* Поддерживает только TRX↔USDT (same scope как legacy /tron/swap/build).
*
* TRX → USDT (1 tx): swapNativeWithFee wraps swapExactETHForTokens.
* USDT → TRX (1-2 tx): optional approve(infinite) + swapTokenWithFee wraps swapExactTokensForETH.
*
* Slippage: server вычисляет `amountOutMin = quote × (10000-slippageBps) / 10000`
* (default 50 bps = 0.5%). Клиент НЕ задаёт amountOutMin (защита от MEV-sandwich).
*
* Возвращает `{ approveTxid?, swapTxid }` для совместимости с swapBsc.
*/
export async function swapTrx(
p: SwapTrxParams,
): Promise<{ approveTxid?: string; swapTxid: string }> {
const fromU = p.from.toUpperCase();
const toU = p.to.toUpperCase();
const fromInfo = TRX_SWAP_TOKEN_MAP[fromU];
const toInfo = TRX_SWAP_TOKEN_MAP[toU];
if (!fromInfo || !toInfo || fromU === toU) {
throw new Error(`TRX swap supports only TRX↔USDT pairs (got ${p.from}${p.to})`); throw new Error(`TRX swap supports only TRX↔USDT pairs (got ${p.from}${p.to})`);
} }
const amount = BigInt(p.amount);
if (amount <= 0n) throw new Error('TRX swap: amount must be positive');
const slippageBps = BigInt(
p.slippageBps !== undefined && Number.isFinite(p.slippageBps) ? p.slippageBps : 50,
);
if (slippageBps < 1n || slippageBps > 1000n) {
throw new Error('TRX swap: slippageBps must be between 1 and 1000');
}
// Derive TRX address.
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX); const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX);
const fromTronAddr = ethAddressToTron(wallet.address); const fromTronAddr = ethAddressToTron(wallet.address);
if (fromTronAddr !== p.expectedFromAddress) { if (fromTronAddr !== p.expectedFromAddress) {
@@ -302,10 +633,114 @@ export async function swapTrx(p: SwapTrxParams): Promise<{ txid: string }> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey; if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
// Build SunSwap unsigned tx через triggersmartcontract // Compute fee + swap split (FeeSwapRouter забирает 0.7%, остальные 99.3% уходят в SunSwap).
// (Полная implementation SunSwap calldata builder — большой кусок; для prod — call existing const feeAmount = (amount * FEE_BPS) / BPS_DENOMINATOR;
// /tron/swap/build endpoint logic. Пока MVP: throw "use legacy /tron/swap/build + /broadcast") const swapAmount = amount - feeAmount;
throw new Error('TRX swap orchestrator: pending implementation. Use legacy /tron/swap/build + custodial broadcast.');
// Quote (на 99.3%, т.к. это то что SunSwap реально получит).
const isTrxToUsdt = fromU === 'TRX';
const path = isTrxToUsdt
? [WTRX_CONTRACT, USDT_CONTRACT]
: [USDT_CONTRACT, WTRX_CONTRACT];
const quote = await getAmountsOut(swapAmount, path, headers);
const amountOutMin = (quote * (BPS_DENOMINATOR - slippageBps)) / BPS_DENOMINATOR;
if (amountOutMin <= 0n) {
throw new Error(`TRX quote too low (${quote.toString()}) — no liquidity?`);
}
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // 20 минут
// ─── TRX → USDT ───
if (isTrxToUsdt) {
const sunswapCalldata = buildSwapExactETHForTokensCalldata(
amountOutMin, path, fromTronAddr, deadline,
);
const offsetToBytes = encU256(32n);
const feeRouterParam = offsetToBytes + encDynamicBytes(sunswapCalldata);
// Number(amount) safe здесь т.к. TRX bounded по precision-check в sendTrx (≤ MAX_SAFE_INT sun).
const amountNum = Number(amount);
if (amountNum > Number.MAX_SAFE_INTEGER) {
throw new Error('TRX swap amount exceeds Number.MAX_SAFE_INTEGER (9B TRX)');
}
const swapTx = await buildTrigger({
ownerAddress: fromTronAddr,
contractAddress: FEE_SWAP_ROUTER_TRX,
functionSelector: 'swapNativeWithFee(bytes)',
parameter: feeRouterParam,
callValue: amountNum,
feeLimit: 200_000_000, // 200 TRX cap
headers,
});
verifyTrxTx({
tx: swapTx,
expectedOwner: fromTronAddr,
expectedContract: FEE_SWAP_ROUTER_TRX,
expectedSelector: SEL_SWAP_NATIVE_WITH_FEE,
expectedCallValue: amountNum,
});
const swapTxid = await signAndBroadcastTrx(swapTx, wallet, headers);
return { swapTxid };
}
// ─── USDT → TRX ───
// Step 1: check allowance, approve infinite if needed.
let approveTxid: string | undefined;
const allowance = await checkAllowance(fromTronAddr, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, headers);
if (allowance < amount) {
const INFINITE = BigInt(
'0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
);
const approveParam = encAddr(FEE_SWAP_ROUTER_TRX) + encU256(INFINITE);
const approveTx = await buildTrigger({
ownerAddress: fromTronAddr,
contractAddress: USDT_CONTRACT,
functionSelector: 'approve(address,uint256)',
parameter: approveParam,
callValue: 0,
feeLimit: 100_000_000, // 100 TRX cap
headers,
});
verifyTrxTx({
tx: approveTx,
expectedOwner: fromTronAddr,
expectedContract: USDT_CONTRACT,
expectedSelector: SEL_APPROVE,
expectedCallValue: 0,
});
approveTxid = await signAndBroadcastTrx(approveTx, wallet, headers);
// Ждём inclusion approve, иначе swap revert'нёт "transfer amount exceeds allowance".
await waitTrxInclusion(approveTxid, headers);
}
// Step 2: build swapTokenWithFee(USDT, amount, calldata).
const sunswapCalldata = buildSwapExactTokensForETHCalldata(
swapAmount, amountOutMin, path, fromTronAddr, deadline,
);
const tokenInEnc = encAddr(USDT_CONTRACT);
const amountInEnc = encU256(amount);
const offsetToBytes = encU256(96n); // 3 × 32 bytes
const feeRouterParam = tokenInEnc + amountInEnc + offsetToBytes + encDynamicBytes(sunswapCalldata);
const swapTx = await buildTrigger({
ownerAddress: fromTronAddr,
contractAddress: FEE_SWAP_ROUTER_TRX,
functionSelector: 'swapTokenWithFee(address,uint256,bytes)',
parameter: feeRouterParam,
callValue: 0,
feeLimit: 200_000_000,
headers,
});
verifyTrxTx({
tx: swapTx,
expectedOwner: fromTronAddr,
expectedContract: FEE_SWAP_ROUTER_TRX,
expectedSelector: SEL_SWAP_TOKEN_WITH_FEE,
expectedCallValue: 0,
});
const swapTxid = await signAndBroadcastTrx(swapTx, wallet, headers);
return { approveTxid, swapTxid };
} }
// ─── SOL Jupiter ───────────────────────────────────────────────────── // ─── SOL Jupiter ─────────────────────────────────────────────────────

View File

@@ -6,6 +6,8 @@ import { ethers } from 'ethers';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { env } from '../config/env'; import { env } from '../config/env';
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry'; import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
import { getPricesBySymbols } from './price-oracle.service';
import { logger } from '../lib/logger';
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC'; export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
@@ -30,6 +32,17 @@ export interface FormattedAmount {
raw: string; // smallest units (string-encoded BigInt — без потери точности) raw: string; // smallest units (string-encoded BigInt — без потери точности)
formatted: string; // human-readable, e.g. "0.003" formatted: string; // human-readable, e.g. "0.003"
decimals: number; // decimals chain'а/токена decimals: number; // decimals chain'а/токена
/**
* USD price per 1 целая единица (e.g. $67432.12 за 1 BTC).
* `null` если symbol не в реестре с `coingeckoId` или upstream price oracle недоступен.
* Источник: CoinGecko free API, cache 5 мин в KeyDB.
*/
usdPrice: number | null;
/**
* Совокупная USD-стоимость holding'а: `Number(formatted) × usdPrice`.
* Округлено до 8 знаков. `null` если `usdPrice === null` или вычисление дало `Infinity/NaN`.
*/
usdValue: number | null;
} }
export interface BalanceResult { export interface BalanceResult {
@@ -70,7 +83,67 @@ export function formatUnits(raw: string, decimals: number): string {
} }
function fmt(raw: string, decimals: number): FormattedAmount { function fmt(raw: string, decimals: number): FormattedAmount {
return { raw, formatted: formatUnits(raw, decimals), decimals }; return {
raw,
formatted: formatUnits(raw, decimals),
decimals,
usdPrice: null, // populated post-build via populatePrices()
usdValue: null,
};
}
/**
* Округление USD значения до 8 знаков после запятой (избавляемся от float-мусора).
* S10 — `Infinity`/`NaN` → `null`.
*/
function roundUsd(n: number): number | null {
if (!Number.isFinite(n)) return null;
return Math.round(n * 1e8) / 1e8;
}
/**
* Мутирует BalanceResult, проставляя `usdPrice` и `usdValue` каждому FormattedAmount.
* Никогда не throws — если price oracle упал, поля остаются `null`.
*/
async function populatePrices(result: BalanceResult): Promise<void> {
try {
const pairs: { chain: ChainCode; symbol: string }[] = [
{ chain: result.chain, symbol: result.chain }, // native (BTC/ETH/BSC/TRX/SOL)
];
if (result.tokens) {
for (const sym of Object.keys(result.tokens)) {
pairs.push({ chain: result.chain, symbol: sym });
}
}
const prices = await getPricesBySymbols(pairs);
// Native
const nativeKey = `${result.chain}:${result.chain}`;
const nativePrice = prices.get(nativeKey) ?? null;
result.native.usdPrice = nativePrice;
if (nativePrice != null) {
const formattedNum = Number(result.native.formatted);
result.native.usdValue = Number.isFinite(formattedNum)
? roundUsd(formattedNum * nativePrice)
: null;
}
// Tokens
if (result.tokens) {
for (const [sym, amt] of Object.entries(result.tokens)) {
const key = `${result.chain}:${sym}`;
const p = prices.get(key) ?? null;
amt.usdPrice = p;
if (p != null) {
const fNum = Number(amt.formatted);
amt.usdValue = Number.isFinite(fNum) ? roundUsd(fNum * p) : null;
}
}
}
} catch (err: any) {
// Не валим запрос — balance вернётся без цен.
logger.warn(`populatePrices(${result.chain}) failed: ${err?.message || 'unknown'}`);
}
} }
function fmtTokens( function fmtTokens(
@@ -86,20 +159,23 @@ function fmtTokens(
export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> { export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> {
const nativeDecimals = NATIVE_DECIMALS[chain]; const nativeDecimals = NATIVE_DECIMALS[chain];
let result: BalanceResult;
switch (chain) { switch (chain) {
case 'BTC': case 'BTC':
return { result = {
chain, address, chain, address,
native: fmt(await btcBalance(address), nativeDecimals), native: fmt(await btcBalance(address), nativeDecimals),
}; };
break;
case 'TRX': { case 'TRX': {
const { trx, tokens } = await trxBalance(address); const { trx, tokens } = await trxBalance(address);
const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals])); const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals]));
return { result = {
chain, address, chain, address,
native: fmt(trx, nativeDecimals), native: fmt(trx, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap), tokens: fmtTokens(tokens, decimalsMap),
}; };
break;
} }
case 'BSC': case 'BSC':
case 'ETH': { case 'ETH': {
@@ -111,22 +187,28 @@ export async function getBalance(chain: ChainCode, address: string): Promise<Bal
tokenList.map((t) => ({ symbol: t.symbol, addr: t.contractAddress })), tokenList.map((t) => ({ symbol: t.symbol, addr: t.contractAddress })),
); );
const decimalsMap = Object.fromEntries(tokenList.map((t) => [t.symbol, t.decimals])); const decimalsMap = Object.fromEntries(tokenList.map((t) => [t.symbol, t.decimals]));
return { result = {
chain, address, chain, address,
native: fmt(native, nativeDecimals), native: fmt(native, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap), tokens: fmtTokens(tokens, decimalsMap),
}; };
break;
} }
case 'SOL': { case 'SOL': {
const { native, tokens } = await solBalance(address); const { native, tokens } = await solBalance(address);
const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals])); const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals]));
return { result = {
chain, address, chain, address,
native: fmt(native, nativeDecimals), native: fmt(native, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap), tokens: fmtTokens(tokens, decimalsMap),
}; };
break;
} }
} }
// Populate USD prices (graceful — never throws, fields stay null on failure).
await populatePrices(result);
return result;
} }
async function btcBalance(address: string): Promise<string> { async function btcBalance(address: string): Promise<string> {

View File

@@ -17,7 +17,8 @@
{ "name": "Solana", "description": "Solana swap proxy (Jupiter)" }, { "name": "Solana", "description": "Solana swap proxy (Jupiter)" },
{ "name": "TRON Swap", "description": "TRON swap proxy (SunSwap + FeeSwapRouter)" }, { "name": "TRON Swap", "description": "TRON swap proxy (SunSwap + FeeSwapRouter)" },
{ "name": "BSC", "description": "BSC swap proxy (PancakeSwap V2)" }, { "name": "BSC", "description": "BSC swap proxy (PancakeSwap V2)" },
{ "name": "Relay", "description": "Cross-chain bridges (Relay Protocol)" } { "name": "Relay", "description": "Cross-chain bridges (Relay Protocol)" },
{ "name": "Prices", "description": "USD-цены (CoinGecko + KeyDB cache 5 мин)" }
], ],
"components": { "components": {
"securitySchemes": { "securitySchemes": {
@@ -85,10 +86,45 @@
}, },
"FormattedAmount": { "FormattedAmount": {
"type": "object", "type": "object",
"description": "Сумма с метаданными формата + USD-цена. Поля `usdPrice`/`usdValue` всегда присутствуют, но могут быть `null` если symbol не в registry или upstream price oracle (CoinGecko) недоступен.",
"required": ["raw", "formatted", "decimals", "usdPrice", "usdValue"],
"properties": { "properties": {
"raw": { "type": "string", "description": "Smallest units (wei/sat/sun/lamports), string-encoded BigInt" }, "raw": { "type": "string", "description": "Smallest units (wei/sat/sun/lamports), string-encoded BigInt", "example": "1500000000000000000" },
"formatted": { "type": "string", "description": "Human-readable decimal", "example": "0.003" }, "formatted": { "type": "string", "description": "Human-readable decimal", "example": "1.5" },
"decimals": { "type": "integer", "description": "Decimals of the chain/token", "example": 18 } "decimals": { "type": "integer", "description": "Decimals of the chain/token", "example": 18 },
"usdPrice": {
"type": "number",
"nullable": true,
"description": "Цена 1 целой единицы в USD по данным CoinGecko (cache 5 мин, KeyDB). `null` если symbol не в registry или upstream недоступен.",
"example": 3210.45
},
"usdValue": {
"type": "number",
"nullable": true,
"description": "Совокупная стоимость holding'а в USD = `Number(formatted) × usdPrice`, округлено до 8 знаков. `null` если `usdPrice === null` или результат не finite.",
"example": 4815.675
}
}
},
"PricesResponse": {
"type": "object",
"properties": {
"success": { "type": "boolean", "example": true },
"data": {
"type": "object",
"description": "Map symbol → { usd: price | null }. `null` если symbol whitelist'ed но upstream не вернул котировку.",
"additionalProperties": {
"type": "object",
"properties": {
"usd": { "type": "number", "nullable": true, "example": 67432.12 }
}
},
"example": {
"BTC": { "usd": 67432.12 },
"ETH": { "usd": 3210.45 },
"USDT": { "usd": 1.0 }
}
}
} }
}, },
"BalanceResponse": { "BalanceResponse": {
@@ -255,11 +291,42 @@
"/wallets/{chain}/balance": { "/wallets/{chain}/balance": {
"get": { "get": {
"summary": "Balance for user wallet in chain", "summary": "Balance for user wallet in chain (с USD-ценами)",
"description": "Возвращает количество и USD-стоимость для native монеты + всех известных токенов сети. Каждый `FormattedAmount` содержит `raw` (smallest units), `formatted` (human-readable), `decimals`, `usdPrice` (цена 1 единицы), `usdValue` (стоимость holding'а). Цены — CoinGecko с 5-минутным KeyDB-кэшем. Если упал price oracle — `usdPrice`/`usdValue` = `null`, но количества всё равно возвращаются.\n\n**Пример curl:**\n```\ncurl -H \"Authorization: Bearer $JWT\" https://api.example.com/api/wallets/ETH/balance\n```",
"tags": ["Wallet Ops"], "tags": ["Wallet Ops"],
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }], "parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }],
"responses": { "responses": {
"200": { "description": "Balance", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BalanceResponse" } } } }, "200": {
"description": "Balance + USD prices",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/BalanceResponse" },
"example": {
"success": true,
"data": {
"chain": "ETH",
"address": "0x742d35Cc6634C0532925a3b844Bc9e7595f4F45A",
"native": {
"raw": "1500000000000000000",
"formatted": "1.5",
"decimals": 18,
"usdPrice": 3210.45,
"usdValue": 4815.675
},
"tokens": {
"USDT": { "raw": "1000000", "formatted": "1", "decimals": 6, "usdPrice": 1.0, "usdValue": 1.0 },
"USDC": { "raw": "0", "formatted": "0", "decimals": 6, "usdPrice": 0.9999, "usdValue": 0 },
"DAI": { "raw": "0", "formatted": "0", "decimals": 18, "usdPrice": 0.9998, "usdValue": 0 },
"WBTC": { "raw": "0", "formatted": "0", "decimals": 8, "usdPrice": 67432.12, "usdValue": 0 },
"LINK": { "raw": "0", "formatted": "0", "decimals": 18, "usdPrice": 14.32, "usdValue": 0 },
"UNI": { "raw": "0", "formatted": "0", "decimals": 18, "usdPrice": 8.41, "usdValue": 0 }
}
}
}
}
}
},
"401": { "description": "Not authenticated" },
"404": { "description": "Wallet for this chain not found" }, "404": { "description": "Wallet for this chain not found" },
"502": { "description": "Upstream RPC error" } "502": { "description": "Upstream RPC error" }
} }
@@ -337,8 +404,8 @@
}, },
"/wallets/{chain}/swap": { "/wallets/{chain}/swap": {
"post": { "post": {
"summary": "Custodial chained swap (BSC PancakeSwap / TRX SunSwap / SOL Jupiter)", "summary": "Custodial chained swap (BSC PancakeSwap / TRX SunSwap+FeeSwapRouter / SOL Jupiter)",
"description": "Полностью custodial swap в один HTTP-вызов. Никакого client-side signing. BSC: approve+swap chained (PancakeSwap V2, поддерживает BNB/USDT/USDC/DOGE/WBNB/BUSD). TRX: SunSwap TRX↔USDT. SOL: Jupiter aggregatorюбые mints из registry). Slippage protection — server computes amountOutMin от actual quote с default 50 bps tolerance. Optional Idempotency-Key header для anti double-spend.", "description": "Полностью custodial swap в один HTTP-вызов. Никакого client-side signing.\n\n**BSC** — PancakeSwap V2 approve+swap chained. Пары: BNB/USDT/USDC/DOGE/WBNB/BUSD.\n\n**TRX** — SunSwap V2 через FeeSwapRouter (0.7% fee). Только пары TRX↔USDT. Server делает approve(infinite, FeeSwapRouter) (если allowance < amount) + wait inclusion + swap. 4-layer MITM defense (txID/expiration/type/selector verify) — компрометированный TronGrid не сможет подсунуть `transfer` вместо `swap`.\n\n**SOL** — Jupiter aggregator. Любые mints из registry (USDT/USDC/PUMP/JUP/WIF/POPCAT/TRUMP/PYTH/JTO/W/BONK/ORCA/PENGU/RAY).\n\n**Slippage protection** — server computes `amountOutMin = quote × (10000-slippageBps)/10000` от actual quote (default 50 bps = 0.5%). Клиент НЕ задаёт amountOutMin напрямую (защита от MEV-sandwich). Optional `Idempotency-Key` header для anti double-spend.",
"tags": ["Wallet Ops"], "tags": ["Wallet Ops"],
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["BSC", "TRX", "SOL"] } }], "parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["BSC", "TRX", "SOL"] } }],
"requestBody": { "requestBody": {
@@ -352,11 +419,11 @@
"title": "BSC/TRX swap (symbols)", "title": "BSC/TRX swap (symbols)",
"required": ["from", "to", "amount"], "required": ["from", "to", "amount"],
"properties": { "properties": {
"from": { "type": "string", "description": "BSC: BNB|USDT|USDC|DOGE|WBNB|BUSD; TRX: TRX|USDT" }, "from": { "type": "string", "description": "BSC: BNB|USDT|USDC|DOGE|WBNB|BUSD; TRX: TRX|USDT (только эта пара поддерживается на TRON)" },
"to": { "type": "string" }, "to": { "type": "string" },
"amount": { "type": "string", "description": "Smallest units (wei для 18-dec, sun для TRX 6-dec)" }, "amount": { "type": "string", "description": "Smallest units (wei для 18-dec EVM, sun для TRX 6-dec). Max для TRX = 9_007_199_254_740_991 (~9B TRX)." },
"slippageBps": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "0.01%-10%. Default 50 (0.5%)." }, "slippageBps": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "0.01%-10%. Default 50 (0.5%). Server вычислит amountOutMin сам — клиент НЕ задаёт его напрямую." },
"feeTier": { "type": "string", "enum": ["slow", "normal", "fast"] } "feeTier": { "type": "string", "enum": ["slow", "normal", "fast"], "description": "Только BSC (ETH/BSC). На TRX игнорится." }
} }
}, },
{ {
@@ -619,6 +686,60 @@
"502": { "description": "Relay upstream error" } "502": { "description": "Relay upstream error" }
} }
} }
},
"/prices": {
"get": {
"summary": "USD-цены для списка символов",
"description": "Возвращает котировки USD для указанных символов (max 50). Символы должны быть из реестра поддерживаемых токенов (см. tag описание сетей в /wallets/{chain}/balance). Источник — CoinGecko free API, кэшируется в KeyDB 5 минут.\n\n**Resolution:**\n- Native символ совпадающий с chain code (BTC/ETH/BSC/TRX/SOL) → используется native CoinGecko id.\n- Иначе: ищется в реестре сети из `chain` query param.\n- Если `chain` не задан → fallback порядок ETH → BSC → SOL → TRX → BTC. Первый matched chain wins.\n\n**Безопасность:** symbols whitelisted, никакого user-input в URL CoinGecko (защита от SSRF). Max 50 символов на запрос. Auth обязательна (JWT Bearer или cookie).\n\n**Пример curl:**\n```\ncurl -H \"Authorization: Bearer $JWT\" \"https://api.example.com/api/prices?symbols=BTC,ETH,USDT,SOL,BONK\"\n```",
"tags": ["Prices"],
"parameters": [
{
"name": "symbols",
"in": "query",
"required": true,
"description": "Comma-separated список символов (макс 50). Каждый — `[A-Z0-9]{1,16}`. Только символы из registry: BTC, ETH, BSC, TRX, SOL (native) + USDT, USDC, DAI, WBTC, LINK, UNI, DOGE, WBNB, BUSD, PUMP, JUP, WIF, POPCAT, TRUMP, PYTH, JTO, W, BONK, ORCA, PENGU, RAY.",
"schema": { "type": "string", "example": "BTC,ETH,USDT" }
},
{
"name": "chain",
"in": "query",
"required": false,
"description": "Опционально: для disambiguation если symbol присутствует в нескольких сетях (USDT/USDC). Если не задан — fallback порядок: ETH → BSC → SOL → TRX → BTC.",
"schema": { "$ref": "#/components/schemas/Chain" }
}
],
"responses": {
"200": {
"description": "USD prices",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/PricesResponse" },
"example": {
"success": true,
"data": {
"BTC": { "usd": 67432.12 },
"ETH": { "usd": 3210.45 },
"USDT": { "usd": 1.0 },
"SOL": { "usd": 142.88 },
"BONK": { "usd": 0.00002145 }
}
}
}
}
},
"400": {
"description": "Validation error: пустой/слишком большой/невалидный список, неизвестный chain или unknown symbol",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
},
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"429": { "description": "Rate limit exceeded" },
"502": {
"description": "Upstream price oracle error (CoinGecko)",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
}
}
}
} }
} }
} }