diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 86c3042..3ab41a1 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ import { authMiddleware } from './middleware/auth'; import { csrfMiddleware } from './middleware/csrf'; import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit'; import { errorHandler } from './middleware/error-handler'; +import { generateCsrfToken } from './services/csrf.service'; import walletRoutes from './routes/wallet.routes'; import relayProxyRoutes from './routes/relay-proxy.routes'; import jumperProxyRoutes from './routes/jumper-proxy.routes'; @@ -41,6 +42,7 @@ app.use( origin: corsIsWildcard ? '*' : (corsOrigins.length > 0 ? corsOrigins : false), // Wildcard incompatible с credentials per browser spec — force false при wildcard. credentials: corsIsWildcard ? false : env.cors.allowCredentials, + exposedHeaders: ['X-CSRF-Token', 'X-Trace-ID'], }), ); app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS @@ -65,6 +67,30 @@ app.get('/api/health', async (_req, res) => { // ── Глобальный rate limit на /api/* — ДО docs чтобы не было unauthenticated DoS на swagger.json app.use('/api', globalLimiter); +app.get('/api/csrf', (_req, res) => { + if (!env.vault.csrfPath) { + res.json({ success: true, data: { enabled: false, csrfToken: null } }); + return; + } + + try { + const { token, maxAgeSec } = generateCsrfToken(); + const crossSiteCookie = !corsIsWildcard && env.cors.allowCredentials; + res.cookie('csrf_token', token, { + httpOnly: false, + secure: crossSiteCookie, + sameSite: crossSiteCookie ? 'none' : 'lax', + maxAge: maxAgeSec * 1000, + path: '/', + }); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-CSRF-Token', token); + res.json({ success: true, data: { enabled: true, csrfToken: token, maxAgeSec } }); + } catch (err: any) { + res.status(503).json({ success: false, error: err.message || 'CSRF token unavailable' }); + } +}); + // H1 — Swagger gated. В production требуется basic-auth ИЛИ NODE_ENV != production. // JSON endpoint ОБЯЗАТЕЛЬНО до app.use('/api/docs', ...) — иначе swagger-ui-express // перехватывает все /api/docs/* и возвращает HTML вместо JSON. diff --git a/apps/api/src/routes/tron-proxy.routes.ts b/apps/api/src/routes/tron-proxy.routes.ts index 46b9172..4b5e529 100644 --- a/apps/api/src/routes/tron-proxy.routes.ts +++ b/apps/api/src/routes/tron-proxy.routes.ts @@ -209,6 +209,7 @@ const ALLOWED_TRC_FUNCTIONS = new Set([ 'allowance(address,address)', 'swapExactETHForTokens(uint256,address[],address,uint256)', 'swapExactTokensForETH(uint256,uint256,address[],address,uint256)', + 'swapExactTokensForETHSupportingFeeOnTransferTokens(uint256,uint256,address[],address,uint256)', 'swapNativeWithFee(bytes)', 'swapTokenWithFee(address,uint256,bytes)', 'getAmountsOut(uint256,address[])', diff --git a/apps/api/src/services/csrf.service.ts b/apps/api/src/services/csrf.service.ts index b0d79a0..f58a58e 100644 --- a/apps/api/src/services/csrf.service.ts +++ b/apps/api/src/services/csrf.service.ts @@ -36,6 +36,10 @@ export function isCsrfConfigured(): boolean { return current !== null; } +function b64urlEncode(buf: Buffer): string { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + /** * Pre-fetch CSRF config из Vault — НЕ мутирует глобал, возвращает новый объект. */ @@ -95,11 +99,38 @@ function decodeTimestamp(encoded: string): number { return ts + ITSDANGEROUS_EPOCH; } +function encodeTimestamp(unixSeconds: number): string { + let ts = unixSeconds - ITSDANGEROUS_EPOCH; + const bytes: number[] = []; + do { + bytes.unshift(ts & 0xff); + ts = Math.floor(ts / 256); + } while (ts > 0); + return b64urlEncode(Buffer.from(bytes)); +} + export interface CsrfVerifyResult { valid: boolean; reason?: string; } +export function generateCsrfToken(): { token: string; maxAgeSec: number } { + if (!current) { + throw new Error('CSRF secret not loaded'); + } + + const payload = b64urlEncode(crypto.randomBytes(32)); + const timestamp = encodeTimestamp(Math.floor(Date.now() / 1000)); + const payloadTs = `${payload}.${timestamp}`; + const derived = deriveKey(current.secret, current.salt, current.digest); + const signature = b64urlEncode(crypto.createHmac(current.digest, derived).update(payloadTs).digest()); + + return { + token: `${payloadTs}.${signature}`, + maxAgeSec: current.maxAgeSec, + }; +} + export function verifyCsrfToken(token: string): CsrfVerifyResult { if (!current) return { valid: false, reason: 'CSRF secret not loaded' }; if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' }; diff --git a/apps/api/src/services/swap-orchestrator.service.ts b/apps/api/src/services/swap-orchestrator.service.ts index 81a6dd7..ab9adbb 100644 --- a/apps/api/src/services/swap-orchestrator.service.ts +++ b/apps/api/src/services/swap-orchestrator.service.ts @@ -526,7 +526,7 @@ const TRX_BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrs // 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_EXACT_TOKENS_FOR_ETH_SUPPORTING_FEE = '791ac947'; const SEL_SWAP_NATIVE_WITH_FEE = '152dad1d'; const SEL_SWAP_TOKEN_WITH_FEE = 'e8d1f203'; @@ -617,8 +617,8 @@ function buildSwapExactETHForTokensCalldata( encAddr(to) + encU256(deadline) + pathLen + pathElements; } -// function swapExactTokensForETH(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline) -function buildSwapExactTokensForETHCalldata( +// function swapExactTokensForETHSupportingFeeOnTransferTokens(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline) +function buildSwapExactTokensForETHSupportingFeeCalldata( amountIn: bigint, amountOutMin: bigint, path: string[], @@ -628,7 +628,7 @@ function buildSwapExactTokensForETHCalldata( 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) + + return SEL_SWAP_EXACT_TOKENS_FOR_ETH_SUPPORTING_FEE + encU256(amountIn) + encU256(amountOutMin) + offsetToPath + encAddr(to) + encU256(deadline) + pathLen + pathElements; } @@ -936,7 +936,7 @@ export async function quoteTrx(p: QuoteTrxParams): Promise { * Поддерживает только TRX↔USDT. * * TRX → USDT (1 tx): swapNativeWithFee wraps swapExactETHForTokens. - * USDT → TRX (1-2 tx): optional approve(infinite) + swapTokenWithFee wraps swapExactTokensForETH. + * USDT → TRX (1-2 tx): optional approve(infinite) + swapTokenWithFee wraps supporting-fee swapExactTokensForETH. * * Slippage: если `lockedMinOut` задан → используется. Иначе re-quote on-chain. */ @@ -1059,7 +1059,7 @@ export async function executeTrx( } // Step 2: build swapTokenWithFee(USDT, amount, calldata). - const sunswapCalldata = buildSwapExactTokensForETHCalldata( + const sunswapCalldata = buildSwapExactTokensForETHSupportingFeeCalldata( swapAmount, amountOutMin, path, fromTronAddr, deadline, ); const tokenInEnc = encAddr(USDT_CONTRACT);