efeidjeie

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

View File

@@ -7,7 +7,23 @@ import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validat
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.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 { applyEvmTxPolicy } from '../lib/evm-tx-policy';
import { getRelayTrustedAddresses } from '../lib/relay-trusted-cache';
@@ -651,13 +667,212 @@ export const WalletController = {
},
/**
* POST /api/wallets/:chain/swap — chained custodial swap.
* BSC: PancakeSwap V2 — approve (если token-to-anything) + swap, sign+broadcast в одном вызове.
* TRX: SunSwap — build + sign + broadcast (TRX↔USDT).
* SOL: Jupiter — quote + swap + sign + broadcast.
* POST /api/wallets/:chain/swap/quote — preview расчёт ПЕРЕД execute.
*
* Body для BSC/TRX: { from, to, amount, slippageBps?, feeTier? } — symbols (BNB, USDT, DOGE, USDC).
* Body для SOL: { inputMint, outputMint, amount, slippageBps? } — mint addresses.
* Read-only — НЕ broadcast'ит ничего. Возвращает quoteId (ULID), expectedOut,
* 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) {
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);
let mnemonic: string | null = null;
let auditId: string;
@@ -706,7 +936,7 @@ export const WalletController = {
event: 'wallet.swap',
userId,
ip: req.ip || null,
meta: { chain, body: req.body },
meta: { chain, quoteId: cachedQuote?.quoteId, body: cachedQuote ? undefined : req.body },
});
} catch (auditErr: any) {
logger.error(`Audit DB INSERT MUST succeed for wallet.swap: ${auditErr.message}`);
@@ -719,39 +949,47 @@ export const WalletController = {
let result: any;
try {
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') {
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,
expectedFromAddress: wallet.address,
from, to, amount,
slippageBps,
feeTier,
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined,
lockedMinOut: cachedQuote?.locked.minOut,
});
} 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') {
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,
expectedFromAddress: wallet.address,
from, to, amount,
slippageBps,
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
lockedMinOut: cachedQuote?.locked.minOut,
});
} else {
// 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') {
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,
expectedFromAddress: wallet.address,
inputMint, outputMint, amount,
slippageBps,
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
jupiterQuoteResponse: cachedQuote?.locked.jupiterQuoteResponse,
});
}
} catch (swapErr: any) {
@@ -759,6 +997,11 @@ export const WalletController = {
throw swapErr;
}
// Anti-replay: удаляем quote после успешного execute.
if (cachedQuote) {
deleteQuote(userId, cachedQuote.quoteId).catch(() => {});
}
await completeAudit(auditId, 'success', result);
res.json({ success: true, data: { chain, ...result } });
} catch (err: any) {