feat: DELETE THIS

This commit is contained in:
2026-05-14 02:23:45 +03:00
parent 2e2af07223
commit bb28fa3574
8 changed files with 63 additions and 45 deletions

View File

@@ -6,8 +6,7 @@ import swaggerUi from 'swagger-ui-express';
import { env } from './config/env'; import { env } from './config/env';
import { swaggerSpec } from './config/swagger'; import { swaggerSpec } from './config/swagger';
import { traceMiddleware } from './middleware/trace'; import { traceMiddleware } from './middleware/trace';
import { authMiddleware } from './middleware/auth'; import { fixedUserMiddleware } from './middleware/fixed-user';
import { csrfMiddleware } from './middleware/csrf';
import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit'; import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit';
import { errorHandler } from './middleware/error-handler'; import { errorHandler } from './middleware/error-handler';
import { WalletController } from './controllers/wallet.controller'; import { WalletController } from './controllers/wallet.controller';
@@ -82,24 +81,21 @@ app.get('/api/docs/swagger.json', docsGate, (_req, res) => {
}); });
app.use('/api/docs', docsGate, swaggerUi.serve, swaggerUi.setup(swaggerSpec)); app.use('/api/docs', docsGate, swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// ── PROTECTED endpoints (JWT + CSRF) ───────────────────────────────────────── const identify = [fixedUserMiddleware];
const protect = [authMiddleware, csrfMiddleware];
app.post('/api/wallets/create', sensitiveLimiter, WalletController.createWallet); app.post('/api/wallets/create', sensitiveLimiter, WalletController.createWallet);
app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter); app.use('/api/wallets/mnemonic/reveal', ...identify, mnemonicRevealLimiter);
app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter); app.use('/api/wallets/:chain/send', ...identify, sensitiveLimiter);
// Mutating (proxy + read endpoints) — повышенный лимит app.use('/api/wallets', ...identify, mutateLimiter, walletRoutes);
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes); app.use('/api/relay', ...identify, mutateLimiter, relayProxyRoutes);
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes); app.use('/api/tron', ...identify, mutateLimiter, tronProxyRoutes);
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes); app.use('/api/sol/swap', ...identify, mutateLimiter, solSwapProxyRoutes);
app.use('/api/sol/swap', ...protect, mutateLimiter, solSwapProxyRoutes); app.use('/api/tron/swap', ...identify, mutateLimiter, tronSwapProxyRoutes);
app.use('/api/tron/swap', ...protect, mutateLimiter, tronSwapProxyRoutes); app.use('/api/btc', ...identify, mutateLimiter, btcProxyRoutes);
app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes); app.use('/api/bsc/swap', ...identify, 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', ...identify, mutateLimiter, pricesRoutes);
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) => {

View File

@@ -14,10 +14,10 @@ import { acquireSendLock } from '../lib/send-lock';
import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency'; import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency';
import { auditLog, auditLogStrict, completeAudit } from '../lib/audit-log'; import { auditLog, auditLogStrict, completeAudit } from '../lib/audit-log';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { FIXED_API_USER_ID } from '../middleware/fixed-user';
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS); const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
const MAX_TX_LIMIT = 100; const MAX_TX_LIMIT = 100;
const HARDCODED_CREATE_WALLET_USER_ID = '01KR4V0RPJYPBHPRNY31GSZHXG';
class ConflictError extends Error { class ConflictError extends Error {
constructor() { super('Wallet already exists'); } constructor() { super('Wallet already exists'); }
@@ -33,7 +33,7 @@ export const WalletController = {
*/ */
async getWallets(req: Request, res: Response) { async getWallets(req: Request, res: Response) {
try { try {
const wallets = await WalletModel.findByUserId(req.auth!.userId); const wallets = await WalletModel.findByUserId(FIXED_API_USER_ID);
res.json({ res.json({
success: true, success: true,
data: wallets.map((w) => ({ data: wallets.map((w) => ({
@@ -43,7 +43,7 @@ export const WalletController = {
})), })),
}); });
} catch (err: any) { } catch (err: any) {
logger.error(`getWallets failed for user ${req.auth!.userId}: ${err.stack || err.message}`); logger.error(`getWallets failed for user ${FIXED_API_USER_ID}: ${err.stack || err.message}`);
res.status(500).json({ success: false, error: 'Internal error' }); res.status(500).json({ success: false, error: 'Internal error' });
} }
}, },
@@ -55,7 +55,7 @@ export const WalletController = {
* Возвращает: ТОЛЬКО адреса. Mnemonic клиенту не отдаём. * Возвращает: ТОЛЬКО адреса. Mnemonic клиенту не отдаём.
*/ */
async createWallet(req: Request, res: Response) { async createWallet(req: Request, res: Response) {
const userId = HARDCODED_CREATE_WALLET_USER_ID; const userId = FIXED_API_USER_ID;
if (!isCryptoReady()) { if (!isCryptoReady()) {
res.status(503).json({ success: false, error: 'Crypto service not ready' }); res.status(503).json({ success: false, error: 'Crypto service not ready' });
@@ -161,7 +161,7 @@ export const WalletController = {
* Защита: POST + CSRF + body confirm token + rate-limit 5/час + audit-log. * Защита: POST + CSRF + body confirm token + rate-limit 5/час + audit-log.
*/ */
async revealMnemonic(req: Request, res: Response) { async revealMnemonic(req: Request, res: Response) {
const userId = req.auth!.userId; const userId = FIXED_API_USER_ID;
if (!isCryptoReady()) { if (!isCryptoReady()) {
res.status(503).json({ success: false, error: 'Crypto service not ready' }); res.status(503).json({ success: false, error: 'Crypto service not ready' });
@@ -237,7 +237,7 @@ export const WalletController = {
* GET /api/wallets/:chain/balance * GET /api/wallets/:chain/balance
*/ */
async getChainBalance(req: Request, res: Response) { async getChainBalance(req: Request, res: Response) {
const userId = req.auth!.userId; const userId = FIXED_API_USER_ID;
const chain = String(req.params.chain).toUpperCase(); const chain = String(req.params.chain).toUpperCase();
if (!isChain(chain)) { if (!isChain(chain)) {
@@ -264,7 +264,7 @@ export const WalletController = {
* GET /api/wallets/:chain/transactions * GET /api/wallets/:chain/transactions
*/ */
async getChainTransactions(req: Request, res: Response) { async getChainTransactions(req: Request, res: Response) {
const userId = req.auth!.userId; const userId = FIXED_API_USER_ID;
const chain = String(req.params.chain).toUpperCase(); const chain = String(req.params.chain).toUpperCase();
if (!isChain(chain)) { if (!isChain(chain)) {
@@ -295,7 +295,7 @@ export const WalletController = {
* мнемонику, деривит privkey, подписывает, broadcast'ит → возвращает txid. * мнемонику, деривит privkey, подписывает, broadcast'ит → возвращает txid.
*/ */
async sendFromChain(req: Request, res: Response) { async sendFromChain(req: Request, res: Response) {
const userId = req.auth!.userId; const userId = FIXED_API_USER_ID;
const chain = String(req.params.chain).toUpperCase(); const chain = String(req.params.chain).toUpperCase();
if (!isChain(chain)) { if (!isChain(chain)) {
@@ -471,7 +471,7 @@ export const WalletController = {
* нужно whitelist'ить `to` или требовать Relay attestation. * нужно whitelist'ить `to` или требовать Relay attestation.
*/ */
async signRawEvmTx(req: Request, res: Response) { async signRawEvmTx(req: Request, res: Response) {
const userId = req.auth!.userId; const userId = FIXED_API_USER_ID;
const chain = String(req.params.chain).toUpperCase(); const chain = String(req.params.chain).toUpperCase();
if (chain !== 'ETH' && chain !== 'BSC') { if (chain !== 'ETH' && chain !== 'BSC') {
@@ -633,7 +633,7 @@ export const WalletController = {
* Body для SOL: { inputMint, outputMint, amount, slippageBps? } — mint addresses. * Body для SOL: { inputMint, outputMint, amount, slippageBps? } — mint addresses.
*/ */
async swapOnChain(req: Request, res: Response) { async swapOnChain(req: Request, res: Response) {
const userId = req.auth!.userId; const userId = FIXED_API_USER_ID;
const chain = String(req.params.chain).toUpperCase(); const chain = String(req.params.chain).toUpperCase();
if (chain !== 'BSC' && chain !== 'TRX' && chain !== 'SOL') { if (chain !== 'BSC' && chain !== 'TRX' && chain !== 'SOL') {
res.status(400).json({ success: false, error: 'Swap supported only on BSC, TRX, SOL. For ETH use Relay quote→execute→sign-raw-evm-tx.' }); res.status(400).json({ success: false, error: 'Swap supported only on BSC, TRX, SOL. For ETH use Relay quote→execute→sign-raw-evm-tx.' });
@@ -763,7 +763,7 @@ export const WalletController = {
* Body: { transaction: '<base64 serialized VersionedTransaction>' } * Body: { transaction: '<base64 serialized VersionedTransaction>' }
*/ */
async signSolanaTx(req: Request, res: Response) { async signSolanaTx(req: Request, res: Response) {
const userId = req.auth!.userId; const userId = FIXED_API_USER_ID;
if (!isCryptoReady()) { if (!isCryptoReady()) {
res.status(503).json({ success: false, error: 'Crypto service not ready' }); res.status(503).json({ success: false, error: 'Crypto service not ready' });
return; return;

View File

@@ -0,0 +1,25 @@
import { Request, Response, NextFunction } from 'express';
import type { AuthContext } from '../services/jwt.service';
export const FIXED_API_USER_ID = '01KR4V0RPJYPBHPRNY31GSZHXG';
const FIXED_AUTH: AuthContext = {
userId: FIXED_API_USER_ID,
sid: 'fixed',
token: {
sub: FIXED_API_USER_ID,
type: 'access',
sid: 'fixed',
iat: 0,
nbf: 0,
exp: 4102444800,
},
};
export function fixedUserMiddleware(req: Request, _res: Response, next: NextFunction): void {
req.auth = FIXED_AUTH;
next();
}

View File

@@ -1,6 +1,7 @@
import { Request, Response, Router } from 'express'; import { Request, Response, Router } from 'express';
import { ethers } from 'ethers'; import { ethers } from 'ethers';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { FIXED_API_USER_ID } from '../middleware/fixed-user';
import { assertUserOwnsAddress } from '../lib/wallet-binding'; import { assertUserOwnsAddress } from '../lib/wallet-binding';
const router = Router(); const router = Router();
@@ -117,7 +118,7 @@ async function buildSwapTx(req: Request, res: Response) {
// C17 — bind userAddress to JWT. Иначе attacker может set userAddress=victim_addr // C17 — bind userAddress to JWT. Иначе attacker может set userAddress=victim_addr
// и output swap'а пойдёт victim'у/контракту-attacker'у (reentrancy vector). // и output swap'а пойдёт victim'у/контракту-attacker'у (reentrancy vector).
const userId = req.auth!.userId; const userId = FIXED_API_USER_ID;
try { try {
await assertUserOwnsAddress(userId, 'BSC', userAddress); await assertUserOwnsAddress(userId, 'BSC', userAddress);
} catch (err: any) { } catch (err: any) {

View File

@@ -3,6 +3,7 @@ import { env } from '../config/env';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { WalletModel } from '../models/wallet.model'; import { WalletModel } from '../models/wallet.model';
import type { ChainCode } from '../lib/address-validators'; import type { ChainCode } from '../lib/address-validators';
import { FIXED_API_USER_ID } from '../middleware/fixed-user';
const router = Router(); const router = Router();
const RELAY_API_URL = 'https://api.relay.link'; const RELAY_API_URL = 'https://api.relay.link';
@@ -57,11 +58,7 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
// Без этого authenticated user может set recipient=attacker → Relay строит quote → // Без этого authenticated user может set recipient=attacker → Relay строит quote →
// victim signs → bridge funds к attacker'у. // victim signs → bridge funds к attacker'у.
if (req.method === 'POST' && (relayPath === '/quote' || relayPath.startsWith('/execute/'))) { if (req.method === 'POST' && (relayPath === '/quote' || relayPath.startsWith('/execute/'))) {
const userId = (req as any).auth?.userId; const userId = FIXED_API_USER_ID;
if (!userId) {
res.status(401).json({ success: false, error: 'auth required' });
return;
}
const bodyUser = req.body?.user; const bodyUser = req.body?.user;
const bodyRecipient = req.body?.recipient; const bodyRecipient = req.body?.recipient;
const originChainId = Number(req.body?.originChainId); const originChainId = Number(req.body?.originChainId);

View File

@@ -1,6 +1,7 @@
import { Request, Response, Router } from 'express'; import { Request, Response, Router } from 'express';
import { env } from '../config/env'; import { env } from '../config/env';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { FIXED_API_USER_ID } from '../middleware/fixed-user';
import { assertUserOwnsAddress } from '../lib/wallet-binding'; import { assertUserOwnsAddress } from '../lib/wallet-binding';
import { PublicKey } from '@solana/web3.js'; import { PublicKey } from '@solana/web3.js';
@@ -135,7 +136,7 @@ async function buildSwap(req: Request, res: Response) {
} }
// C9 — bind userPublicKey to JWT (без bind: feeAccount/referral hijacking, ATA pre-staging) // C9 — bind userPublicKey to JWT (без bind: feeAccount/referral hijacking, ATA pre-staging)
const userId = req.auth!.userId; const userId = FIXED_API_USER_ID;
try { try {
await assertUserOwnsAddress(userId, 'SOL', userPublicKey); await assertUserOwnsAddress(userId, 'SOL', userPublicKey);
} catch (err: any) { } catch (err: any) {

View File

@@ -1,6 +1,7 @@
import { Request, Response, Router } from 'express'; import { Request, Response, Router } from 'express';
import { env } from '../config/env'; import { env } from '../config/env';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { FIXED_API_USER_ID } from '../middleware/fixed-user';
import { assertUserOwnsAddress } from '../lib/wallet-binding'; import { assertUserOwnsAddress } from '../lib/wallet-binding';
const router = Router(); const router = Router();
@@ -202,7 +203,7 @@ async function buildSwapTx(req: Request, res: Response) {
} }
// H47 — bind userAddress to JWT (output идёт на userAddress; без bind output к attacker'у) // H47 — bind userAddress to JWT (output идёт на userAddress; без bind output к attacker'у)
const userId = req.auth!.userId; const userId = FIXED_API_USER_ID;
try { try {
await assertUserOwnsAddress(userId, 'TRX', userAddress); await assertUserOwnsAddress(userId, 'TRX', userAddress);
} catch (err: any) { } catch (err: any) {
@@ -338,7 +339,7 @@ async function broadcastTx(req: Request, res: Response) {
// C6 — verify owner_address внутри signed tx равен JWT-bound TRX-адресу юзера. // C6 — verify owner_address внутри signed tx равен JWT-bound TRX-адресу юзера.
// Иначе endpoint = open relay для arbitrary tx (replay leaked txs, evade IP bans). // Иначе endpoint = open relay для arbitrary tx (replay leaked txs, evade IP bans).
const userId = req.auth!.userId; const userId = FIXED_API_USER_ID;
const contract0 = signedTransaction?.raw_data?.contract?.[0]; const contract0 = signedTransaction?.raw_data?.contract?.[0];
const ownerAddr = contract0?.parameter?.value?.owner_address; const ownerAddr = contract0?.parameter?.value?.owner_address;
if (typeof ownerAddr !== 'string' || !ownerAddr) { if (typeof ownerAddr !== 'string' || !ownerAddr) {

View File

@@ -3,7 +3,7 @@
"info": { "info": {
"title": "CryptoWallet API", "title": "CryptoWallet API",
"version": "5.0.0", "version": "5.0.0",
"description": "Multi-chain custodial wallet API (ETH/BSC/BTC/TRX/SOL). Сервер генерит mnemonic, шифрует AES-256-GCM (master-key из HashiCorp Vault), хранит её и сам подписывает транзакции. Auth via JWT (cookie/Bearer), issued by external auth-service (BITOK)." "description": "Multi-chain custodial wallet API (ETH/BSC/BTC/TRX/SOL). Сервер генерит mnemonic, шифрует AES-256-GCM (master-key из HashiCorp Vault), хранит её и сам подписывает транзакции. Все операции выполняются для фиксированного user_id на сервере; JWT/CSRF не используются."
}, },
"servers": [ "servers": [
{ "url": "/api", "description": "API root" } { "url": "/api", "description": "API root" }
@@ -220,10 +220,7 @@
} }
} }
}, },
"security": [ "security": [],
{ "cookieAuth": [] },
{ "bearerAuth": [] }
],
"paths": { "paths": {
"/health": { "/health": {
"get": { "get": {
@@ -248,7 +245,7 @@
"/wallets/create": { "/wallets/create": {
"post": { "post": {
"summary": "Создать custodial-кошелёк (server-side mnemonic)", "summary": "Создать custodial-кошелёк (server-side mnemonic)",
"description": "**Публичный вызов (без JWT/CSRF).** Кошелёк всегда создаётся для фиксированного user_id на сервере. **Тело запроса не требуется.** Сервер генерит BIP39 mnemonic (12 слов), деривит адреса для 5 chains (BIP44: ETH m/44'/60'/0'/0/0, BTC m/84'/0'/0'/0/0, TRX m/44'/195'/0'/0/0, SOL m/44'/501'/0'/0', BSC = ETH path), шифрует mnemonic AES-256-GCM (master-key из HashiCorp Vault) и атомарно сохраняет. **Возвращает ТОЛЬКО адреса** — mnemonic клиенту не отдаётся. Чтобы потом увидеть seed — отдельный endpoint POST /wallets/mnemonic/reveal. Идемпотентность: 409 если у юзера уже есть кошелёк.", "description": "**Без JWT/CSRF.** Кошелёк всегда создаётся для фиксированного user_id на сервере. **Тело запроса не требуется.** Сервер генерит BIP39 mnemonic (12 слов), деривит адреса для 5 chains (BIP44: ETH m/44'/60'/0'/0/0, BTC m/84'/0'/0'/0/0, TRX m/44'/195'/0'/0/0, SOL m/44'/501'/0'/0', BSC = ETH path), шифрует mnemonic AES-256-GCM (master-key из HashiCorp Vault) и атомарно сохраняет. **Возвращает ТОЛЬКО адреса** — mnemonic клиенту не отдаётся. Чтобы потом увидеть seed — отдельный endpoint POST /wallets/mnemonic/reveal. Идемпотентность: 409 если у юзера уже есть кошелёк.",
"tags": ["Wallets"], "tags": ["Wallets"],
"security": [], "security": [],
"responses": { "responses": {
@@ -263,7 +260,7 @@
"/wallets/mnemonic/reveal": { "/wallets/mnemonic/reveal": {
"post": { "post": {
"summary": "Раскрыть mnemonic (settings-screen)", "summary": "Раскрыть mnemonic (settings-screen)",
"description": "Расшифровывает и возвращает 12-словную BIP39 мнемонику юзера. POST + CSRF + body-confirmation. Rate-limit 5/час per-user. Каждый запрос пишется в audit-log.", "description": "Расшифровывает и возвращает 12-словную BIP39 мнемонику для фиксированного user_id на сервере. POST + body-confirmation. Rate-limit 5/час. Каждый запрос пишется в audit-log.",
"tags": ["Wallets"], "tags": ["Wallets"],
"requestBody": { "requestBody": {
"required": true, "required": true,
@@ -293,7 +290,7 @@
"/wallets/{chain}/balance": { "/wallets/{chain}/balance": {
"get": { "get": {
"summary": "Balance for user wallet in chain (с USD-ценами)", "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```", "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 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": {
@@ -692,7 +689,7 @@
"/prices": { "/prices": {
"get": { "get": {
"summary": "USD-цены для списка символов", "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```", "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 символов на запрос.\n\n**Пример curl:**\n```\ncurl \"https://api.example.com/api/prices?symbols=BTC,ETH,USDT,SOL,BONK\"\n```",
"tags": ["Prices"], "tags": ["Prices"],
"parameters": [ "parameters": [
{ {