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