efeidjeie
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user