Files
cryptowallet/apps/api/src/routes/bsc-swap-proxy.routes.ts
2026-05-13 00:17:32 +03:00

219 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
BNB: WBNB,
USDT: '0x55d398326f99059fF775485246999027B3197955',
DOGE: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
};
const TOKEN_DECIMALS: Record<string, number> = {
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<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => { clearTimeout(timeoutId); resolve(value); })
.catch((error) => { clearTimeout(timeoutId); reject(error); });
});
}