219 lines
7.7 KiB
TypeScript
219 lines
7.7 KiB
TypeScript
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); });
|
||
});
|
||
}
|