import { Request, Response, Router } from 'express'; import { ethers } from 'ethers'; import { logger } from '../lib/logger'; import { assertUserOwnsAddress } from '../lib/wallet-binding'; const router = Router(); const BSC_RPC = 'https://bsc-dataseed.binance.org'; const BSC_CHAIN_ID = 56; const BSC_TIMEOUT_MS = 15_000; // PancakeSwap V2 Router const PANCAKE_ROUTER = '0x10ED43C718714eb63d5aA57B78B54704E256024E'; const WBNB = '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c'; // Supported tokens const TOKEN_MAP: Record = { BNB: WBNB, USDT: '0x55d398326f99059fF775485246999027B3197955', DOGE: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', }; const TOKEN_DECIMALS: Record = { BNB: 18, USDT: 18, DOGE: 8, }; const ROUTER_ABI = [ 'function getAmountsOut(uint amountIn, address[] calldata path) external view returns (uint[] memory amounts)', 'function swapExactETHForTokensSupportingFeeOnTransferTokens(uint amountOutMin, address[] calldata path, address to, uint deadline) external payable', 'function swapExactTokensForETHSupportingFeeOnTransferTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external', ]; const ERC20_ABI = [ 'function approve(address spender, uint256 amount) external returns (bool)', 'function allowance(address owner, address spender) external view returns (uint256)', ]; router.get('/quote', getSwapQuote); router.post('/build', buildSwapTx); export default router; // ─── GET /quote ─── async function getSwapQuote(req: Request, res: Response) { const from = String(req.query.from || '').toUpperCase(); const to = String(req.query.to || '').toUpperCase(); const amount = String(req.query.amount || ''); if (!TOKEN_MAP[from] || !TOKEN_MAP[to]) { res.status(400).json({ success: false, error: 'Invalid from/to. Supported: BNB, USDT, DOGE' }); return; } if (from === to) { res.status(400).json({ success: false, error: 'from and to must be different' }); return; } const amountBigInt = BigInt(amount || '0'); if (amountBigInt <= 0n) { res.status(400).json({ success: false, error: 'amount must be positive' }); return; } try { const provider = new ethers.providers.StaticJsonRpcProvider(BSC_RPC, BSC_CHAIN_ID); const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider); const path = [TOKEN_MAP[from], TOKEN_MAP[to]]; const amounts: ethers.BigNumber[] = await withTimeout( routerContract.getAmountsOut(amount, path), BSC_TIMEOUT_MS, 'PancakeSwap quote timed out' ); const amountOut = amounts[amounts.length - 1].toString(); res.json({ success: true, amountIn: amountBigInt.toString(), amountOut, from, to, fromDecimals: TOKEN_DECIMALS[from], toDecimals: TOKEN_DECIMALS[to], }); } catch (error) { logger.error(`BSC quote failed: ${(error as any)?.stack || (error as any)?.message}`); res.status(502).json({ success: false, error: 'Failed to get BSC swap quote' }); } } // ─── POST /build ─── async function buildSwapTx(req: Request, res: Response) { const { from, to, amount, amountOutMin, userAddress } = req.body; if (!from || !to || !amount || !amountOutMin || !userAddress) { res.status(400).json({ success: false, error: 'Missing required fields: from, to, amount, amountOutMin, userAddress' }); return; } const fromUpper = String(from).toUpperCase(); const toUpper = String(to).toUpperCase(); if (!TOKEN_MAP[fromUpper] || !TOKEN_MAP[toUpper] || fromUpper === toUpper) { res.status(400).json({ success: false, error: 'Invalid from/to pair' }); return; } if (!ethers.utils.isAddress(userAddress)) { res.status(400).json({ success: false, error: 'Invalid BSC address' }); return; } // C17 — bind userAddress to JWT. Иначе attacker может set userAddress=victim_addr // и output swap'а пойдёт victim'у/контракту-attacker'у (reentrancy vector). const userId = req.auth!.userId; try { await assertUserOwnsAddress(userId, 'BSC', userAddress); } catch (err: any) { res.status(403).json({ success: false, error: err.message }); return; } // Validate amount + amountOutMin: positive integers > 0 (string-encoded BigInt). // "0" truthy bypass — без этого attacker может выставить amountOutMin=0 = 100% slippage // → sandwich attack осушает swap. if (!/^\d+$/.test(String(amount)) || BigInt(amount) <= 0n) { res.status(400).json({ success: false, error: 'Invalid amount (must be positive integer string)' }); return; } if (!/^\d+$/.test(String(amountOutMin)) || BigInt(amountOutMin) <= 0n) { res.status(400).json({ success: false, error: 'Invalid amountOutMin (must be positive; 0 = unlimited slippage)' }); return; } try { const provider = new ethers.providers.StaticJsonRpcProvider(BSC_RPC, BSC_CHAIN_ID); const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider); const deadline = Math.floor(Date.now() / 1000) + 1200; // 20 minutes const path = [TOKEN_MAP[fromUpper], TOKEN_MAP[toUpper]]; const transactions: Array<{ type: string; to: string; data: string; value: string }> = []; if (fromUpper === 'BNB') { // BNB → Token: swapExactETHForTokensSupportingFeeOnTransferTokens const data = routerContract.interface.encodeFunctionData( 'swapExactETHForTokensSupportingFeeOnTransferTokens', [amountOutMin, path, userAddress, deadline] ); transactions.push({ type: 'swap', to: PANCAKE_ROUTER, data, value: amount, // BNB amount in wei }); } else { // Token → BNB: check allowance, build approve if needed, then swap const tokenAddress = TOKEN_MAP[fromUpper]; const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider); const currentAllowance: ethers.BigNumber = await withTimeout( tokenContract.allowance(userAddress, PANCAKE_ROUTER), BSC_TIMEOUT_MS, 'Allowance check timed out' ); if (currentAllowance.lt(ethers.BigNumber.from(amount))) { // Exact-amount approve (НЕ MaxUint256!). Infinite approval = post-tx attack vector: // если router compromised или attacker узнаёт private key позже, attacker дренит // всё что approved. Approve только то что нужно сейчас. const approveData = tokenContract.interface.encodeFunctionData( 'approve', [PANCAKE_ROUTER, amount] ); transactions.push({ type: 'approve', to: tokenAddress, data: approveData, value: '0', }); } // Build swap tx const swapData = routerContract.interface.encodeFunctionData( 'swapExactTokensForETHSupportingFeeOnTransferTokens', [amount, amountOutMin, path, userAddress, deadline] ); transactions.push({ type: 'swap', to: PANCAKE_ROUTER, data: swapData, value: '0', }); } res.json({ success: true, transactions }); } catch (error) { logger.error(`BSC build failed: ${(error as any)?.stack || (error as any)?.message}`); res.status(502).json({ success: false, error: 'Failed to build BSC swap' }); } } // ─── Utils ─── function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); promise .then((value) => { clearTimeout(timeoutId); resolve(value); }) .catch((error) => { clearTimeout(timeoutId); reject(error); }); }); }