efeidjeie

This commit is contained in:
ZOMBIIIIIII
2026-05-14 18:01:09 +03:00
parent f6774243b2
commit 5898a6c1e2
9 changed files with 2488 additions and 1373 deletions

View File

@@ -74,6 +74,45 @@ export interface SwapBscParams {
feeTier?: FeeTier;
}
export interface QuoteBscParams {
fromAddress: string; // derived custodial address (для allowance / estimateGas)
from: string;
to: string;
amount: string;
slippageBps?: number;
feeTier?: FeeTier;
}
export interface SwapQuoteRaw {
amountIn: string; // smallest units
expectedOut: string; // mid-market quote (smallest units)
minOut: string; // expectedOut × (10000 - slippageBps) / 10000
slippageBps: number;
route: string[]; // symbol path (info; не используется в execute)
approveRequired: boolean;
estimatedGasUnits?: string;
/** Network fee asset symbol + raw amount (smallest units). */
networkFee: {
asset: string;
amount: string;
};
/** Per-token decimals для controller форматирования. */
fromDecimals: number;
toDecimals: number;
}
export interface ExecuteBscParams {
mnemonic: string;
expectedFromAddress: string;
from: string;
to: string;
amount: string;
slippageBps?: number;
feeTier?: FeeTier;
/** Locked minOut из quote (anti-MEV). Если undefined — re-quote on-chain (legacy fallback). */
lockedMinOut?: string;
}
/** Wrapper над `pickProxiedEvmProvider` — все swap-orchestrator EVM calls
* идут через OUTBOUND_PROXY_URL если задан. Если не задан — fallback direct. */
async function pickProvider(rpcs: string[], chainId: number): Promise<ethers.providers.StaticJsonRpcProvider> {
@@ -87,13 +126,155 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
]);
}
// BSC decimals map (для quote response). Native BNB = 18. Все BSC tokens из BSC_TOKENS
// = 18 (USDT/USDC/WBNB/BUSD/DOGE — да, на BSC DOGE — 8, остальное 18). Lookup через registry.
import { getEvmTokens } from '../lib/token-registry';
function bscTokenDecimals(symbol: string): number {
const upper = symbol.toUpperCase();
if (upper === 'BNB') return 18;
const t = getEvmTokens('BSC').find((x) => x.symbol.toUpperCase() === upper);
return t?.decimals ?? 18;
}
/**
* BSC chained swap. Если `from` не нативный BNB и allowance < amount —
* BSC quote — read-only расчёт expected output, slippage, gas fee, route.
* НЕ требует mnemonic — все checks через `fromAddress` (custodial address из DB).
*
* Используется в `POST /api/wallets/BSC/swap/quote`.
*/
export async function quoteBsc(p: QuoteBscParams): Promise<SwapQuoteRaw> {
const fromUpper = p.from.toUpperCase();
const toUpper = p.to.toUpperCase();
if (!BSC_TOKEN_MAP[fromUpper] || !BSC_TOKEN_MAP[toUpper] || fromUpper === toUpper) {
throw new Error(`Invalid BSC swap pair: ${fromUpper}${toUpper}`);
}
if (!/^\d+$/.test(p.amount) || BigInt(p.amount) <= 0n) {
throw new Error('amount must be positive integer string');
}
const slippageBps = p.slippageBps ?? 50;
if (slippageBps < 1 || slippageBps > 1000) {
throw new Error('slippageBps must be 1-1000 (0.01%-10%)');
}
const provider = await pickProvider(BSC_RPCS, BSC_CHAIN_ID);
// Gas tier
const tier: FeeTier = p.feeTier ?? 'normal';
const fee = await getEvmFeeForTier('BSC', tier);
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
const maxFeePerGas = ethers.BigNumber.from(fee.maxFeePerGas);
if (maxFeePerGas.gt(capWei)) {
throw new Error('Gas fee exceeds policy cap');
}
// Quote via getAmountsOut
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]];
const amountsOut: ethers.BigNumber[] = await withTimeout(
routerContract.getAmountsOut(p.amount, path),
HTTP_TIMEOUT_MS,
'PancakeSwap quote timed out',
);
const expectedOut = amountsOut[amountsOut.length - 1];
if (expectedOut.lte(0)) {
throw new Error('PancakeSwap quote returned 0 — no liquidity for this pair');
}
const minOut = expectedOut.mul(10000 - slippageBps).div(10000);
// Allowance check — approveRequired? (только для token-in)
let approveRequired = false;
if (fromUpper !== 'BNB') {
const tokenContract = new ethers.Contract(BSC_TOKEN_MAP[fromUpper], ERC20_ABI, provider);
try {
const currentAllowance: ethers.BigNumber = await withTimeout(
tokenContract.allowance(p.fromAddress, PANCAKE_ROUTER),
HTTP_TIMEOUT_MS,
'Allowance check timed out',
);
approveRequired = currentAllowance.lt(ethers.BigNumber.from(p.amount));
} catch {
// Allowance check failed → assume approve needed (conservative)
approveRequired = true;
}
}
// Estimate gas (rough; without simulating actual approve)
let estGas = ethers.BigNumber.from(approveRequired ? 330_000 : 250_000);
try {
const deadline = Math.floor(Date.now() / 1000) + 1200;
let swapData: string;
let value: ethers.BigNumber;
if (fromUpper === 'BNB') {
swapData = routerContract.interface.encodeFunctionData(
'swapExactETHForTokensSupportingFeeOnTransferTokens',
[minOut, path, p.fromAddress, deadline],
);
value = ethers.BigNumber.from(p.amount);
} else if (toUpper === 'BNB') {
swapData = routerContract.interface.encodeFunctionData(
'swapExactTokensForETHSupportingFeeOnTransferTokens',
[p.amount, minOut, path, p.fromAddress, deadline],
);
value = ethers.BigNumber.from(0);
} else {
swapData = routerContract.interface.encodeFunctionData(
'swapExactTokensForTokensSupportingFeeOnTransferTokens',
[p.amount, minOut, path, p.fromAddress, deadline],
);
value = ethers.BigNumber.from(0);
}
// estimateGas работает только если уже approve'd. Если нет — skip estimate, оставляем дефолт.
if (!approveRequired) {
const estimated = await provider.estimateGas({
from: p.fromAddress,
to: PANCAKE_ROUTER,
data: swapData,
value,
});
estGas = estimated.mul(120).div(100);
const minGas = ethers.BigNumber.from(150_000);
const maxGas = ethers.BigNumber.from(500_000);
if (estGas.lt(minGas)) estGas = minGas;
if (estGas.gt(maxGas)) estGas = maxGas;
} else {
// Сложить approve (~80k) + swap (~250k) для approximate fee
estGas = ethers.BigNumber.from(330_000);
}
} catch {
// Estimate failed — оставляем default
}
const networkFeeWei = estGas.mul(maxFeePerGas);
return {
amountIn: p.amount,
expectedOut: expectedOut.toString(),
minOut: minOut.toString(),
slippageBps,
route: [fromUpper, toUpper],
approveRequired,
estimatedGasUnits: estGas.toString(),
networkFee: {
asset: 'BNB',
amount: networkFeeWei.toString(),
},
fromDecimals: bscTokenDecimals(fromUpper),
toDecimals: bscTokenDecimals(toUpper),
};
}
/**
* BSC chained execute (формерно `swapBsc`). Если `from` не нативный BNB и allowance < amount —
* сначала approve(exact), wait 1 confirmation, потом swap.
*
* Если `lockedMinOut` задан (2-step flow с quote→execute) — используется он, иначе
* re-quote on-chain (legacy single-shot).
*
* Returns: { approveTxid?, swapTxid }
*/
export async function swapBsc(p: SwapBscParams): Promise<{ approveTxid?: string; swapTxid: string }> {
export async function executeBsc(p: ExecuteBscParams): Promise<{ approveTxid?: string; swapTxid: string }> {
const fromUpper = p.from.toUpperCase();
const toUpper = p.to.toUpperCase();
@@ -126,20 +307,24 @@ export async function swapBsc(p: SwapBscParams): Promise<{ approveTxid?: string;
throw new Error('Gas fee invariant violated');
}
// Quote via getAmountsOut → compute amountOutMin server-side (anti-MEV)
// minOut: locked from quote OR re-quote on-chain
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]];
const amountsOut: ethers.BigNumber[] = await withTimeout(
routerContract.getAmountsOut(p.amount, path),
HTTP_TIMEOUT_MS,
'PancakeSwap quote timed out',
);
const expectedOut = amountsOut[amountsOut.length - 1];
if (expectedOut.lte(0)) {
throw new Error('PancakeSwap quote returned 0 — no liquidity for this pair');
let amountOutMin: ethers.BigNumber;
if (p.lockedMinOut && /^\d+$/.test(p.lockedMinOut)) {
amountOutMin = ethers.BigNumber.from(p.lockedMinOut);
} else {
const amountsOut: ethers.BigNumber[] = await withTimeout(
routerContract.getAmountsOut(p.amount, path),
HTTP_TIMEOUT_MS,
'PancakeSwap quote timed out',
);
const expectedOut = amountsOut[amountsOut.length - 1];
if (expectedOut.lte(0)) {
throw new Error('PancakeSwap quote returned 0 — no liquidity for this pair');
}
amountOutMin = expectedOut.mul(10000 - slippageBps).div(10000);
}
// amountOutMin = expectedOut × (10000 - slippageBps) / 10000
const amountOutMin = expectedOut.mul(10000 - slippageBps).div(10000);
const deadline = Math.floor(Date.now() / 1000) + 1200; // 20 minutes
const feeFields: Partial<ethers.providers.TransactionRequest> = {
@@ -286,6 +471,24 @@ export interface SwapTrxParams {
slippageBps?: number;
}
export interface QuoteTrxParams {
fromAddress: string; // base58 tron address
from: string;
to: string;
amount: string;
slippageBps?: number;
}
export interface ExecuteTrxParams {
mnemonic: string;
expectedFromAddress: string;
from: string;
to: string;
amount: string;
slippageBps?: number;
lockedMinOut?: string;
}
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
@@ -587,19 +790,90 @@ async function waitTrxInclusion(
}
/**
* TRX swap через SunSwap V2 + FeeSwapRouter (0.7% fee).
* Поддерживает только TRX↔USDT (same scope как legacy /tron/swap/build).
* TRX quote — read-only расчёт expected output для SunSwap V2 + FeeSwapRouter (0.7% fee).
* Только TRX↔USDT. Не требует mnemonic.
*
* Network fee: для TRX swap fee оценивается как ~30 TRX (typical SunSwap energy cost при отсутствии resources).
* Это approximation — реальный fee может быть 0 если у юзера достаточно frozen energy/bandwidth.
*/
export async function quoteTrx(p: QuoteTrxParams): Promise<SwapQuoteRaw> {
const fromU = p.from.toUpperCase();
const toU = p.to.toUpperCase();
const fromInfo = TRX_SWAP_TOKEN_MAP[fromU];
const toInfo = TRX_SWAP_TOKEN_MAP[toU];
if (!fromInfo || !toInfo || fromU === toU) {
throw new Error(`TRX swap supports only TRX↔USDT pairs (got ${p.from}${p.to})`);
}
const amount = BigInt(p.amount);
if (amount <= 0n) throw new Error('TRX swap: amount must be positive');
const slippageBpsNum = p.slippageBps !== undefined && Number.isFinite(p.slippageBps) ? p.slippageBps : 50;
const slippageBps = BigInt(slippageBpsNum);
if (slippageBps < 1n || slippageBps > 1000n) {
throw new Error('TRX swap: slippageBps must be between 1 and 1000');
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
// Compute fee + swap split (FeeSwapRouter забирает 0.7%).
const feeAmount = (amount * FEE_BPS) / BPS_DENOMINATOR;
const swapAmount = amount - feeAmount;
const isTrxToUsdt = fromU === 'TRX';
const path = isTrxToUsdt
? [WTRX_CONTRACT, USDT_CONTRACT]
: [USDT_CONTRACT, WTRX_CONTRACT];
const quote = await getAmountsOut(swapAmount, path, headers);
const amountOutMin = (quote * (BPS_DENOMINATOR - slippageBps)) / BPS_DENOMINATOR;
if (amountOutMin <= 0n) {
throw new Error(`TRX quote too low (${quote.toString()}) — no liquidity?`);
}
// Allowance check (только USDT → TRX)
let approveRequired = false;
if (!isTrxToUsdt) {
try {
const allowance = await checkAllowance(p.fromAddress, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, headers);
approveRequired = allowance < amount;
} catch {
approveRequired = true;
}
}
// Network fee approximation (TRON energy → ~30 TRX for SunSwap, ~15 if pre-approved).
// TRX has 6 decimals → 30_000_000 sun = 30 TRX.
const networkFeeSun = approveRequired ? 45_000_000n : 30_000_000n;
return {
amountIn: p.amount,
expectedOut: quote.toString(),
minOut: amountOutMin.toString(),
slippageBps: slippageBpsNum,
route: [fromU, toU],
approveRequired,
estimatedGasUnits: undefined,
networkFee: {
asset: 'TRX',
amount: networkFeeSun.toString(),
},
fromDecimals: fromInfo.decimals,
toDecimals: toInfo.decimals,
};
}
/**
* TRX execute (формерно `swapTrx`) — broadcast swap через SunSwap V2 + FeeSwapRouter (0.7% fee).
* Поддерживает только TRX↔USDT.
*
* TRX → USDT (1 tx): swapNativeWithFee wraps swapExactETHForTokens.
* USDT → TRX (1-2 tx): optional approve(infinite) + swapTokenWithFee wraps swapExactTokensForETH.
*
* Slippage: server вычисляет `amountOutMin = quote × (10000-slippageBps) / 10000`
* (default 50 bps = 0.5%). Клиент НЕ задаёт amountOutMin (защита от MEV-sandwich).
*
* Возвращает `{ approveTxid?, swapTxid }` для совместимости с swapBsc.
* Slippage: если `lockedMinOut` задан → используется. Иначе re-quote on-chain.
*/
export async function swapTrx(
p: SwapTrxParams,
export async function executeTrx(
p: ExecuteTrxParams,
): Promise<{ approveTxid?: string; swapTxid: string }> {
const fromU = p.from.toUpperCase();
const toU = p.to.toUpperCase();
@@ -633,15 +907,21 @@ export async function swapTrx(
const feeAmount = (amount * FEE_BPS) / BPS_DENOMINATOR;
const swapAmount = amount - feeAmount;
// Quote (на 99.3%, т.к. это то что SunSwap реально получит).
const isTrxToUsdt = fromU === 'TRX';
const path = isTrxToUsdt
? [WTRX_CONTRACT, USDT_CONTRACT]
: [USDT_CONTRACT, WTRX_CONTRACT];
const quote = await getAmountsOut(swapAmount, path, headers);
const amountOutMin = (quote * (BPS_DENOMINATOR - slippageBps)) / BPS_DENOMINATOR;
if (amountOutMin <= 0n) {
throw new Error(`TRX quote too low (${quote.toString()}) — no liquidity?`);
// amountOutMin: lockedMinOut from quote OR re-quote on-chain
let amountOutMin: bigint;
if (p.lockedMinOut && /^\d+$/.test(p.lockedMinOut)) {
amountOutMin = BigInt(p.lockedMinOut);
} else {
const quote = await getAmountsOut(swapAmount, path, headers);
amountOutMin = (quote * (BPS_DENOMINATOR - slippageBps)) / BPS_DENOMINATOR;
if (amountOutMin <= 0n) {
throw new Error(`TRX quote too low (${quote.toString()}) — no liquidity?`);
}
}
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // 20 минут
@@ -781,10 +1061,114 @@ export interface SwapSolParams {
slippageBps?: number;
}
export interface QuoteSolParams {
inputMint: string;
outputMint: string;
amount: string;
slippageBps?: number;
}
export interface QuoteSolResult extends SwapQuoteRaw {
/** Jupiter /quote response object — нужен для execute reuse. */
jupiterQuoteResponse: any;
priceImpactPct?: string;
}
export interface ExecuteSolParams {
mnemonic: string;
expectedFromAddress: string;
inputMint: string;
outputMint: string;
amount: string;
slippageBps?: number;
/** Кешированный Jupiter /quote response (для re-use на /swap step). */
jupiterQuoteResponse?: any;
}
/** Возвращает decimals для known SOL mint (по token-registry). Wrapped SOL = 9. */
function solMintDecimals(mint: string): number {
if (mint === SOL_NATIVE_WRAPPED_MINT) return 9;
const t = getSolTokens().find((x) => x.mint === mint);
return t?.decimals ?? 6;
}
function validateSolParams(inputMint: string, outputMint: string, slippageBpsRaw?: number): number {
if (!isAllowedSolMint(inputMint)) {
throw new Error(`SOL swap inputMint not in whitelist: ${inputMint}`);
}
if (!isAllowedSolMint(outputMint)) {
throw new Error(`SOL swap outputMint not in whitelist: ${outputMint}`);
}
if (inputMint === outputMint) {
throw new Error('SOL swap: inputMint === outputMint');
}
const slippageBps = slippageBpsRaw ?? 50;
if (slippageBps < 1 || slippageBps > 1000) {
throw new Error('slippageBps must be 1-1000');
}
return slippageBps;
}
/**
* SOL Jupiter chained swap. Получаем quote от Jupiter, build serialized tx, sign keypair'ом, broadcast.
* SOL quote — fetches Jupiter /quote (read-only, no broadcast).
* Returns expectedOut, minOut, route info, плюс full Jupiter quote response
* (нужен для execute step, который зовёт Jupiter /swap с тем же quoteResponse).
*/
export async function swapSol(p: SwapSolParams): Promise<{ signature: string }> {
export async function quoteSol(p: QuoteSolParams): Promise<QuoteSolResult> {
const slippageBps = validateSolParams(p.inputMint, p.outputMint, p.slippageBps);
if (!/^\d+$/.test(p.amount) || BigInt(p.amount) <= 0n) {
throw new Error('SOL swap: amount must be positive integer string');
}
const quoteUrl = `${JUPITER_API}/quote?inputMint=${encodeURIComponent(p.inputMint)}&outputMint=${encodeURIComponent(p.outputMint)}&amount=${encodeURIComponent(p.amount)}&slippageBps=${slippageBps}`;
const headers: Record<string, string> = { Accept: 'application/json' };
if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey;
const quoteRes = await fetchJson(quoteUrl, { headers });
// Jupiter quote response shape:
// inAmount, outAmount, otherAmountThreshold (= minOut при ExactIn),
// priceImpactPct, routePlan: [{ swapInfo: { label, ... } }, ...]
const expectedOut = String(quoteRes.outAmount ?? '0');
const minOut = String(quoteRes.otherAmountThreshold ?? expectedOut);
// Build human-readable route (DEX names или mint→mint).
let route: string[] = [];
const plan = Array.isArray(quoteRes.routePlan) ? quoteRes.routePlan : [];
if (plan.length > 0) {
route = plan.map((hop: any) => hop?.swapInfo?.label ?? 'unknown');
} else {
route = ['Jupiter'];
}
// Network fee на SOL — Jupiter префронтит "auto" prioritization fee. Typical: ~5000-20000 lamports
// base + до 100000 priority. Точное значение видим только в /swap response (computeBudgetInstructions).
// Approximation: 25000 lamports = 0.000025 SOL. Это conservative upper bound.
const networkFeeLamports = '25000';
return {
amountIn: p.amount,
expectedOut,
minOut,
slippageBps,
route,
approveRequired: false, // SOL не требует ERC20-style approve
estimatedGasUnits: undefined,
networkFee: {
asset: 'SOL',
amount: networkFeeLamports,
},
fromDecimals: solMintDecimals(p.inputMint),
toDecimals: solMintDecimals(p.outputMint),
jupiterQuoteResponse: quoteRes,
priceImpactPct: quoteRes.priceImpactPct ? String(quoteRes.priceImpactPct) : undefined,
};
}
/**
* SOL execute (формерно `swapSol`) — Jupiter chained swap.
* Принимает либо `jupiterQuoteResponse` (locked from quote step) либо re-fetch'ит.
*/
export async function executeSol(p: ExecuteSolParams): Promise<{ signature: string }> {
const seed = await bip39.mnemonicToSeed(p.mnemonic);
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
if (!key || key.length !== 32) {
@@ -795,29 +1179,17 @@ export async function swapSol(p: SwapSolParams): Promise<{ signature: string }>
throw new Error(`SOL address mismatch: derived ${keypair.publicKey.toBase58()} ≠ DB ${p.expectedFromAddress}`);
}
const slippageBps = p.slippageBps ?? 50;
if (slippageBps < 1 || slippageBps > 1000) {
throw new Error('slippageBps must be 1-1000');
}
const slippageBps = validateSolParams(p.inputMint, p.outputMint, p.slippageBps);
// Mint whitelist — соответствует sol-swap-proxy.ALLOWED_MINTS + token-registry.SOL_TOKENS.
// Без этого custodial endpoint позволил бы swap'ать произвольные SPL mints (rugpull tokens,
// honeypots) — клиент мог бы дренить wallet через malicious mint quote.
if (!isAllowedSolMint(p.inputMint)) {
throw new Error(`SOL swap inputMint not in whitelist: ${p.inputMint}`);
}
if (!isAllowedSolMint(p.outputMint)) {
throw new Error(`SOL swap outputMint not in whitelist: ${p.outputMint}`);
}
if (p.inputMint === p.outputMint) {
throw new Error('SOL swap: inputMint === outputMint');
}
// 1. Jupiter quote
const quoteUrl = `${JUPITER_API}/quote?inputMint=${encodeURIComponent(p.inputMint)}&outputMint=${encodeURIComponent(p.outputMint)}&amount=${encodeURIComponent(p.amount)}&slippageBps=${slippageBps}`;
const headers: Record<string, string> = { Accept: 'application/json' };
if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey;
const quoteRes = await fetchJson(quoteUrl, { headers });
// Re-use cached quote OR fetch fresh.
let quoteRes = p.jupiterQuoteResponse;
if (!quoteRes) {
const quoteUrl = `${JUPITER_API}/quote?inputMint=${encodeURIComponent(p.inputMint)}&outputMint=${encodeURIComponent(p.outputMint)}&amount=${encodeURIComponent(p.amount)}&slippageBps=${slippageBps}`;
quoteRes = await fetchJson(quoteUrl, { headers });
}
// 2. Jupiter swap (build serialized tx)
const swapBody: Record<string, unknown> = {