efeidjeie
This commit is contained in:
@@ -13,10 +13,7 @@ import { errorHandler } from './middleware/error-handler';
|
|||||||
import walletRoutes from './routes/wallet.routes';
|
import walletRoutes from './routes/wallet.routes';
|
||||||
import relayProxyRoutes from './routes/relay-proxy.routes';
|
import relayProxyRoutes from './routes/relay-proxy.routes';
|
||||||
import tronProxyRoutes from './routes/tron-proxy.routes';
|
import tronProxyRoutes from './routes/tron-proxy.routes';
|
||||||
import solSwapProxyRoutes from './routes/sol-swap-proxy.routes';
|
|
||||||
import tronSwapProxyRoutes from './routes/tron-swap-proxy.routes';
|
|
||||||
import btcProxyRoutes from './routes/btc-proxy.routes';
|
import btcProxyRoutes from './routes/btc-proxy.routes';
|
||||||
import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes';
|
|
||||||
import pricesRoutes from './routes/prices.routes';
|
import pricesRoutes from './routes/prices.routes';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -105,10 +102,9 @@ app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter);
|
|||||||
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
|
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
|
||||||
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes);
|
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes);
|
||||||
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes);
|
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes);
|
||||||
app.use('/api/sol/swap', ...protect, mutateLimiter, solSwapProxyRoutes);
|
|
||||||
app.use('/api/tron/swap', ...protect, mutateLimiter, tronSwapProxyRoutes);
|
|
||||||
app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes);
|
app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes);
|
||||||
app.use('/api/bsc/swap', ...protect, mutateLimiter, bscSwapProxyRoutes);
|
// Legacy non-custodial swap proxies (/api/bsc/swap, /api/sol/swap, /api/tron/swap)
|
||||||
|
// УДАЛЕНЫ. Custodial 2-step swap живёт под /api/wallets/{chain}/swap{,/quote}.
|
||||||
|
|
||||||
// USD-цены (CoinGecko + KeyDB cache). GET-only, auth required, max 50 symbols.
|
// USD-цены (CoinGecko + KeyDB cache). GET-only, auth required, max 50 symbols.
|
||||||
app.use('/api/prices', ...protect, mutateLimiter, pricesRoutes);
|
app.use('/api/prices', ...protect, mutateLimiter, pricesRoutes);
|
||||||
|
|||||||
@@ -7,7 +7,23 @@ import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validat
|
|||||||
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
|
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
|
||||||
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service';
|
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service';
|
||||||
import { signAndBroadcast, signAndBroadcastRawEvm, signAndBroadcastSolanaTx, signAndBroadcastSolanaInstructions } from '../services/wallet-signer.service';
|
import { signAndBroadcast, signAndBroadcastRawEvm, signAndBroadcastSolanaTx, signAndBroadcastSolanaInstructions } from '../services/wallet-signer.service';
|
||||||
import { swapBsc, swapTrx, swapSol } from '../services/swap-orchestrator.service';
|
import {
|
||||||
|
quoteBsc, executeBsc,
|
||||||
|
quoteTrx, executeTrx,
|
||||||
|
quoteSol, executeSol,
|
||||||
|
type SwapQuoteRaw, type QuoteSolResult,
|
||||||
|
} from '../services/swap-orchestrator.service';
|
||||||
|
import { saveQuote, getQuote, deleteQuote, QUOTE_TTL_SECONDS, type CachedSwapQuote } from '../lib/swap-quote-cache';
|
||||||
|
import { generateUlid } from '../utils/ulid';
|
||||||
|
import { getPricesBySymbols } from '../services/price-oracle.service';
|
||||||
|
import { SOL_TOKENS } from '../lib/token-registry';
|
||||||
|
|
||||||
|
const SOL_WRAPPED_NATIVE_MINT = 'So11111111111111111111111111111111111111112';
|
||||||
|
function solMintToSymbol(mint: string): string | null {
|
||||||
|
if (mint === SOL_WRAPPED_NATIVE_MINT) return 'SOL';
|
||||||
|
const t = SOL_TOKENS.find((x) => x.mint === mint);
|
||||||
|
return t?.symbol ?? null;
|
||||||
|
}
|
||||||
import { getEvmFeeTiers, type FeeTier } from '../services/gas-oracle.service';
|
import { getEvmFeeTiers, type FeeTier } from '../services/gas-oracle.service';
|
||||||
import { applyEvmTxPolicy } from '../lib/evm-tx-policy';
|
import { applyEvmTxPolicy } from '../lib/evm-tx-policy';
|
||||||
import { getRelayTrustedAddresses } from '../lib/relay-trusted-cache';
|
import { getRelayTrustedAddresses } from '../lib/relay-trusted-cache';
|
||||||
@@ -651,13 +667,212 @@ export const WalletController = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/wallets/:chain/swap — chained custodial swap.
|
* POST /api/wallets/:chain/swap/quote — preview расчёт ПЕРЕД execute.
|
||||||
* BSC: PancakeSwap V2 — approve (если token-to-anything) + swap, sign+broadcast в одном вызове.
|
|
||||||
* TRX: SunSwap — build + sign + broadcast (TRX↔USDT).
|
|
||||||
* SOL: Jupiter — quote + swap + sign + broadcast.
|
|
||||||
*
|
*
|
||||||
* Body для BSC/TRX: { from, to, amount, slippageBps?, feeTier? } — symbols (BNB, USDT, DOGE, USDC).
|
* Read-only — НЕ broadcast'ит ничего. Возвращает quoteId (ULID), expectedOut,
|
||||||
* Body для SOL: { inputMint, outputMint, amount, slippageBps? } — mint addresses.
|
* minOut, slippage, network fee, approveRequired, route. Quote сохраняется в KeyDB
|
||||||
|
* с TTL 30s — затем юзер шлёт POST /:chain/swap с {quoteId} для execute.
|
||||||
|
*
|
||||||
|
* Body (BSC/TRX): { from, to, amount, slippageBps?, feeTier? } — symbols.
|
||||||
|
* Body (SOL): { inputMint, outputMint, amount, slippageBps? } — mints.
|
||||||
|
*/
|
||||||
|
async quoteSwap(req: Request, res: Response) {
|
||||||
|
const userId = req.auth!.userId;
|
||||||
|
const chain = String(req.params.chain).toUpperCase() as 'BSC' | 'TRX' | 'SOL';
|
||||||
|
if (chain !== 'BSC' && chain !== 'TRX' && chain !== 'SOL') {
|
||||||
|
res.status(400).json({ success: false, error: 'Quote supported only on BSC, TRX, SOL.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wallet = await WalletModel.findByUserAndChain(userId, chain as ChainCode);
|
||||||
|
if (!wallet) {
|
||||||
|
res.status(404).json({ success: false, error: 'Wallet not found for this chain' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: SwapQuoteRaw | QuoteSolResult;
|
||||||
|
let cacheParams: CachedSwapQuote['params'];
|
||||||
|
let lockedJupiterQuote: any | undefined;
|
||||||
|
|
||||||
|
if (chain === 'BSC') {
|
||||||
|
const { from, to, amount, slippageBps, feeTier } = req.body ?? {};
|
||||||
|
if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') {
|
||||||
|
res.status(400).json({ success: false, error: 'BSC quote body: {from, to, amount} required as strings' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
raw = await quoteBsc({
|
||||||
|
fromAddress: wallet.address,
|
||||||
|
from, to, amount,
|
||||||
|
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
||||||
|
feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined,
|
||||||
|
});
|
||||||
|
cacheParams = {
|
||||||
|
from, to, amount,
|
||||||
|
slippageBps: raw.slippageBps,
|
||||||
|
feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined,
|
||||||
|
};
|
||||||
|
} else if (chain === 'TRX') {
|
||||||
|
const { from, to, amount, slippageBps } = req.body ?? {};
|
||||||
|
if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') {
|
||||||
|
res.status(400).json({ success: false, error: 'TRX quote body: {from, to, amount} required as strings' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
raw = await quoteTrx({
|
||||||
|
fromAddress: wallet.address,
|
||||||
|
from, to, amount,
|
||||||
|
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
||||||
|
});
|
||||||
|
cacheParams = { from, to, amount, slippageBps: raw.slippageBps };
|
||||||
|
} else {
|
||||||
|
const { inputMint, outputMint, amount, slippageBps } = req.body ?? {};
|
||||||
|
if (typeof inputMint !== 'string' || typeof outputMint !== 'string' || typeof amount !== 'string') {
|
||||||
|
res.status(400).json({ success: false, error: 'SOL quote body: {inputMint, outputMint, amount} required as strings' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const solQuote = await quoteSol({
|
||||||
|
inputMint, outputMint, amount,
|
||||||
|
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
||||||
|
});
|
||||||
|
raw = solQuote;
|
||||||
|
lockedJupiterQuote = solQuote.jupiterQuoteResponse;
|
||||||
|
cacheParams = { inputMint, outputMint, amount, slippageBps: solQuote.slippageBps };
|
||||||
|
}
|
||||||
|
|
||||||
|
// USD enrichment (graceful — null если CoinGecko недоступен).
|
||||||
|
// BSC/TRX use route symbols; SOL uses 'SOL' for native + first hop label as fallback.
|
||||||
|
let fromSymbol: string;
|
||||||
|
let toSymbol: string;
|
||||||
|
if (chain === 'SOL') {
|
||||||
|
// Для SOL route — DEX labels, не coin symbols. Используем mint→symbol lookup из registry.
|
||||||
|
const { inputMint, outputMint } = req.body ?? {};
|
||||||
|
fromSymbol = solMintToSymbol(inputMint) || 'SOL';
|
||||||
|
toSymbol = solMintToSymbol(outputMint) || 'SOL';
|
||||||
|
} else {
|
||||||
|
fromSymbol = raw.route[0];
|
||||||
|
toSymbol = raw.route[raw.route.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceMap = await getPricesBySymbols([
|
||||||
|
{ chain, symbol: fromSymbol },
|
||||||
|
{ chain, symbol: toSymbol },
|
||||||
|
{ chain, symbol: raw.networkFee.asset },
|
||||||
|
]).catch(() => new Map<string, number | null>());
|
||||||
|
|
||||||
|
const fromPriceUsd = priceMap.get(`${chain}:${fromSymbol}`) ?? null;
|
||||||
|
const toPriceUsd = priceMap.get(`${chain}:${toSymbol}`) ?? null;
|
||||||
|
const feePriceUsd = priceMap.get(`${chain}:${raw.networkFee.asset}`) ?? null;
|
||||||
|
|
||||||
|
// Decimal formatting helper.
|
||||||
|
const fmtUnits = (raw: string, decimals: number): string => {
|
||||||
|
try {
|
||||||
|
const big = BigInt(raw);
|
||||||
|
const divisor = 10n ** BigInt(decimals);
|
||||||
|
const whole = big / divisor;
|
||||||
|
const frac = big % divisor;
|
||||||
|
if (frac === 0n) return whole.toString();
|
||||||
|
const fracStr = frac.toString().padStart(decimals, '0').replace(/0+$/, '');
|
||||||
|
return `${whole}.${fracStr}`;
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const toUsd = (rawAmount: string, decimals: number, price: number | null): number | null => {
|
||||||
|
if (price == null) return null;
|
||||||
|
try {
|
||||||
|
const big = BigInt(rawAmount);
|
||||||
|
const divisor = 10n ** BigInt(decimals);
|
||||||
|
// BigInt → Number conversion для USD (USD precision 6 sig figs достаточно для UI).
|
||||||
|
const whole = Number(big / divisor);
|
||||||
|
const frac = Number(big % divisor) / Number(divisor);
|
||||||
|
return (whole + frac) * price;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const networkFeeAssetDecimals = raw.networkFee.asset === 'TRX' ? 6
|
||||||
|
: raw.networkFee.asset === 'SOL' ? 9
|
||||||
|
: 18; // ETH/BSC native
|
||||||
|
const networkFeeUsd = toUsd(raw.networkFee.amount, networkFeeAssetDecimals, feePriceUsd);
|
||||||
|
|
||||||
|
const quoteId = `q_${generateUlid()}`;
|
||||||
|
const expiresIn = QUOTE_TTL_SECONDS;
|
||||||
|
const expiresAt = Date.now() + expiresIn * 1000;
|
||||||
|
|
||||||
|
const preview = {
|
||||||
|
quoteId,
|
||||||
|
expiresIn,
|
||||||
|
expiresAt,
|
||||||
|
chain,
|
||||||
|
|
||||||
|
amountIn: raw.amountIn,
|
||||||
|
amountInFormatted: fmtUnits(raw.amountIn, raw.fromDecimals),
|
||||||
|
amountInUsd: toUsd(raw.amountIn, raw.fromDecimals, fromPriceUsd),
|
||||||
|
|
||||||
|
expectedOut: raw.expectedOut,
|
||||||
|
expectedOutFormatted: fmtUnits(raw.expectedOut, raw.toDecimals),
|
||||||
|
expectedOutUsd: toUsd(raw.expectedOut, raw.toDecimals, toPriceUsd),
|
||||||
|
|
||||||
|
minOut: raw.minOut,
|
||||||
|
minOutFormatted: fmtUnits(raw.minOut, raw.toDecimals),
|
||||||
|
|
||||||
|
slippageBps: raw.slippageBps,
|
||||||
|
priceImpactPct: (raw as QuoteSolResult).priceImpactPct ?? null,
|
||||||
|
|
||||||
|
fees: {
|
||||||
|
network: {
|
||||||
|
asset: raw.networkFee.asset,
|
||||||
|
amount: raw.networkFee.amount,
|
||||||
|
amountFormatted: fmtUnits(raw.networkFee.amount, networkFeeAssetDecimals),
|
||||||
|
amountUsd: networkFeeUsd,
|
||||||
|
},
|
||||||
|
// dex fee включён в expectedOut (Pancake 0.25%, SunSwap 0.3%+0.7% fee router, Jupiter platform varied).
|
||||||
|
// Не вычисляем отдельно — слишком много moving parts.
|
||||||
|
total: { amountUsd: networkFeeUsd },
|
||||||
|
},
|
||||||
|
|
||||||
|
route: raw.route,
|
||||||
|
approveRequired: raw.approveRequired,
|
||||||
|
estimatedGasUnits: raw.estimatedGasUnits ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cached: CachedSwapQuote = {
|
||||||
|
quoteId,
|
||||||
|
userId,
|
||||||
|
chain,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt,
|
||||||
|
params: cacheParams,
|
||||||
|
locked: {
|
||||||
|
expectedOut: raw.expectedOut,
|
||||||
|
minOut: raw.minOut,
|
||||||
|
jupiterQuoteResponse: lockedJupiterQuote,
|
||||||
|
},
|
||||||
|
preview,
|
||||||
|
};
|
||||||
|
|
||||||
|
const saved = await saveQuote(cached, expiresIn);
|
||||||
|
if (!saved) {
|
||||||
|
res.status(503).json({ success: false, error: 'Quote cache unavailable — try again' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: preview });
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`quoteSwap failed for user ${userId} chain ${chain}: ${err.stack || err.message}`);
|
||||||
|
res.status(502).json({ success: false, error: err?.message?.slice?.(0, 250) || 'Quote failed' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/wallets/:chain/swap — execute swap с locked params из quote.
|
||||||
|
* Body: { quoteId } — обязательно. Quote должен быть запрошен через `/swap/quote` ранее (TTL 30s).
|
||||||
|
* Returns: { approveTxid?, swapTxid | signature }.
|
||||||
|
*
|
||||||
|
* Legacy fallback: если body содержит {from, to, amount,...} вместо {quoteId} —
|
||||||
|
* выполнит execute в режиме re-quote on-chain (без anti-MEV gate). Хранится для
|
||||||
|
* backwards-compat. Web UI должен идти через 2-step flow.
|
||||||
*/
|
*/
|
||||||
async swapOnChain(req: Request, res: Response) {
|
async swapOnChain(req: Request, res: Response) {
|
||||||
const userId = req.auth!.userId;
|
const userId = req.auth!.userId;
|
||||||
@@ -686,6 +901,21 @@ export const WalletController = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read quote from cache (preferred 2-step path).
|
||||||
|
const { quoteId } = req.body ?? {};
|
||||||
|
let cachedQuote: CachedSwapQuote | null = null;
|
||||||
|
if (typeof quoteId === 'string' && quoteId.length > 0) {
|
||||||
|
cachedQuote = await getQuote(userId, quoteId);
|
||||||
|
if (!cachedQuote) {
|
||||||
|
res.status(410).json({ success: false, error: 'Quote expired or not found — request a new one via POST /:chain/swap/quote' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cachedQuote.chain !== chain) {
|
||||||
|
res.status(400).json({ success: false, error: `Quote chain mismatch: quote=${cachedQuote.chain} ≠ url=${chain}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const releaseLock = await acquireSendLock(userId, chain);
|
const releaseLock = await acquireSendLock(userId, chain);
|
||||||
let mnemonic: string | null = null;
|
let mnemonic: string | null = null;
|
||||||
let auditId: string;
|
let auditId: string;
|
||||||
@@ -706,7 +936,7 @@ export const WalletController = {
|
|||||||
event: 'wallet.swap',
|
event: 'wallet.swap',
|
||||||
userId,
|
userId,
|
||||||
ip: req.ip || null,
|
ip: req.ip || null,
|
||||||
meta: { chain, body: req.body },
|
meta: { chain, quoteId: cachedQuote?.quoteId, body: cachedQuote ? undefined : req.body },
|
||||||
});
|
});
|
||||||
} catch (auditErr: any) {
|
} catch (auditErr: any) {
|
||||||
logger.error(`Audit DB INSERT MUST succeed for wallet.swap: ${auditErr.message}`);
|
logger.error(`Audit DB INSERT MUST succeed for wallet.swap: ${auditErr.message}`);
|
||||||
@@ -719,39 +949,47 @@ export const WalletController = {
|
|||||||
let result: any;
|
let result: any;
|
||||||
try {
|
try {
|
||||||
if (chain === 'BSC') {
|
if (chain === 'BSC') {
|
||||||
const { from, to, amount, slippageBps, feeTier } = req.body ?? {};
|
const params = cachedQuote
|
||||||
|
? cachedQuote.params
|
||||||
|
: req.body ?? {};
|
||||||
|
const { from, to, amount, slippageBps, feeTier } = params;
|
||||||
if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') {
|
if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') {
|
||||||
throw new Error('BSC swap body: {from, to, amount} required as strings');
|
throw new Error('BSC swap body: {quoteId} OR {from, to, amount} required');
|
||||||
}
|
}
|
||||||
result = await swapBsc({
|
result = await executeBsc({
|
||||||
mnemonic,
|
mnemonic,
|
||||||
expectedFromAddress: wallet.address,
|
expectedFromAddress: wallet.address,
|
||||||
from, to, amount,
|
from, to, amount,
|
||||||
slippageBps,
|
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
||||||
feeTier,
|
feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined,
|
||||||
|
lockedMinOut: cachedQuote?.locked.minOut,
|
||||||
});
|
});
|
||||||
} else if (chain === 'TRX') {
|
} else if (chain === 'TRX') {
|
||||||
const { from, to, amount, slippageBps } = req.body ?? {};
|
const params = cachedQuote ? cachedQuote.params : req.body ?? {};
|
||||||
|
const { from, to, amount, slippageBps } = params;
|
||||||
if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') {
|
if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') {
|
||||||
throw new Error('TRX swap body: {from, to, amount} required as strings');
|
throw new Error('TRX swap body: {quoteId} OR {from, to, amount} required');
|
||||||
}
|
}
|
||||||
result = await swapTrx({
|
result = await executeTrx({
|
||||||
mnemonic,
|
mnemonic,
|
||||||
expectedFromAddress: wallet.address,
|
expectedFromAddress: wallet.address,
|
||||||
from, to, amount,
|
from, to, amount,
|
||||||
slippageBps,
|
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
||||||
|
lockedMinOut: cachedQuote?.locked.minOut,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// SOL Jupiter
|
// SOL Jupiter
|
||||||
const { inputMint, outputMint, amount, slippageBps } = req.body ?? {};
|
const params = cachedQuote ? cachedQuote.params : req.body ?? {};
|
||||||
|
const { inputMint, outputMint, amount, slippageBps } = params;
|
||||||
if (typeof inputMint !== 'string' || typeof outputMint !== 'string' || typeof amount !== 'string') {
|
if (typeof inputMint !== 'string' || typeof outputMint !== 'string' || typeof amount !== 'string') {
|
||||||
throw new Error('SOL swap body: {inputMint, outputMint, amount} required as strings');
|
throw new Error('SOL swap body: {quoteId} OR {inputMint, outputMint, amount} required');
|
||||||
}
|
}
|
||||||
result = await swapSol({
|
result = await executeSol({
|
||||||
mnemonic,
|
mnemonic,
|
||||||
expectedFromAddress: wallet.address,
|
expectedFromAddress: wallet.address,
|
||||||
inputMint, outputMint, amount,
|
inputMint, outputMint, amount,
|
||||||
slippageBps,
|
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
||||||
|
jupiterQuoteResponse: cachedQuote?.locked.jupiterQuoteResponse,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (swapErr: any) {
|
} catch (swapErr: any) {
|
||||||
@@ -759,6 +997,11 @@ export const WalletController = {
|
|||||||
throw swapErr;
|
throw swapErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anti-replay: удаляем quote после успешного execute.
|
||||||
|
if (cachedQuote) {
|
||||||
|
deleteQuote(userId, cachedQuote.quoteId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
await completeAudit(auditId, 'success', result);
|
await completeAudit(auditId, 'success', result);
|
||||||
res.json({ success: true, data: { chain, ...result } });
|
res.json({ success: true, data: { chain, ...result } });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
146
apps/api/src/lib/swap-quote-cache.ts
Normal file
146
apps/api/src/lib/swap-quote-cache.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Swap quote cache (KeyDB).
|
||||||
|
*
|
||||||
|
* Используется 2-step swap flow: `quoteSwap` сохраняет результат расчёта в Redis
|
||||||
|
* под опаковым `quoteId`, `swapOnChain` читает по `{userId, quoteId}` и выполняет
|
||||||
|
* swap с зафиксированными параметрами (anti-MEV защита от изменения minOut между
|
||||||
|
* quote и execute).
|
||||||
|
*
|
||||||
|
* Cache key:
|
||||||
|
* swap-quote:{userId}:{quoteId}
|
||||||
|
*
|
||||||
|
* TTL: 30 секунд (default). После expire — execute вернёт 410 Gone и юзер
|
||||||
|
* перезапросит quote.
|
||||||
|
*
|
||||||
|
* Anti-replay: успешный execute удаляет cache entry (см. `deleteQuote`).
|
||||||
|
*
|
||||||
|
* Security:
|
||||||
|
* - quoteId сгенерирован через ULID (collision-resistant)
|
||||||
|
* - cache key включает userId — even if quoteId leak'нет, другой юзер не
|
||||||
|
* сможет execute (DB read будет miss)
|
||||||
|
* - extra check на field `userId` внутри cached object — defence-in-depth
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getRedis } from '../config/redis';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
const KEY_PREFIX = 'swap-quote:';
|
||||||
|
const DEFAULT_TTL_SECONDS = 30;
|
||||||
|
|
||||||
|
export interface CachedSwapQuote {
|
||||||
|
// Метаданные
|
||||||
|
quoteId: string;
|
||||||
|
userId: string;
|
||||||
|
chain: 'BSC' | 'TRX' | 'SOL';
|
||||||
|
createdAt: number; // unix ms
|
||||||
|
expiresAt: number; // unix ms
|
||||||
|
|
||||||
|
// Параметры execute — locked
|
||||||
|
// Для BSC/TRX: from/to/amount — symbols. SOL: inputMint/outputMint/amount — mints.
|
||||||
|
params: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
inputMint?: string;
|
||||||
|
outputMint?: string;
|
||||||
|
amount: string;
|
||||||
|
slippageBps: number;
|
||||||
|
feeTier?: 'slow' | 'normal' | 'fast';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Locked-in expectation для execute path (защита от MEV-sandwich).
|
||||||
|
// На execute мы передаём `minOut` (BSC/TRX) ИЛИ напрямую `quoteResponse`
|
||||||
|
// (SOL — Jupiter API требует full quote object).
|
||||||
|
locked: {
|
||||||
|
expectedOut: string;
|
||||||
|
minOut: string;
|
||||||
|
// SOL only: serialized Jupiter /quote response (для re-use на /swap step).
|
||||||
|
jupiterQuoteResponse?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Snapshot всех полей quote — возвращается клиенту, попадает в audit_log.
|
||||||
|
preview: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKey(userId: string, quoteId: string): string {
|
||||||
|
return `${KEY_PREFIX}${userId}:${quoteId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохраняет quote в KeyDB с TTL.
|
||||||
|
* Возвращает true если успешно, false если cache write failed (не критично — caller
|
||||||
|
* может вернуть 503).
|
||||||
|
*/
|
||||||
|
export async function saveQuote(
|
||||||
|
quote: CachedSwapQuote,
|
||||||
|
ttlSeconds: number = DEFAULT_TTL_SECONDS,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (ttlSeconds < 1 || ttlSeconds > 600) {
|
||||||
|
throw new Error(`saveQuote: ttlSeconds ${ttlSeconds} out of range [1,600]`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const key = buildKey(quote.userId, quote.quoteId);
|
||||||
|
await getRedis().set(key, JSON.stringify(quote), 'EX', ttlSeconds);
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`swap-quote-cache.saveQuote failed: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Читает quote из KeyDB по `{userId, quoteId}`.
|
||||||
|
* Возвращает `null` если:
|
||||||
|
* - не найден (expired или never existed)
|
||||||
|
* - userId mismatch в cached object (defence-in-depth)
|
||||||
|
* - JSON parse error
|
||||||
|
* - Redis unavailable (логируется error)
|
||||||
|
*/
|
||||||
|
export async function getQuote(
|
||||||
|
userId: string,
|
||||||
|
quoteId: string,
|
||||||
|
): Promise<CachedSwapQuote | null> {
|
||||||
|
if (!userId || !quoteId) return null;
|
||||||
|
// Базовая sanity-check: quoteId должен быть alphanumeric (ULID = 26 chars), но
|
||||||
|
// допускаем любые printable для гибкости. Бьём только entrants с обвидно
|
||||||
|
// malformed input.
|
||||||
|
if (quoteId.length > 64 || /[\r\n\s]/.test(quoteId)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = buildKey(userId, quoteId);
|
||||||
|
const raw = await getRedis().get(key);
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
let parsed: CachedSwapQuote;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as CachedSwapQuote;
|
||||||
|
} catch {
|
||||||
|
logger.error(`swap-quote-cache.getQuote: JSON parse failed for key=${key}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defence-in-depth: cache key уже content-binds userId, но проверяем поле.
|
||||||
|
if (parsed.userId !== userId) {
|
||||||
|
logger.error(`swap-quote-cache.getQuote: userId mismatch (key=${userId}, body=${parsed.userId})`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`swap-quote-cache.getQuote failed: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет quote после успешного execute (anti-replay).
|
||||||
|
* Best-effort — ошибки логируются и swallowed.
|
||||||
|
*/
|
||||||
|
export async function deleteQuote(userId: string, quoteId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await getRedis().del(buildKey(userId, quoteId));
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.warn(`swap-quote-cache.deleteQuote failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QUOTE_TTL_SECONDS = DEFAULT_TTL_SECONDS;
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
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); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import { Request, Response, Router } from 'express';
|
|
||||||
import { env } from '../config/env';
|
|
||||||
import { logger } from '../lib/logger';
|
|
||||||
import { assertUserOwnsAddress } from '../lib/wallet-binding';
|
|
||||||
import { PublicKey } from '@solana/web3.js';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
const JUPITER_BASE = 'https://api.jup.ag/swap/v1';
|
|
||||||
const JUPITER_TIMEOUT_MS = 15_000;
|
|
||||||
|
|
||||||
const ALLOWED_MINTS = new Set([
|
|
||||||
'So11111111111111111111111111111111111111112', // SOL
|
|
||||||
'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', // USDT
|
|
||||||
'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC
|
|
||||||
'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', // PUMP
|
|
||||||
'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', // JUP
|
|
||||||
'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', // WIF
|
|
||||||
'7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', // POPCAT
|
|
||||||
'6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', // TRUMP
|
|
||||||
'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', // PYTH
|
|
||||||
'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', // JTO
|
|
||||||
'85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', // W
|
|
||||||
'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', // BONK
|
|
||||||
'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', // ORCA
|
|
||||||
'2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', // PENGU
|
|
||||||
'4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', // RAY
|
|
||||||
]);
|
|
||||||
|
|
||||||
router.get('/quote', getQuote);
|
|
||||||
router.post('/build', buildSwap);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/sol/swap/quote
|
|
||||||
* Proxies to Jupiter GET /v6/quote
|
|
||||||
*/
|
|
||||||
async function getQuote(req: Request, res: Response) {
|
|
||||||
const { inputMint, outputMint, amount, slippageBps } = req.query;
|
|
||||||
|
|
||||||
if (!inputMint || !outputMint || !amount || !slippageBps) {
|
|
||||||
res.status(400).json({ success: false, error: 'Missing required params: inputMint, outputMint, amount, slippageBps' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ALLOWED_MINTS.has(String(inputMint)) || !ALLOWED_MINTS.has(String(outputMint))) {
|
|
||||||
res.status(400).json({ success: false, error: 'Token mint not in whitelist' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inputMint === outputMint) {
|
|
||||||
res.status(400).json({ success: false, error: 'inputMint and outputMint must be different' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedAmount = parseInt(String(amount), 10);
|
|
||||||
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
|
||||||
res.status(400).json({ success: false, error: 'amount must be a positive integer' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedSlippage = parseInt(String(slippageBps), 10);
|
|
||||||
if (!Number.isFinite(parsedSlippage) || parsedSlippage < 1 || parsedSlippage > 500) {
|
|
||||||
res.status(400).json({ success: false, error: 'slippageBps must be 1-500 (max 5%)' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = new URL(`${JUPITER_BASE}/quote`);
|
|
||||||
url.searchParams.set('inputMint', String(inputMint));
|
|
||||||
url.searchParams.set('outputMint', String(outputMint));
|
|
||||||
url.searchParams.set('amount', String(parsedAmount));
|
|
||||||
// H38 — forward PARSED value, not raw query string ("300abc" → "300" но raw forwarded as "300abc")
|
|
||||||
url.searchParams.set('slippageBps', String(parsedSlippage));
|
|
||||||
|
|
||||||
// Platform fee (0.7%) — Jupiter deducts this natively
|
|
||||||
if (env.jupiterFeeBps > 0) {
|
|
||||||
url.searchParams.set('platformFeeBps', String(env.jupiterFeeBps));
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
||||||
if (env.jupiterApiKey) {
|
|
||||||
headers['x-api-key'] = env.jupiterApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), { headers, signal: controller.signal });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text().catch(() => '');
|
|
||||||
// НЕ reflect'им upstream body — может содержать internal config (feeAccount, refs)
|
|
||||||
logger.error(`Jupiter quote ${response.status}: ${text.slice(0, 200)}`);
|
|
||||||
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
res.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
if (controller.signal.aborted) {
|
|
||||||
res.status(504).json({ success: false, error: 'Jupiter quote request timed out' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(502).json({ success: false, error: 'Failed to reach Jupiter API' });
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/sol/swap/build
|
|
||||||
* Proxies to Jupiter POST /v6/swap — returns serialized transaction for client signing
|
|
||||||
*/
|
|
||||||
async function buildSwap(req: Request, res: Response) {
|
|
||||||
const { quoteResponse, userPublicKey } = req.body;
|
|
||||||
|
|
||||||
if (!quoteResponse || typeof quoteResponse !== 'object') {
|
|
||||||
res.status(400).json({ success: false, error: 'Missing quoteResponse object' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userPublicKey || typeof userPublicKey !== 'string') {
|
|
||||||
res.status(400).json({ success: false, error: 'Missing userPublicKey string' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate userPublicKey syntactically
|
|
||||||
try {
|
|
||||||
new PublicKey(userPublicKey);
|
|
||||||
} catch {
|
|
||||||
res.status(400).json({ success: false, error: 'Invalid userPublicKey (not a valid base58 32-byte pubkey)' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// C9 — bind userPublicKey to JWT (без bind: feeAccount/referral hijacking, ATA pre-staging)
|
|
||||||
const userId = req.auth!.userId;
|
|
||||||
try {
|
|
||||||
await assertUserOwnsAddress(userId, 'SOL', userPublicKey);
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(403).json({ success: false, error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// C8 — re-validate input/output mints в quoteResponse против ALLOWED_MINTS.
|
|
||||||
// Иначе attacker может вызвать /quote с whitelisted mints (passes), затем POST /build
|
|
||||||
// с hand-crafted quoteResponse где outputMint = scam-mint, и Jupiter trust'нет shape.
|
|
||||||
const qInputMint = (quoteResponse as any)?.inputMint;
|
|
||||||
const qOutputMint = (quoteResponse as any)?.outputMint;
|
|
||||||
if (typeof qInputMint !== 'string' || !ALLOWED_MINTS.has(qInputMint)) {
|
|
||||||
res.status(400).json({ success: false, error: 'quoteResponse.inputMint not in allowlist' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof qOutputMint !== 'string' || !ALLOWED_MINTS.has(qOutputMint)) {
|
|
||||||
res.status(400).json({ success: false, error: 'quoteResponse.outputMint not in allowlist' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
};
|
|
||||||
if (env.jupiterApiKey) {
|
|
||||||
headers['x-api-key'] = env.jupiterApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const swapBody: Record<string, unknown> = {
|
|
||||||
quoteResponse,
|
|
||||||
userPublicKey,
|
|
||||||
wrapAndUnwrapSol: true,
|
|
||||||
dynamicComputeUnitLimit: true,
|
|
||||||
prioritizationFeeLamports: 'auto',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Attach referral fee account for Jupiter to route platform fees
|
|
||||||
if (env.jupiterReferralAccount) {
|
|
||||||
swapBody.feeAccount = env.jupiterReferralAccount;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${JUPITER_BASE}/swap`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
signal: controller.signal,
|
|
||||||
body: JSON.stringify(swapBody),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text().catch(() => '');
|
|
||||||
logger.error(`Jupiter swap build ${response.status}: ${text.slice(0, 200)}`);
|
|
||||||
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
res.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
if (controller.signal.aborted) {
|
|
||||||
res.status(504).json({ success: false, error: 'Jupiter swap build timed out' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(502).json({ success: false, error: 'Failed to reach Jupiter API' });
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,499 +0,0 @@
|
|||||||
import { Request, Response, Router } from 'express';
|
|
||||||
import { env } from '../config/env';
|
|
||||||
import { logger } from '../lib/logger';
|
|
||||||
import { assertUserOwnsAddress } from '../lib/wallet-binding';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
const TRONGRID_BASE = 'https://api.trongrid.io';
|
|
||||||
const TRON_TIMEOUT_MS = 15_000;
|
|
||||||
const TRON_ADDRESS_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
|
|
||||||
|
|
||||||
// Contracts
|
|
||||||
const SUNSWAP_SMART_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax';
|
|
||||||
const USDT_CONTRACT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
|
||||||
const WTRX_CONTRACT = 'TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR';
|
|
||||||
|
|
||||||
// FeeSwapRouter_TRX — deployed contract, 0.7% fee
|
|
||||||
const FEE_SWAP_ROUTER_TRX = 'TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E';
|
|
||||||
const FEE_BPS = 70n;
|
|
||||||
const BPS_DENOMINATOR = 10_000n;
|
|
||||||
|
|
||||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
||||||
|
|
||||||
// Token map
|
|
||||||
const TOKEN_MAP: Record<string, string> = {
|
|
||||||
TRX: WTRX_CONTRACT,
|
|
||||||
USDT: USDT_CONTRACT,
|
|
||||||
};
|
|
||||||
|
|
||||||
const TOKEN_DECIMALS: Record<string, number> = {
|
|
||||||
TRX: 6,
|
|
||||||
USDT: 6,
|
|
||||||
};
|
|
||||||
|
|
||||||
router.get('/quote', getSwapQuote);
|
|
||||||
router.post('/build', buildSwapTx);
|
|
||||||
router.post('/broadcast', broadcastTx);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
||||||
// ─── Helpers ───
|
|
||||||
|
|
||||||
function tronAddressToHex(address: string): string {
|
|
||||||
let num = 0n;
|
|
||||||
for (const char of address) {
|
|
||||||
const index = BASE58_ALPHABET.indexOf(char);
|
|
||||||
if (index === -1) throw new Error('Invalid base58 character');
|
|
||||||
num = num * 58n + BigInt(index);
|
|
||||||
}
|
|
||||||
const hex = num.toString(16).padStart(50, '0');
|
|
||||||
return hex.slice(2, 42); // skip 0x41, take 20 bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeUint256(value: bigint): string {
|
|
||||||
return value.toString(16).padStart(64, '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeAddress(tronAddress: string): string {
|
|
||||||
const hex = tronAddressToHex(tronAddress);
|
|
||||||
return hex.padStart(64, '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
function tronHeaders(): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
};
|
|
||||||
if (env.tronApiKey) {
|
|
||||||
headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode bytes calldata as ABI dynamic bytes parameter
|
|
||||||
function encodeDynamicBytes(hexData: string): string {
|
|
||||||
// Remove 0x prefix if present
|
|
||||||
const data = hexData.startsWith('0x') ? hexData.slice(2) : hexData;
|
|
||||||
const byteLength = data.length / 2;
|
|
||||||
const lengthEncoded = encodeUint256(BigInt(byteLength));
|
|
||||||
// Pad data to 32-byte boundary
|
|
||||||
const paddedData = data.padEnd(Math.ceil(data.length / 64) * 64, '0');
|
|
||||||
return lengthEncoded + paddedData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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. Use TRX or USDT' });
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduct 0.7% fee — SunSwap will only receive 99.3%
|
|
||||||
const feeAmount = (amountBigInt * FEE_BPS) / BPS_DENOMINATOR;
|
|
||||||
const amountAfterFee = amountBigInt - feeAmount;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fromToken = TOKEN_MAP[from];
|
|
||||||
const toToken = TOKEN_MAP[to];
|
|
||||||
|
|
||||||
// ABI: getAmountsOut(uint256 amountIn, address[] path)
|
|
||||||
const amountHex = encodeUint256(amountAfterFee);
|
|
||||||
const offsetHex = encodeUint256(64n);
|
|
||||||
const lengthHex = encodeUint256(2n);
|
|
||||||
const addr0Hex = encodeAddress(fromToken);
|
|
||||||
const addr1Hex = encodeAddress(toToken);
|
|
||||||
const parameter = amountHex + offsetHex + lengthHex + addr0Hex + addr1Hex;
|
|
||||||
|
|
||||||
const response = await fetch(`${TRONGRID_BASE}/wallet/triggerconstantcontract`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: tronHeaders(),
|
|
||||||
signal: controller.signal,
|
|
||||||
body: JSON.stringify({
|
|
||||||
owner_address: SUNSWAP_SMART_ROUTER,
|
|
||||||
contract_address: SUNSWAP_SMART_ROUTER,
|
|
||||||
function_selector: 'getAmountsOut(uint256,address[])',
|
|
||||||
parameter,
|
|
||||||
visible: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
res.status(response.status).json({ success: false, error: 'TronGrid error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await response.json()) as {
|
|
||||||
constant_result?: string[];
|
|
||||||
result?: { result?: boolean; message?: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body.constant_result?.[0]) {
|
|
||||||
const errorMsg = body.result?.message
|
|
||||||
? Buffer.from(body.result.message, 'hex').toString('utf8')
|
|
||||||
: 'No result from getAmountsOut';
|
|
||||||
res.status(502).json({ success: false, error: errorMsg });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultHex = body.constant_result[0];
|
|
||||||
const amountOutHex = resultHex.slice(-64);
|
|
||||||
const amountOut = BigInt('0x' + amountOutHex).toString();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
amountIn: amountBigInt.toString(),
|
|
||||||
amountOut,
|
|
||||||
fee: feeAmount.toString(),
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
fromDecimals: TOKEN_DECIMALS[from],
|
|
||||||
toDecimals: TOKEN_DECIMALS[to],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (controller.signal.aborted) {
|
|
||||||
res.status(504).json({ success: false, error: 'TronGrid quote request timed out' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(502).json({ success: false, error: 'Failed to reach TronGrid' });
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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 (!TRON_ADDRESS_RE.test(userAddress)) {
|
|
||||||
res.status(400).json({ success: false, error: 'Invalid TRON address' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// H47 — bind userAddress to JWT (output идёт на userAddress; без bind output к attacker'у)
|
|
||||||
const userId = req.auth!.userId;
|
|
||||||
try {
|
|
||||||
await assertUserOwnsAddress(userId, 'TRX', userAddress);
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(403).json({ success: false, error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const transactions: Array<{ txID: string; raw_data: unknown; raw_data_hex: string; type: string }> = [];
|
|
||||||
const amountBigInt = BigInt(amount);
|
|
||||||
const minOutBigInt = BigInt(amountOutMin);
|
|
||||||
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // 20 minutes
|
|
||||||
|
|
||||||
// Calculate fee and swap amounts
|
|
||||||
const feeAmount = (amountBigInt * FEE_BPS) / BPS_DENOMINATOR;
|
|
||||||
const swapAmount = amountBigInt - feeAmount;
|
|
||||||
|
|
||||||
if (fromUpper === 'TRX') {
|
|
||||||
// ═══ TRX → USDT: through FeeSwapRouter.swapNativeWithFee(bytes routerCalldata) ═══
|
|
||||||
|
|
||||||
// Step 1: Build the SunSwap calldata for swapExactETHForTokens
|
|
||||||
// SunSwap will receive swapAmount (99.3%) of TRX from FeeSwapRouter
|
|
||||||
// SunSwap sends output tokens to `to` address — must be userAddress
|
|
||||||
const sunswapCalldata = buildSwapExactETHForTokensCalldata(
|
|
||||||
minOutBigInt,
|
|
||||||
[WTRX_CONTRACT, USDT_CONTRACT],
|
|
||||||
userAddress,
|
|
||||||
deadline,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 2: Wrap in swapNativeWithFee(bytes routerCalldata)
|
|
||||||
// ABI: swapNativeWithFee(bytes) — single dynamic bytes param
|
|
||||||
const offsetToBytes = encodeUint256(32n); // offset to dynamic bytes
|
|
||||||
const feeRouterParam = offsetToBytes + encodeDynamicBytes(sunswapCalldata);
|
|
||||||
|
|
||||||
const swapTx = await buildTriggerSmartContract({
|
|
||||||
ownerAddress: userAddress,
|
|
||||||
contractAddress: FEE_SWAP_ROUTER_TRX,
|
|
||||||
functionSelector: 'swapNativeWithFee(bytes)',
|
|
||||||
parameter: feeRouterParam,
|
|
||||||
callValue: Number(amountBigInt), // full amount — contract takes 0.7%
|
|
||||||
feeLimit: 200_000_000, // 200 TRX
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (swapTx) {
|
|
||||||
transactions.push({ ...swapTx, type: 'swap' });
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// ═══ USDT → TRX: through FeeSwapRouter.swapTokenWithFee(address, uint256, bytes) ═══
|
|
||||||
|
|
||||||
// Step 1: Approve USDT to FeeSwapRouter (not SunSwap!)
|
|
||||||
const allowance = await checkAllowance(userAddress, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, controller.signal);
|
|
||||||
|
|
||||||
if (allowance < amountBigInt) {
|
|
||||||
const approveTx = await buildTriggerSmartContract({
|
|
||||||
ownerAddress: userAddress,
|
|
||||||
contractAddress: USDT_CONTRACT,
|
|
||||||
functionSelector: 'approve(address,uint256)',
|
|
||||||
parameter: encodeAddress(FEE_SWAP_ROUTER_TRX) + encodeUint256(BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff')),
|
|
||||||
callValue: 0,
|
|
||||||
feeLimit: 100_000_000,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (approveTx) {
|
|
||||||
transactions.push({ ...approveTx, type: 'approve' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Build SunSwap calldata for swapExactTokensForETH
|
|
||||||
// FeeSwapRouter will approve swapAmount (99.3%) to SunSwap and forward this calldata
|
|
||||||
const sunswapCalldata = buildSwapExactTokensForETHCalldata(
|
|
||||||
swapAmount, // 99.3% — what SunSwap actually receives
|
|
||||||
minOutBigInt,
|
|
||||||
[USDT_CONTRACT, WTRX_CONTRACT],
|
|
||||||
userAddress, // output TRX goes to user
|
|
||||||
deadline,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 3: Wrap in swapTokenWithFee(address tokenIn, uint256 amountIn, bytes routerCalldata)
|
|
||||||
const tokenInEncoded = encodeAddress(USDT_CONTRACT);
|
|
||||||
const amountInEncoded = encodeUint256(amountBigInt); // full amount — contract takes 0.7%
|
|
||||||
const offsetToBytes = encodeUint256(96n); // offset to dynamic bytes (3 * 32)
|
|
||||||
const feeRouterParam = tokenInEncoded + amountInEncoded + offsetToBytes + encodeDynamicBytes(sunswapCalldata);
|
|
||||||
|
|
||||||
const swapTx = await buildTriggerSmartContract({
|
|
||||||
ownerAddress: userAddress,
|
|
||||||
contractAddress: FEE_SWAP_ROUTER_TRX,
|
|
||||||
functionSelector: 'swapTokenWithFee(address,uint256,bytes)',
|
|
||||||
parameter: feeRouterParam,
|
|
||||||
callValue: 0,
|
|
||||||
feeLimit: 200_000_000,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (swapTx) {
|
|
||||||
transactions.push({ ...swapTx, type: 'swap' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!transactions.length) {
|
|
||||||
res.status(502).json({ success: false, error: 'Failed to build swap transactions' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true, transactions });
|
|
||||||
} catch (error) {
|
|
||||||
if (controller.signal.aborted) {
|
|
||||||
res.status(504).json({ success: false, error: 'Build request timed out' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logger.error(`TRON swap build failed: ${(error as any)?.stack || (error as any)?.message}`);
|
|
||||||
res.status(502).json({ success: false, error: 'Failed to build swap' });
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── POST /broadcast ───
|
|
||||||
|
|
||||||
async function broadcastTx(req: Request, res: Response) {
|
|
||||||
const { signedTransaction } = req.body;
|
|
||||||
|
|
||||||
if (!signedTransaction || typeof signedTransaction !== 'object') {
|
|
||||||
res.status(400).json({ success: false, error: 'Missing or invalid signedTransaction' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// C6 — verify owner_address внутри signed tx равен JWT-bound TRX-адресу юзера.
|
|
||||||
// Иначе endpoint = open relay для arbitrary tx (replay leaked txs, evade IP bans).
|
|
||||||
const userId = req.auth!.userId;
|
|
||||||
const contract0 = signedTransaction?.raw_data?.contract?.[0];
|
|
||||||
const ownerAddr = contract0?.parameter?.value?.owner_address;
|
|
||||||
if (typeof ownerAddr !== 'string' || !ownerAddr) {
|
|
||||||
res.status(400).json({ success: false, error: 'Cannot extract owner_address from signedTransaction.raw_data.contract[0]' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await assertUserOwnsAddress(userId, 'TRX', ownerAddr);
|
|
||||||
} catch (err: any) {
|
|
||||||
logger.warn(`broadcast rejected: ${err.message} userId=${userId}`);
|
|
||||||
res.status(403).json({ success: false, error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${TRONGRID_BASE}/wallet/broadcasttransaction`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: tronHeaders(),
|
|
||||||
signal: controller.signal,
|
|
||||||
body: JSON.stringify(signedTransaction),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
res.status(response.ok ? 200 : 502).json(data);
|
|
||||||
} catch (error) {
|
|
||||||
if (controller.signal.aborted) {
|
|
||||||
res.status(504).json({ success: false, error: 'Broadcast timed out' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(502).json({ success: false, error: 'Failed to broadcast transaction' });
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── SunSwap Calldata Builders ───
|
|
||||||
|
|
||||||
// Build raw calldata hex for swapExactETHForTokens(uint256,address[],address,uint256)
|
|
||||||
function buildSwapExactETHForTokensCalldata(
|
|
||||||
amountOutMin: bigint,
|
|
||||||
path: string[], // TRON base58 addresses
|
|
||||||
to: string, // TRON base58 address
|
|
||||||
deadline: bigint,
|
|
||||||
): string {
|
|
||||||
// Function selector: keccak256("swapExactETHForTokens(uint256,address[],address,uint256)") first 4 bytes
|
|
||||||
const selector = 'b6f9de95';
|
|
||||||
|
|
||||||
const amountOutMinEnc = encodeUint256(amountOutMin);
|
|
||||||
const offsetToPath = encodeUint256(128n); // 4 * 32 bytes offset
|
|
||||||
const toEnc = encodeAddress(to);
|
|
||||||
const deadlineEnc = encodeUint256(deadline);
|
|
||||||
const pathLenEnc = encodeUint256(BigInt(path.length));
|
|
||||||
const pathElements = path.map((addr) => encodeAddress(addr)).join('');
|
|
||||||
|
|
||||||
return selector + amountOutMinEnc + offsetToPath + toEnc + deadlineEnc + pathLenEnc + pathElements;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build raw calldata hex for swapExactTokensForETH(uint256,uint256,address[],address,uint256)
|
|
||||||
function buildSwapExactTokensForETHCalldata(
|
|
||||||
amountIn: bigint,
|
|
||||||
amountOutMin: bigint,
|
|
||||||
path: string[], // TRON base58 addresses
|
|
||||||
to: string, // TRON base58 address
|
|
||||||
deadline: bigint,
|
|
||||||
): string {
|
|
||||||
// Function selector: keccak256("swapExactTokensForETH(uint256,uint256,address[],address,uint256)") first 4 bytes
|
|
||||||
const selector = '18cbafe5';
|
|
||||||
|
|
||||||
const amountInEnc = encodeUint256(amountIn);
|
|
||||||
const amountOutMinEnc = encodeUint256(amountOutMin);
|
|
||||||
const offsetToPath = encodeUint256(160n); // 5 * 32 bytes offset
|
|
||||||
const toEnc = encodeAddress(to);
|
|
||||||
const deadlineEnc = encodeUint256(deadline);
|
|
||||||
const pathLenEnc = encodeUint256(BigInt(path.length));
|
|
||||||
const pathElements = path.map((addr) => encodeAddress(addr)).join('');
|
|
||||||
|
|
||||||
return selector + amountInEnc + amountOutMinEnc + offsetToPath + toEnc + deadlineEnc + pathLenEnc + pathElements;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Internal Helpers ───
|
|
||||||
|
|
||||||
async function checkAllowance(
|
|
||||||
owner: string,
|
|
||||||
tokenContract: string,
|
|
||||||
spender: string,
|
|
||||||
signal: AbortSignal
|
|
||||||
): Promise<bigint> {
|
|
||||||
const parameter = encodeAddress(owner) + encodeAddress(spender);
|
|
||||||
|
|
||||||
const response = await fetch(`${TRONGRID_BASE}/wallet/triggerconstantcontract`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: tronHeaders(),
|
|
||||||
signal,
|
|
||||||
body: JSON.stringify({
|
|
||||||
owner_address: owner,
|
|
||||||
contract_address: tokenContract,
|
|
||||||
function_selector: 'allowance(address,address)',
|
|
||||||
parameter,
|
|
||||||
visible: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) return 0n;
|
|
||||||
|
|
||||||
const body = (await response.json()) as { constant_result?: string[] };
|
|
||||||
const hex = body.constant_result?.[0];
|
|
||||||
if (!hex || /^0+$/.test(hex)) return 0n;
|
|
||||||
|
|
||||||
return BigInt('0x' + hex);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TriggerSmartContractParams {
|
|
||||||
ownerAddress: string;
|
|
||||||
contractAddress: string;
|
|
||||||
functionSelector: string;
|
|
||||||
parameter: string;
|
|
||||||
callValue: number;
|
|
||||||
feeLimit: number;
|
|
||||||
signal: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildTriggerSmartContract(
|
|
||||||
params: TriggerSmartContractParams
|
|
||||||
): Promise<{ txID: string; raw_data: unknown; raw_data_hex: string } | null> {
|
|
||||||
const response = await fetch(`${TRONGRID_BASE}/wallet/triggersmartcontract`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: tronHeaders(),
|
|
||||||
signal: params.signal,
|
|
||||||
body: JSON.stringify({
|
|
||||||
owner_address: params.ownerAddress,
|
|
||||||
contract_address: params.contractAddress,
|
|
||||||
function_selector: params.functionSelector,
|
|
||||||
parameter: params.parameter,
|
|
||||||
call_value: params.callValue,
|
|
||||||
fee_limit: params.feeLimit,
|
|
||||||
visible: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) return null;
|
|
||||||
|
|
||||||
const body = (await response.json()) as {
|
|
||||||
result?: { result?: boolean; message?: string };
|
|
||||||
transaction?: { txID: string; raw_data: unknown; raw_data_hex: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body.result?.result || !body.transaction) {
|
|
||||||
const errorMsg = body.result?.message
|
|
||||||
? Buffer.from(body.result.message, 'hex').toString('utf8')
|
|
||||||
: 'Transaction build failed';
|
|
||||||
throw new Error(errorMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
return body.transaction;
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,9 @@ router.get('/:chain/transactions', WalletController.getChainTransactions);
|
|||||||
router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions);
|
router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions);
|
||||||
router.post('/:chain/send', WalletController.sendFromChain);
|
router.post('/:chain/send', WalletController.sendFromChain);
|
||||||
router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx);
|
router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx);
|
||||||
|
// IMPORTANT: /:chain/swap/quote ДОЛЖЕН быть ПЕРЕД /:chain/swap чтобы Express
|
||||||
|
// сматчил specific route раньше general'ного.
|
||||||
|
router.post('/:chain/swap/quote', WalletController.quoteSwap);
|
||||||
router.post('/:chain/swap', WalletController.swapOnChain);
|
router.post('/:chain/swap', WalletController.swapOnChain);
|
||||||
router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx);
|
router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx);
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,45 @@ export interface SwapBscParams {
|
|||||||
feeTier?: FeeTier;
|
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
|
/** Wrapper над `pickProxiedEvmProvider` — все swap-orchestrator EVM calls
|
||||||
* идут через OUTBOUND_PROXY_URL если задан. Если не задан — fallback direct. */
|
* идут через OUTBOUND_PROXY_URL если задан. Если не задан — fallback direct. */
|
||||||
async function pickProvider(rpcs: string[], chainId: number): Promise<ethers.providers.StaticJsonRpcProvider> {
|
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.
|
* сначала approve(exact), wait 1 confirmation, потом swap.
|
||||||
*
|
*
|
||||||
|
* Если `lockedMinOut` задан (2-step flow с quote→execute) — используется он, иначе
|
||||||
|
* re-quote on-chain (legacy single-shot).
|
||||||
|
*
|
||||||
* Returns: { approveTxid?, swapTxid }
|
* 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 fromUpper = p.from.toUpperCase();
|
||||||
const toUpper = p.to.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');
|
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 routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
|
||||||
const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]];
|
const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]];
|
||||||
const amountsOut: ethers.BigNumber[] = await withTimeout(
|
let amountOutMin: ethers.BigNumber;
|
||||||
routerContract.getAmountsOut(p.amount, path),
|
if (p.lockedMinOut && /^\d+$/.test(p.lockedMinOut)) {
|
||||||
HTTP_TIMEOUT_MS,
|
amountOutMin = ethers.BigNumber.from(p.lockedMinOut);
|
||||||
'PancakeSwap quote timed out',
|
} else {
|
||||||
);
|
const amountsOut: ethers.BigNumber[] = await withTimeout(
|
||||||
const expectedOut = amountsOut[amountsOut.length - 1];
|
routerContract.getAmountsOut(p.amount, path),
|
||||||
if (expectedOut.lte(0)) {
|
HTTP_TIMEOUT_MS,
|
||||||
throw new Error('PancakeSwap quote returned 0 — no liquidity for this pair');
|
'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 deadline = Math.floor(Date.now() / 1000) + 1200; // 20 minutes
|
||||||
const feeFields: Partial<ethers.providers.TransactionRequest> = {
|
const feeFields: Partial<ethers.providers.TransactionRequest> = {
|
||||||
@@ -286,6 +471,24 @@ export interface SwapTrxParams {
|
|||||||
slippageBps?: number;
|
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> {
|
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
||||||
@@ -587,19 +790,90 @@ async function waitTrxInclusion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TRX swap через SunSwap V2 + FeeSwapRouter (0.7% fee).
|
* TRX quote — read-only расчёт expected output для SunSwap V2 + FeeSwapRouter (0.7% fee).
|
||||||
* Поддерживает только TRX↔USDT (same scope как legacy /tron/swap/build).
|
* Только 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.
|
* 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 swapExactTokensForETH.
|
||||||
*
|
*
|
||||||
* Slippage: server вычисляет `amountOutMin = quote × (10000-slippageBps) / 10000`
|
* Slippage: если `lockedMinOut` задан → используется. Иначе re-quote on-chain.
|
||||||
* (default 50 bps = 0.5%). Клиент НЕ задаёт amountOutMin (защита от MEV-sandwich).
|
|
||||||
*
|
|
||||||
* Возвращает `{ approveTxid?, swapTxid }` для совместимости с swapBsc.
|
|
||||||
*/
|
*/
|
||||||
export async function swapTrx(
|
export async function executeTrx(
|
||||||
p: SwapTrxParams,
|
p: ExecuteTrxParams,
|
||||||
): Promise<{ approveTxid?: string; swapTxid: string }> {
|
): Promise<{ approveTxid?: string; swapTxid: string }> {
|
||||||
const fromU = p.from.toUpperCase();
|
const fromU = p.from.toUpperCase();
|
||||||
const toU = p.to.toUpperCase();
|
const toU = p.to.toUpperCase();
|
||||||
@@ -633,15 +907,21 @@ export async function swapTrx(
|
|||||||
const feeAmount = (amount * FEE_BPS) / BPS_DENOMINATOR;
|
const feeAmount = (amount * FEE_BPS) / BPS_DENOMINATOR;
|
||||||
const swapAmount = amount - feeAmount;
|
const swapAmount = amount - feeAmount;
|
||||||
|
|
||||||
// Quote (на 99.3%, т.к. это то что SunSwap реально получит).
|
|
||||||
const isTrxToUsdt = fromU === 'TRX';
|
const isTrxToUsdt = fromU === 'TRX';
|
||||||
const path = isTrxToUsdt
|
const path = isTrxToUsdt
|
||||||
? [WTRX_CONTRACT, USDT_CONTRACT]
|
? [WTRX_CONTRACT, USDT_CONTRACT]
|
||||||
: [USDT_CONTRACT, WTRX_CONTRACT];
|
: [USDT_CONTRACT, WTRX_CONTRACT];
|
||||||
const quote = await getAmountsOut(swapAmount, path, headers);
|
|
||||||
const amountOutMin = (quote * (BPS_DENOMINATOR - slippageBps)) / BPS_DENOMINATOR;
|
// amountOutMin: lockedMinOut from quote OR re-quote on-chain
|
||||||
if (amountOutMin <= 0n) {
|
let amountOutMin: bigint;
|
||||||
throw new Error(`TRX quote too low (${quote.toString()}) — no liquidity?`);
|
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 минут
|
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // 20 минут
|
||||||
@@ -781,10 +1061,114 @@ export interface SwapSolParams {
|
|||||||
slippageBps?: number;
|
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 seed = await bip39.mnemonicToSeed(p.mnemonic);
|
||||||
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
|
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
|
||||||
if (!key || key.length !== 32) {
|
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}`);
|
throw new Error(`SOL address mismatch: derived ${keypair.publicKey.toBase58()} ≠ DB ${p.expectedFromAddress}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const slippageBps = p.slippageBps ?? 50;
|
const slippageBps = validateSolParams(p.inputMint, p.outputMint, p.slippageBps);
|
||||||
if (slippageBps < 1 || slippageBps > 1000) {
|
|
||||||
throw new Error('slippageBps must be 1-1000');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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' };
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||||
if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey;
|
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)
|
// 2. Jupiter swap (build serialized tx)
|
||||||
const swapBody: Record<string, unknown> = {
|
const swapBody: Record<string, unknown> = {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user