initjnjnj

This commit is contained in:
ZOMBIIIIIII
2026-05-14 19:52:56 +03:00
parent 5898a6c1e2
commit 22059373a4
5 changed files with 1215 additions and 41 deletions

View File

@@ -17,6 +17,13 @@ import { saveQuote, getQuote, deleteQuote, QUOTE_TTL_SECONDS, type CachedSwapQuo
import { generateUlid } from '../utils/ulid';
import { getPricesBySymbols } from '../services/price-oracle.service';
import { SOL_TOKENS } from '../lib/token-registry';
import {
resolveAmountFromBody,
resolveSendDecimals,
resolveSwapDecimalsBscTrx,
resolveSwapDecimalsSol,
formatSmallestUnits,
} from '../lib/amount-units';
const SOL_WRAPPED_NATIVE_MINT = 'So11111111111111111111111111111111111111112';
function solMintToSymbol(mint: string): string | null {
@@ -24,7 +31,7 @@ function solMintToSymbol(mint: string): string | null {
const t = SOL_TOKENS.find((x) => x.mint === mint);
return t?.symbol ?? null;
}
import { getEvmFeeTiers, type FeeTier } from '../services/gas-oracle.service';
import { getEvmFeeTiers, getEvmFeeForTier, type FeeTier } from '../services/gas-oracle.service';
import { applyEvmTxPolicy } from '../lib/evm-tx-policy';
import { getRelayTrustedAddresses } from '../lib/relay-trusted-cache';
import { acquireSendLock } from '../lib/send-lock';
@@ -35,6 +42,123 @@ import { logger } from '../lib/logger';
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
const MAX_TX_LIMIT = 100;
/**
* Per-chain cost-estimate для /send endpoint'а.
*
* Возвращает оценку network fee в native (smallest units + formatted + USD).
* Без mnemonic / RPC broadcast — только gas oracle + price oracle (cached).
*
* Approximations:
* - ETH/BSC: native send = 21000 gas; ERC-20 = 65000 gas.
* - TRX: native send ≈ 5 TRX (5_000_000 sun); USDT ≈ 30 TRX (30_000_000 sun).
* Без actual TronGrid call для bandwidth — approximation.
* - SOL: native ≈ 5_000 lamports; SPL ≈ 5_000 + priority 20_000 ≈ 25_000.
* - BTC: ~140 vbytes × current sat/vB (использует bitcoin-fees-mempool). Здесь упрощено
* до static 5 sat/vB × 140 = 700 satoshi.
*/
interface SendCostEstimate {
fee: { asset: string; amount: string; amountFormatted: string; amountUsd: number | null };
total: { amountUsd: number | null };
breakdown: Record<string, unknown>;
}
async function computeSendCostEstimate(opts: {
chain: ChainCode;
token?: string;
feeTier: FeeTier;
amountSmallest: string; // используется только для validation (что user не послал 0)
}): Promise<SendCostEstimate> {
const { chain, token, feeTier } = opts;
// EVM ─────────────────────────────────────────────
if (chain === 'ETH' || chain === 'BSC') {
const fee = await getEvmFeeForTier(chain, feeTier);
const gasUnits = token ? 65_000n : 21_000n;
const maxFeeWei = BigInt(fee.maxFeePerGas);
const feeWei = gasUnits * maxFeeWei;
const asset = chain === 'ETH' ? 'ETH' : 'BNB';
const priceMap = await getPricesBySymbols([{ chain, symbol: asset }])
.catch(() => new Map<string, number | null>());
const priceUsd = priceMap.get(`${chain}:${asset}`) ?? null;
const amountFormatted = formatSmallestUnits(feeWei.toString(), 18);
let usd: number | null = null;
if (priceUsd != null) {
const big = feeWei;
const divisor = 10n ** 18n;
usd = (Number(big / divisor) + Number(big % divisor) / Number(divisor)) * priceUsd;
}
return {
fee: { asset, amount: feeWei.toString(), amountFormatted, amountUsd: usd },
total: { amountUsd: usd },
breakdown: {
gasUnits: gasUnits.toString(),
maxFeePerGasWei: fee.maxFeePerGas,
maxPriorityFeePerGasWei: fee.maxPriorityFeePerGas,
feeTier,
},
};
}
// TRX ──────────────────────────────────────────────
if (chain === 'TRX') {
const feeSun = token ? 30_000_000n : 5_000_000n; // 30 TRX или 5 TRX
const priceMap = await getPricesBySymbols([{ chain, symbol: 'TRX' }])
.catch(() => new Map<string, number | null>());
const priceUsd = priceMap.get('TRX:TRX') ?? null;
const amountFormatted = formatSmallestUnits(feeSun.toString(), 6);
let usd: number | null = null;
if (priceUsd != null) {
const big = feeSun; const divisor = 10n ** 6n;
usd = (Number(big / divisor) + Number(big % divisor) / Number(divisor)) * priceUsd;
}
return {
fee: { asset: 'TRX', amount: feeSun.toString(), amountFormatted, amountUsd: usd },
total: { amountUsd: usd },
breakdown: { tokenTransfer: !!token, note: 'TRX energy/bandwidth approximation' },
};
}
// SOL ──────────────────────────────────────────────
if (chain === 'SOL') {
const feeLamports = token ? 25_000n : 5_000n;
const priceMap = await getPricesBySymbols([{ chain, symbol: 'SOL' }])
.catch(() => new Map<string, number | null>());
const priceUsd = priceMap.get('SOL:SOL') ?? null;
const amountFormatted = formatSmallestUnits(feeLamports.toString(), 9);
let usd: number | null = null;
if (priceUsd != null) {
const big = feeLamports; const divisor = 10n ** 9n;
usd = (Number(big / divisor) + Number(big % divisor) / Number(divisor)) * priceUsd;
}
return {
fee: { asset: 'SOL', amount: feeLamports.toString(), amountFormatted, amountUsd: usd },
total: { amountUsd: usd },
breakdown: { signatureFee: '5000', priorityFee: token ? '20000' : '0' },
};
}
// BTC ──────────────────────────────────────────────
if (chain === 'BTC') {
// 140 vbytes * 5 sat/vB = 700 satoshi (рassive approximation).
const feeSatoshi = 700n;
const priceMap = await getPricesBySymbols([{ chain, symbol: 'BTC' }])
.catch(() => new Map<string, number | null>());
const priceUsd = priceMap.get('BTC:BTC') ?? null;
const amountFormatted = formatSmallestUnits(feeSatoshi.toString(), 8);
let usd: number | null = null;
if (priceUsd != null) {
const big = feeSatoshi; const divisor = 10n ** 8n;
usd = (Number(big / divisor) + Number(big % divisor) / Number(divisor)) * priceUsd;
}
return {
fee: { asset: 'BTC', amount: feeSatoshi.toString(), amountFormatted, amountUsd: usd },
total: { amountUsd: usd },
breakdown: { vbytes: '140', satPerVByte: '5' },
};
}
throw new Error(`computeSendCostEstimate: unsupported chain ${chain}`);
}
class ConflictError extends Error {
constructor() { super('Wallet already exists'); }
}
@@ -347,16 +471,12 @@ export const WalletController = {
return;
}
const { to, amount, token, feeTier } = req.body ?? {};
const { to, amount, amountHuman, token, feeTier } = req.body ?? {};
if (!isValidAddress(chain, String(to))) {
res.status(400).json({ success: false, error: 'Invalid recipient address for chain' });
return;
}
if (!isValidAmount(String(amount))) {
res.status(400).json({ success: false, error: 'Invalid amount (must be positive integer string)' });
return;
}
let normalizedToken: string | undefined;
if (token !== undefined && token !== null) {
@@ -378,6 +498,22 @@ export const WalletController = {
normalizedFeeTier = feeTier;
}
// Resolve amount: backward-compat'ный {amount: "smallest"} ИЛИ новый {amountHuman: "0.01"}.
// Throws на conflict / missing / invalid format.
let resolvedAmount: string;
try {
const dec = resolveSendDecimals(chain, normalizedToken);
resolvedAmount = resolveAmountFromBody({ amount, amountHuman }, dec);
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
return;
}
// Дополнительная страховка: smallest-units должен пройти isValidAmount (positive integer).
if (!isValidAmount(resolvedAmount)) {
res.status(400).json({ success: false, error: 'Invalid amount (must resolve to positive integer)' });
return;
}
// C3 — idempotency. Если client передал Idempotency-Key — проверяем retry.
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
if (idempKey) {
@@ -433,7 +569,7 @@ export const WalletController = {
chain,
mnemonic,
to: String(to),
amount: String(amount),
amount: resolvedAmount,
token: normalizedToken,
expectedFromAddress: wallet.address,
feeTier: normalizedFeeTier,
@@ -696,47 +832,71 @@ export const WalletController = {
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' });
const { from, to, amount, amountHuman, slippageBps, feeTier } = req.body ?? {};
if (typeof from !== 'string' || typeof to !== 'string') {
res.status(400).json({ success: false, error: 'BSC quote body: {from, to} required as strings' });
return;
}
let resolvedAmount: string;
try {
const dec = resolveSwapDecimalsBscTrx('BSC', from);
resolvedAmount = resolveAmountFromBody({ amount, amountHuman }, dec);
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
return;
}
raw = await quoteBsc({
fromAddress: wallet.address,
from, to, amount,
from, to, amount: resolvedAmount,
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined,
});
cacheParams = {
from, to, amount,
from, to, amount: resolvedAmount,
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' });
const { from, to, amount, amountHuman, slippageBps } = req.body ?? {};
if (typeof from !== 'string' || typeof to !== 'string') {
res.status(400).json({ success: false, error: 'TRX quote body: {from, to} required as strings' });
return;
}
let resolvedAmount: string;
try {
const dec = resolveSwapDecimalsBscTrx('TRX', from);
resolvedAmount = resolveAmountFromBody({ amount, amountHuman }, dec);
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
return;
}
raw = await quoteTrx({
fromAddress: wallet.address,
from, to, amount,
from, to, amount: resolvedAmount,
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
});
cacheParams = { from, to, amount, slippageBps: raw.slippageBps };
cacheParams = { from, to, amount: resolvedAmount, 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' });
const { inputMint, outputMint, amount, amountHuman, slippageBps } = req.body ?? {};
if (typeof inputMint !== 'string' || typeof outputMint !== 'string') {
res.status(400).json({ success: false, error: 'SOL quote body: {inputMint, outputMint} required as strings' });
return;
}
let resolvedAmount: string;
try {
const dec = resolveSwapDecimalsSol(inputMint);
resolvedAmount = resolveAmountFromBody({ amount, amountHuman }, dec);
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
return;
}
const solQuote = await quoteSol({
inputMint, outputMint, amount,
inputMint, outputMint, amount: resolvedAmount,
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
});
raw = solQuote;
lockedJupiterQuote = solQuote.jupiterQuoteResponse;
cacheParams = { inputMint, outputMint, amount, slippageBps: solQuote.slippageBps };
cacheParams = { inputMint, outputMint, amount: resolvedAmount, slippageBps: solQuote.slippageBps };
}
// USD enrichment (graceful — null если CoinGecko недоступен).
@@ -952,42 +1112,52 @@ export const WalletController = {
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: {quoteId} OR {from, to, amount} required');
const { from, to, amount, amountHuman, slippageBps, feeTier } = params;
if (typeof from !== 'string' || typeof to !== 'string') {
throw new Error('BSC swap body: {quoteId} OR {from, to, amount|amountHuman} required');
}
// cachedQuote → amount уже resolved (smallest units). Legacy body → resolve amountHuman если задан.
const amountForExec = cachedQuote
? amount
: resolveAmountFromBody({ amount, amountHuman }, resolveSwapDecimalsBscTrx('BSC', from));
result = await executeBsc({
mnemonic,
expectedFromAddress: wallet.address,
from, to, amount,
from, to, amount: amountForExec,
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined,
lockedMinOut: cachedQuote?.locked.minOut,
});
} else if (chain === 'TRX') {
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: {quoteId} OR {from, to, amount} required');
const { from, to, amount, amountHuman, slippageBps } = params;
if (typeof from !== 'string' || typeof to !== 'string') {
throw new Error('TRX swap body: {quoteId} OR {from, to, amount|amountHuman} required');
}
const amountForExec = cachedQuote
? amount
: resolveAmountFromBody({ amount, amountHuman }, resolveSwapDecimalsBscTrx('TRX', from));
result = await executeTrx({
mnemonic,
expectedFromAddress: wallet.address,
from, to, amount,
from, to, amount: amountForExec,
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
lockedMinOut: cachedQuote?.locked.minOut,
});
} else {
// SOL Jupiter
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: {quoteId} OR {inputMint, outputMint, amount} required');
const { inputMint, outputMint, amount, amountHuman, slippageBps } = params;
if (typeof inputMint !== 'string' || typeof outputMint !== 'string') {
throw new Error('SOL swap body: {quoteId} OR {inputMint, outputMint, amount|amountHuman} required');
}
const amountForExec = cachedQuote
? amount
: resolveAmountFromBody({ amount, amountHuman }, resolveSwapDecimalsSol(inputMint));
result = await executeSol({
mnemonic,
expectedFromAddress: wallet.address,
inputMint, outputMint, amount,
inputMint, outputMint, amount: amountForExec,
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
jupiterQuoteResponse: cachedQuote?.locked.jupiterQuoteResponse,
});
@@ -1026,6 +1196,200 @@ export const WalletController = {
}
},
/**
* POST /api/wallets/:chain/send/cost-estimate
*
* Read-only USD-оценка сколько будет стоить broadcast tx (gas/network fee).
* НЕ резервирует idempotency cache, НЕ дёргает mnemonic, НЕ broadcast'ит.
*
* Body: тот же что у /send минус `to` (и amount ИЛИ amountHuman):
* { token?, amount?, amountHuman?, feeTier? }
*/
async estimateSendCost(req: Request, res: Response) {
const chain = String(req.params.chain).toUpperCase();
if (!isChain(chain)) {
res.status(400).json({ success: false, error: 'Invalid chain parameter' });
return;
}
const { token, amount, amountHuman, feeTier } = req.body ?? {};
// Validate token (если задан) + resolve decimals (для validation amountHuman).
let normalizedToken: string | undefined;
if (token !== undefined && token !== null && token !== '') {
if (typeof token !== 'string' || !/^[A-Za-z0-9]{1,10}$/.test(token)) {
res.status(400).json({ success: false, error: 'Invalid token symbol format' });
return;
}
normalizedToken = token.toUpperCase();
}
let resolvedAmount: string;
try {
const dec = resolveSendDecimals(chain, normalizedToken);
resolvedAmount = resolveAmountFromBody({ amount, amountHuman }, dec);
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
return;
}
if (!isValidAmount(resolvedAmount)) {
res.status(400).json({ success: false, error: 'Invalid amount' });
return;
}
let normalizedFeeTier: FeeTier = 'normal';
if (feeTier !== undefined && feeTier !== null && feeTier !== '') {
if (feeTier !== 'slow' && feeTier !== 'normal' && feeTier !== 'fast') {
res.status(400).json({ success: false, error: 'Invalid feeTier (must be slow|normal|fast)' });
return;
}
normalizedFeeTier = feeTier;
}
try {
const estimate = await computeSendCostEstimate({
chain,
token: normalizedToken,
feeTier: normalizedFeeTier,
amountSmallest: resolvedAmount,
});
res.json({ success: true, data: { chain, ...estimate } });
} catch (err: any) {
logger.error(`estimateSendCost ${chain} failed: ${err.stack || err.message}`);
res.status(502).json({ success: false, error: err?.message?.slice?.(0, 200) || 'Estimate failed' });
}
},
/**
* POST /api/wallets/:chain/swap/cost-estimate
*
* Те же поля что у /swap/quote, но возвращает ТОЛЬКО fee + route + approveRequired
* (без quoteId + cache). Идемпотентно, без побочек.
*/
async estimateSwapCost(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: 'Cost-estimate 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 feeAssetSymbolForPrice: string;
if (chain === 'BSC') {
const { from, to, amount, amountHuman, slippageBps, feeTier } = req.body ?? {};
if (typeof from !== 'string' || typeof to !== 'string') {
res.status(400).json({ success: false, error: 'BSC body: {from, to, amount|amountHuman} required' });
return;
}
let resolved: string;
try {
const dec = resolveSwapDecimalsBscTrx('BSC', from);
resolved = resolveAmountFromBody({ amount, amountHuman }, dec);
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
return;
}
raw = await quoteBsc({
fromAddress: wallet.address,
from, to, amount: resolved,
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined,
});
feeAssetSymbolForPrice = 'BNB';
} else if (chain === 'TRX') {
const { from, to, amount, amountHuman, slippageBps } = req.body ?? {};
if (typeof from !== 'string' || typeof to !== 'string') {
res.status(400).json({ success: false, error: 'TRX body: {from, to, amount|amountHuman} required' });
return;
}
let resolved: string;
try {
const dec = resolveSwapDecimalsBscTrx('TRX', from);
resolved = resolveAmountFromBody({ amount, amountHuman }, dec);
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
return;
}
raw = await quoteTrx({
fromAddress: wallet.address,
from, to, amount: resolved,
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
});
feeAssetSymbolForPrice = 'TRX';
} else {
const { inputMint, outputMint, amount, amountHuman, slippageBps } = req.body ?? {};
if (typeof inputMint !== 'string' || typeof outputMint !== 'string') {
res.status(400).json({ success: false, error: 'SOL body: {inputMint, outputMint, amount|amountHuman} required' });
return;
}
let resolved: string;
try {
const dec = resolveSwapDecimalsSol(inputMint);
resolved = resolveAmountFromBody({ amount, amountHuman }, dec);
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
return;
}
raw = await quoteSol({
inputMint, outputMint, amount: resolved,
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
});
feeAssetSymbolForPrice = 'SOL';
}
// USD enrichment fee asset
const priceMap = await getPricesBySymbols([
{ chain, symbol: feeAssetSymbolForPrice },
]).catch(() => new Map<string, number | null>());
const feePriceUsd = priceMap.get(`${chain}:${feeAssetSymbolForPrice}`) ?? null;
const feeAssetDecimals = raw.networkFee.asset === 'TRX' ? 6
: raw.networkFee.asset === 'SOL' ? 9
: 18;
const amountFormatted = formatSmallestUnits(raw.networkFee.amount, feeAssetDecimals);
let amountUsd: number | null = null;
if (feePriceUsd != null) {
try {
const big = BigInt(raw.networkFee.amount);
const divisor = 10n ** BigInt(feeAssetDecimals);
const whole = Number(big / divisor);
const frac = Number(big % divisor) / Number(divisor);
amountUsd = (whole + frac) * feePriceUsd;
} catch { amountUsd = null; }
}
res.json({
success: true,
data: {
chain,
fee: {
asset: raw.networkFee.asset,
amount: raw.networkFee.amount,
amountFormatted,
amountUsd,
},
total: { amountUsd },
route: raw.route,
approveRequired: raw.approveRequired,
estimatedGasUnits: raw.estimatedGasUnits ?? null,
slippageBps: raw.slippageBps,
},
});
} catch (err: any) {
logger.error(`estimateSwapCost ${chain} failed: ${err.stack || err.message}`);
res.status(502).json({ success: false, error: err?.message?.slice?.(0, 200) || 'Estimate failed' });
}
},
/**
* POST /api/wallets/SOL/sign-and-broadcast-tx — custodial sign + broadcast для arbitrary serialized SOL tx.
* Используется для Relay bridge SOL-side (когда Relay /execute возвращает unsigned Solana tx).