initjnjnj
This commit is contained in:
@@ -17,6 +17,13 @@ import { saveQuote, getQuote, deleteQuote, QUOTE_TTL_SECONDS, type CachedSwapQuo
|
|||||||
import { generateUlid } from '../utils/ulid';
|
import { generateUlid } from '../utils/ulid';
|
||||||
import { getPricesBySymbols } from '../services/price-oracle.service';
|
import { getPricesBySymbols } from '../services/price-oracle.service';
|
||||||
import { SOL_TOKENS } from '../lib/token-registry';
|
import { SOL_TOKENS } from '../lib/token-registry';
|
||||||
|
import {
|
||||||
|
resolveAmountFromBody,
|
||||||
|
resolveSendDecimals,
|
||||||
|
resolveSwapDecimalsBscTrx,
|
||||||
|
resolveSwapDecimalsSol,
|
||||||
|
formatSmallestUnits,
|
||||||
|
} from '../lib/amount-units';
|
||||||
|
|
||||||
const SOL_WRAPPED_NATIVE_MINT = 'So11111111111111111111111111111111111111112';
|
const SOL_WRAPPED_NATIVE_MINT = 'So11111111111111111111111111111111111111112';
|
||||||
function solMintToSymbol(mint: string): string | null {
|
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);
|
const t = SOL_TOKENS.find((x) => x.mint === mint);
|
||||||
return t?.symbol ?? null;
|
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 { applyEvmTxPolicy } from '../lib/evm-tx-policy';
|
||||||
import { getRelayTrustedAddresses } from '../lib/relay-trusted-cache';
|
import { getRelayTrustedAddresses } from '../lib/relay-trusted-cache';
|
||||||
import { acquireSendLock } from '../lib/send-lock';
|
import { acquireSendLock } from '../lib/send-lock';
|
||||||
@@ -35,6 +42,123 @@ import { logger } from '../lib/logger';
|
|||||||
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
|
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
|
||||||
const MAX_TX_LIMIT = 100;
|
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 {
|
class ConflictError extends Error {
|
||||||
constructor() { super('Wallet already exists'); }
|
constructor() { super('Wallet already exists'); }
|
||||||
}
|
}
|
||||||
@@ -347,16 +471,12 @@ export const WalletController = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { to, amount, token, feeTier } = req.body ?? {};
|
const { to, amount, amountHuman, token, feeTier } = req.body ?? {};
|
||||||
|
|
||||||
if (!isValidAddress(chain, String(to))) {
|
if (!isValidAddress(chain, String(to))) {
|
||||||
res.status(400).json({ success: false, error: 'Invalid recipient address for chain' });
|
res.status(400).json({ success: false, error: 'Invalid recipient address for chain' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isValidAmount(String(amount))) {
|
|
||||||
res.status(400).json({ success: false, error: 'Invalid amount (must be positive integer string)' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalizedToken: string | undefined;
|
let normalizedToken: string | undefined;
|
||||||
if (token !== undefined && token !== null) {
|
if (token !== undefined && token !== null) {
|
||||||
@@ -378,6 +498,22 @@ export const WalletController = {
|
|||||||
normalizedFeeTier = feeTier;
|
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.
|
// C3 — idempotency. Если client передал Idempotency-Key — проверяем retry.
|
||||||
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
|
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
|
||||||
if (idempKey) {
|
if (idempKey) {
|
||||||
@@ -433,7 +569,7 @@ export const WalletController = {
|
|||||||
chain,
|
chain,
|
||||||
mnemonic,
|
mnemonic,
|
||||||
to: String(to),
|
to: String(to),
|
||||||
amount: String(amount),
|
amount: resolvedAmount,
|
||||||
token: normalizedToken,
|
token: normalizedToken,
|
||||||
expectedFromAddress: wallet.address,
|
expectedFromAddress: wallet.address,
|
||||||
feeTier: normalizedFeeTier,
|
feeTier: normalizedFeeTier,
|
||||||
@@ -696,47 +832,71 @@ export const WalletController = {
|
|||||||
let lockedJupiterQuote: any | undefined;
|
let lockedJupiterQuote: any | undefined;
|
||||||
|
|
||||||
if (chain === 'BSC') {
|
if (chain === 'BSC') {
|
||||||
const { from, to, amount, slippageBps, feeTier } = req.body ?? {};
|
const { from, to, amount, amountHuman, slippageBps, feeTier } = req.body ?? {};
|
||||||
if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') {
|
if (typeof from !== 'string' || typeof to !== 'string') {
|
||||||
res.status(400).json({ success: false, error: 'BSC quote body: {from, to, amount} required as strings' });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
raw = await quoteBsc({
|
raw = await quoteBsc({
|
||||||
fromAddress: wallet.address,
|
fromAddress: wallet.address,
|
||||||
from, to, amount,
|
from, to, amount: resolvedAmount,
|
||||||
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
||||||
feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined,
|
feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined,
|
||||||
});
|
});
|
||||||
cacheParams = {
|
cacheParams = {
|
||||||
from, to, amount,
|
from, to, amount: resolvedAmount,
|
||||||
slippageBps: raw.slippageBps,
|
slippageBps: raw.slippageBps,
|
||||||
feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined,
|
feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined,
|
||||||
};
|
};
|
||||||
} else if (chain === 'TRX') {
|
} else if (chain === 'TRX') {
|
||||||
const { from, to, amount, slippageBps } = req.body ?? {};
|
const { from, to, amount, amountHuman, slippageBps } = req.body ?? {};
|
||||||
if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') {
|
if (typeof from !== 'string' || typeof to !== 'string') {
|
||||||
res.status(400).json({ success: false, error: 'TRX quote body: {from, to, amount} required as strings' });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
raw = await quoteTrx({
|
raw = await quoteTrx({
|
||||||
fromAddress: wallet.address,
|
fromAddress: wallet.address,
|
||||||
from, to, amount,
|
from, to, amount: resolvedAmount,
|
||||||
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
||||||
});
|
});
|
||||||
cacheParams = { from, to, amount, slippageBps: raw.slippageBps };
|
cacheParams = { from, to, amount: resolvedAmount, slippageBps: raw.slippageBps };
|
||||||
} else {
|
} else {
|
||||||
const { inputMint, outputMint, amount, slippageBps } = req.body ?? {};
|
const { inputMint, outputMint, amount, amountHuman, slippageBps } = req.body ?? {};
|
||||||
if (typeof inputMint !== 'string' || typeof outputMint !== 'string' || typeof amount !== 'string') {
|
if (typeof inputMint !== 'string' || typeof outputMint !== 'string') {
|
||||||
res.status(400).json({ success: false, error: 'SOL quote body: {inputMint, outputMint, amount} required as strings' });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const solQuote = await quoteSol({
|
const solQuote = await quoteSol({
|
||||||
inputMint, outputMint, amount,
|
inputMint, outputMint, amount: resolvedAmount,
|
||||||
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
||||||
});
|
});
|
||||||
raw = solQuote;
|
raw = solQuote;
|
||||||
lockedJupiterQuote = solQuote.jupiterQuoteResponse;
|
lockedJupiterQuote = solQuote.jupiterQuoteResponse;
|
||||||
cacheParams = { inputMint, outputMint, amount, slippageBps: solQuote.slippageBps };
|
cacheParams = { inputMint, outputMint, amount: resolvedAmount, slippageBps: solQuote.slippageBps };
|
||||||
}
|
}
|
||||||
|
|
||||||
// USD enrichment (graceful — null если CoinGecko недоступен).
|
// USD enrichment (graceful — null если CoinGecko недоступен).
|
||||||
@@ -952,42 +1112,52 @@ export const WalletController = {
|
|||||||
const params = cachedQuote
|
const params = cachedQuote
|
||||||
? cachedQuote.params
|
? cachedQuote.params
|
||||||
: req.body ?? {};
|
: req.body ?? {};
|
||||||
const { from, to, amount, slippageBps, feeTier } = params;
|
const { from, to, amount, amountHuman, slippageBps, feeTier } = params;
|
||||||
if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') {
|
if (typeof from !== 'string' || typeof to !== 'string') {
|
||||||
throw new Error('BSC swap body: {quoteId} OR {from, to, amount} required');
|
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({
|
result = await executeBsc({
|
||||||
mnemonic,
|
mnemonic,
|
||||||
expectedFromAddress: wallet.address,
|
expectedFromAddress: wallet.address,
|
||||||
from, to, amount,
|
from, to, amount: amountForExec,
|
||||||
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
||||||
feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined,
|
feeTier: feeTier === 'slow' || feeTier === 'normal' || feeTier === 'fast' ? feeTier : undefined,
|
||||||
lockedMinOut: cachedQuote?.locked.minOut,
|
lockedMinOut: cachedQuote?.locked.minOut,
|
||||||
});
|
});
|
||||||
} else if (chain === 'TRX') {
|
} else if (chain === 'TRX') {
|
||||||
const params = cachedQuote ? cachedQuote.params : req.body ?? {};
|
const params = cachedQuote ? cachedQuote.params : req.body ?? {};
|
||||||
const { from, to, amount, slippageBps } = params;
|
const { from, to, amount, amountHuman, slippageBps } = params;
|
||||||
if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') {
|
if (typeof from !== 'string' || typeof to !== 'string') {
|
||||||
throw new Error('TRX swap body: {quoteId} OR {from, to, amount} required');
|
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({
|
result = await executeTrx({
|
||||||
mnemonic,
|
mnemonic,
|
||||||
expectedFromAddress: wallet.address,
|
expectedFromAddress: wallet.address,
|
||||||
from, to, amount,
|
from, to, amount: amountForExec,
|
||||||
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
||||||
lockedMinOut: cachedQuote?.locked.minOut,
|
lockedMinOut: cachedQuote?.locked.minOut,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// SOL Jupiter
|
// SOL Jupiter
|
||||||
const params = cachedQuote ? cachedQuote.params : req.body ?? {};
|
const params = cachedQuote ? cachedQuote.params : req.body ?? {};
|
||||||
const { inputMint, outputMint, amount, slippageBps } = params;
|
const { inputMint, outputMint, amount, amountHuman, slippageBps } = params;
|
||||||
if (typeof inputMint !== 'string' || typeof outputMint !== 'string' || typeof amount !== 'string') {
|
if (typeof inputMint !== 'string' || typeof outputMint !== 'string') {
|
||||||
throw new Error('SOL swap body: {quoteId} OR {inputMint, outputMint, amount} required');
|
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({
|
result = await executeSol({
|
||||||
mnemonic,
|
mnemonic,
|
||||||
expectedFromAddress: wallet.address,
|
expectedFromAddress: wallet.address,
|
||||||
inputMint, outputMint, amount,
|
inputMint, outputMint, amount: amountForExec,
|
||||||
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
slippageBps: typeof slippageBps === 'number' ? slippageBps : undefined,
|
||||||
jupiterQuoteResponse: cachedQuote?.locked.jupiterQuoteResponse,
|
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.
|
* POST /api/wallets/SOL/sign-and-broadcast-tx — custodial sign + broadcast для arbitrary serialized SOL tx.
|
||||||
* Используется для Relay bridge SOL-side (когда Relay /execute возвращает unsigned Solana tx).
|
* Используется для Relay bridge SOL-side (когда Relay /execute возвращает unsigned Solana tx).
|
||||||
|
|||||||
222
apps/api/src/lib/amount-units.ts
Normal file
222
apps/api/src/lib/amount-units.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* Amount unit utilities.
|
||||||
|
*
|
||||||
|
* API контракт исторически — `amount: string` в smallest-units (wei/lamports/sun/satoshi).
|
||||||
|
* Этот файл добавляет ОПЦИОНАЛЬНЫЙ парсинг `amountHuman: "0.01"` через token decimals из
|
||||||
|
* `token-registry`. Старое поле `amount` остаётся 100% backward-compatible.
|
||||||
|
*
|
||||||
|
* Все вычисления — BigInt-based, без float'ов (для finance: precision critical).
|
||||||
|
*
|
||||||
|
* Используется в:
|
||||||
|
* - sendFromChain (body: {amount | amountHuman, token?})
|
||||||
|
* - quoteSwap / swapOnChain legacy (body: {from/inputMint, amount | amountHuman})
|
||||||
|
* - relay-proxy /quote preprocessing (body: {originCurrency, amount | amountHuman})
|
||||||
|
* - cost-estimate endpoints (body: same)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChainCode } from './address-validators';
|
||||||
|
import {
|
||||||
|
getTokenInfo,
|
||||||
|
getEvmTokens,
|
||||||
|
TRX_TOKENS,
|
||||||
|
SOL_TOKENS,
|
||||||
|
} from './token-registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native decimals per chain. Дублирует `NATIVE_DECIMALS` из `wallet-ops.service.ts`
|
||||||
|
* чтобы избежать circular dep (wallet-ops импортирует address-validators которое
|
||||||
|
* импортирует этот файл косвенно).
|
||||||
|
*/
|
||||||
|
export const NATIVE_DECIMALS: Record<ChainCode, number> = {
|
||||||
|
ETH: 18,
|
||||||
|
BSC: 18,
|
||||||
|
BTC: 8,
|
||||||
|
TRX: 6,
|
||||||
|
SOL: 9,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOL_WRAPPED_NATIVE_MINT = 'So11111111111111111111111111111111111111112';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит "0.01" → "10000000" (для SOL 9 decimals) через BigInt.
|
||||||
|
*
|
||||||
|
* Правила:
|
||||||
|
* - "10" + dec=6 → "10000000" (integer, без точки)
|
||||||
|
* - "0.01" + dec=6 → "10000" (1 + 4 zeros)
|
||||||
|
* - "1.5" + dec=6 → "1500000"
|
||||||
|
* - "0.000001" + dec=6 → "1"
|
||||||
|
* - "0.1234567" + dec=6 → "123456" (truncate — не round; consistent с frontend parseAmount)
|
||||||
|
* - "0" + → throw (zero amount = error per existing isValidAmount)
|
||||||
|
* - "-1" / "1e3" + → throw
|
||||||
|
* - "0.1" + dec=0 → throw "no fractional digits for 0-decimal token"
|
||||||
|
*/
|
||||||
|
export function parseHumanAmount(human: string, decimals: number): string {
|
||||||
|
if (typeof human !== 'string') {
|
||||||
|
throw new Error('amountHuman must be a string');
|
||||||
|
}
|
||||||
|
const s = human.trim();
|
||||||
|
if (!s) throw new Error('amountHuman is empty');
|
||||||
|
// Defense-in-depth: длинная строка (например `"1" + "0".repeat(10000)`) форсирует
|
||||||
|
// O(n) парсинг + BigInt round-trip. 64KB body-limit (express.json) — общий gate;
|
||||||
|
// этот check — specific для нового парсера. Legit amount'ы укладываются в 80 chars
|
||||||
|
// (36-decimal fractional + integer + dot). Атакующий не сможет drain CPU.
|
||||||
|
if (s.length > 80) {
|
||||||
|
throw new Error('amountHuman too long (max 80 chars)');
|
||||||
|
}
|
||||||
|
if (!/^\d+(\.\d+)?$/.test(s)) {
|
||||||
|
throw new Error(`amountHuman invalid format "${s}" (expected "1" or "0.01")`);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) {
|
||||||
|
throw new Error(`Invalid decimals ${decimals}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [whole, frac = ''] = s.split('.');
|
||||||
|
if (decimals === 0 && frac.length > 0) {
|
||||||
|
throw new Error(`This token has 0 decimals, use integer amount (got "${s}")`);
|
||||||
|
}
|
||||||
|
// Truncate (not round) лишние цифры дробной части.
|
||||||
|
const fracTrunc = frac.slice(0, decimals);
|
||||||
|
const padded = fracTrunc.padEnd(decimals, '0');
|
||||||
|
// Strip leading zeros чтобы получился чистый BigInt-friendly string.
|
||||||
|
const result = (whole + padded).replace(/^0+/, '') || '0';
|
||||||
|
|
||||||
|
if (result === '0') {
|
||||||
|
throw new Error(`amountHuman "${s}" evaluates to 0 smallest units`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse of parseHumanAmount. "10000000" + dec=6 → "10".
|
||||||
|
* Используется для логирования / formatted output в cost-estimate.
|
||||||
|
*/
|
||||||
|
export function formatSmallestUnits(smallest: string, decimals: number): string {
|
||||||
|
if (typeof smallest !== 'string' || !/^\d+$/.test(smallest)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
if (decimals === 0) return smallest;
|
||||||
|
if (smallest.length <= decimals) {
|
||||||
|
const padded = smallest.padStart(decimals + 1, '0');
|
||||||
|
const whole = padded.slice(0, padded.length - decimals);
|
||||||
|
const frac = padded.slice(padded.length - decimals).replace(/0+$/, '');
|
||||||
|
return frac ? `${whole}.${frac}` : whole;
|
||||||
|
}
|
||||||
|
const whole = smallest.slice(0, smallest.length - decimals);
|
||||||
|
const frac = smallest.slice(smallest.length - decimals).replace(/0+$/, '');
|
||||||
|
return frac ? `${whole}.${frac}` : whole;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decimals для send endpoint'а.
|
||||||
|
* - token задан → token-registry lookup (case-insensitive)
|
||||||
|
* - token пуст → native decimals
|
||||||
|
*/
|
||||||
|
export function resolveSendDecimals(chain: ChainCode, token?: string | null): number {
|
||||||
|
if (!token) return NATIVE_DECIMALS[chain];
|
||||||
|
const info = getTokenInfo(chain, token);
|
||||||
|
if (!info) {
|
||||||
|
throw new Error(`Unknown token "${token}" on ${chain} — cannot resolve decimals`);
|
||||||
|
}
|
||||||
|
return info.decimals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decimals для BSC/TRX swap (`from` symbol).
|
||||||
|
* - "BNB" / "TRX" → native (18 / 6)
|
||||||
|
* - "USDT" / etc → registry lookup
|
||||||
|
*/
|
||||||
|
export function resolveSwapDecimalsBscTrx(chain: 'BSC' | 'TRX', symbol: string): number {
|
||||||
|
const upper = symbol.toUpperCase();
|
||||||
|
if (upper === chain) return NATIVE_DECIMALS[chain]; // BSC→BNB через chain code не сработает,
|
||||||
|
// но BNB→18 и TRX→6 совпадают с NATIVE_DECIMALS, поэтому fallback ниже работает.
|
||||||
|
if (chain === 'BSC' && upper === 'BNB') return NATIVE_DECIMALS.BSC;
|
||||||
|
if (chain === 'TRX' && upper === 'TRX') return NATIVE_DECIMALS.TRX;
|
||||||
|
const info = getTokenInfo(chain, upper);
|
||||||
|
if (!info) {
|
||||||
|
throw new Error(`Unknown ${chain} token "${symbol}" — cannot resolve decimals`);
|
||||||
|
}
|
||||||
|
return info.decimals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decimals для SOL swap (`inputMint`).
|
||||||
|
* Wrapped SOL = 9; иначе SOL_TOKENS lookup по mint.
|
||||||
|
*/
|
||||||
|
export function resolveSwapDecimalsSol(mint: string): number {
|
||||||
|
if (mint === SOL_WRAPPED_NATIVE_MINT) return NATIVE_DECIMALS.SOL;
|
||||||
|
const t = SOL_TOKENS.find((x) => x.mint === mint);
|
||||||
|
if (!t) {
|
||||||
|
throw new Error(`Unknown SOL mint "${mint}" — cannot resolve decimals`);
|
||||||
|
}
|
||||||
|
return t.decimals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decimals по contract address (для Relay /quote где body содержит
|
||||||
|
* `originCurrency: "0x..."` вместо symbol). Returns null если не найден —
|
||||||
|
* caller решает: 400 vs fallback.
|
||||||
|
*/
|
||||||
|
export function getDecimalsByContract(chain: ChainCode, contractOrMint: string): number | null {
|
||||||
|
const addr = contractOrMint.trim();
|
||||||
|
if (!addr) return null;
|
||||||
|
|
||||||
|
// Native sentinels (Relay использует 0xeeee... для native EVM).
|
||||||
|
if (chain === 'SOL' && addr === SOL_WRAPPED_NATIVE_MINT) return NATIVE_DECIMALS.SOL;
|
||||||
|
if ((chain === 'ETH' || chain === 'BSC') &&
|
||||||
|
/^0xee+/i.test(addr) || addr === '0x0000000000000000000000000000000000000000') {
|
||||||
|
return NATIVE_DECIMALS[chain];
|
||||||
|
}
|
||||||
|
if (chain === 'TRX' && (addr === 'TRX' || addr === '0x0000000000000000000000000000000000000000')) {
|
||||||
|
return NATIVE_DECIMALS.TRX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chain === 'ETH' || chain === 'BSC') {
|
||||||
|
const lower = addr.toLowerCase();
|
||||||
|
const t = getEvmTokens(chain).find((x) => x.contractAddress.toLowerCase() === lower);
|
||||||
|
return t?.decimals ?? null;
|
||||||
|
}
|
||||||
|
if (chain === 'TRX') {
|
||||||
|
const t = TRX_TOKENS.find((x) => x.contractAddress === addr);
|
||||||
|
return t?.decimals ?? null;
|
||||||
|
}
|
||||||
|
if (chain === 'SOL') {
|
||||||
|
const t = SOL_TOKENS.find((x) => x.mint === addr);
|
||||||
|
return t?.decimals ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main dispatcher. Body содержит ровно ОДНО поле из {amount, amountHuman}.
|
||||||
|
* - оба пусты → throw
|
||||||
|
* - оба заданы → throw "use either … not both" (поведение из плана)
|
||||||
|
* - amount задан → возврат as-is (legacy backward-compat)
|
||||||
|
* - amountHuman задан → parseHumanAmount(value, decimals)
|
||||||
|
*
|
||||||
|
* Caller передаёт `decimals` (уже resolved через resolveSendDecimals / resolveSwapDecimals*).
|
||||||
|
*/
|
||||||
|
export function resolveAmountFromBody(
|
||||||
|
body: { amount?: unknown; amountHuman?: unknown },
|
||||||
|
decimals: number,
|
||||||
|
): string {
|
||||||
|
const hasAmount = body?.amount !== undefined && body?.amount !== null && body?.amount !== '';
|
||||||
|
const hasAmountHuman = body?.amountHuman !== undefined && body?.amountHuman !== null && body?.amountHuman !== '';
|
||||||
|
|
||||||
|
if (hasAmount && hasAmountHuman) {
|
||||||
|
throw new Error('Use either "amount" (smallest units) OR "amountHuman" (human form), not both');
|
||||||
|
}
|
||||||
|
if (!hasAmount && !hasAmountHuman) {
|
||||||
|
throw new Error('Either "amount" or "amountHuman" is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAmount) {
|
||||||
|
const a = String(body.amount);
|
||||||
|
if (!/^\d+$/.test(a) || BigInt(a) <= 0n) {
|
||||||
|
throw new Error('amount must be positive integer string (smallest units)');
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// amountHuman path
|
||||||
|
return parseHumanAmount(String(body.amountHuman), decimals);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { WalletModel } from '../models/wallet.model';
|
|||||||
import type { ChainCode } from '../lib/address-validators';
|
import type { ChainCode } from '../lib/address-validators';
|
||||||
import { indexRelayExecuteResponse } from '../lib/relay-trusted-cache';
|
import { indexRelayExecuteResponse } from '../lib/relay-trusted-cache';
|
||||||
import { proxiedFetch } from '../lib/outbound-proxy';
|
import { proxiedFetch } from '../lib/outbound-proxy';
|
||||||
|
import { getDecimalsByContract, parseHumanAmount } from '../lib/amount-units';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const RELAY_API_URL = 'https://api.relay.link';
|
const RELAY_API_URL = 'https://api.relay.link';
|
||||||
@@ -21,7 +22,9 @@ const RELAY_CHAINID_TO_CHAIN: Record<number, ChainCode> = {
|
|||||||
// Без него `req.path` после `/execute/` свободен → `/execute/../admin` обходит startsWith-check.
|
// Без него `req.path` после `/execute/` свободен → `/execute/../admin` обходит startsWith-check.
|
||||||
// Relay API: `POST /quote` (раньше был `GET /quote/v2` — upstream сменили в 2025).
|
// Relay API: `POST /quote` (раньше был `GET /quote/v2` — upstream сменили в 2025).
|
||||||
const ALLOWED_GET_PATHS = new Set(['/intents/status/v3']);
|
const ALLOWED_GET_PATHS = new Set(['/intents/status/v3']);
|
||||||
const ALLOWED_POST_PATHS = new Set(['/quote']);
|
// `/cost-estimate` — LOCAL alias (not a Relay endpoint). Internally calls Relay /quote и
|
||||||
|
// фильтрует response — отдаёт только fees + details (без steps[]).
|
||||||
|
const ALLOWED_POST_PATHS = new Set(['/quote', '/cost-estimate']);
|
||||||
const ALLOWED_EXECUTE_ACTIONS = new Set([
|
const ALLOWED_EXECUTE_ACTIONS = new Set([
|
||||||
'swap',
|
'swap',
|
||||||
'bridge',
|
'bridge',
|
||||||
@@ -55,10 +58,14 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect local-only /cost-estimate endpoint — internally forwarded к Relay /quote,
|
||||||
|
// response trimmed (без steps[]).
|
||||||
|
const isCostEstimate = req.method === 'POST' && relayPath === '/cost-estimate';
|
||||||
|
|
||||||
// C16 — bind body.user / body.recipient to JWT user's wallet.
|
// C16 — bind body.user / body.recipient to JWT user's wallet.
|
||||||
// Без этого authenticated user может set recipient=attacker → Relay строит quote →
|
// Без этого authenticated user может set recipient=attacker → Relay строит quote →
|
||||||
// victim signs → bridge funds к attacker'у.
|
// victim signs → bridge funds к attacker'у.
|
||||||
if (req.method === 'POST' && (relayPath === '/quote' || relayPath.startsWith('/execute/'))) {
|
if (req.method === 'POST' && (relayPath === '/quote' || relayPath === '/cost-estimate' || relayPath.startsWith('/execute/'))) {
|
||||||
const userId = (req as any).auth?.userId;
|
const userId = (req as any).auth?.userId;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
res.status(401).json({ success: false, error: 'auth required' });
|
res.status(401).json({ success: false, error: 'auth required' });
|
||||||
@@ -114,7 +121,45 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const relayUrl = new URL(`${RELAY_API_URL}${relayPath}`);
|
// ADDITIVE: amountHuman preprocessing для /quote, /cost-estimate, /execute/*.
|
||||||
|
// Если body содержит amountHuman → разрешаем через originCurrency contract → decimals.
|
||||||
|
// Старое поле `amount` (smallest units) продолжает работать unchanged.
|
||||||
|
if (req.method === 'POST' &&
|
||||||
|
(relayPath === '/quote' || relayPath === '/cost-estimate' || relayPath.startsWith('/execute/'))) {
|
||||||
|
const body = req.body ?? {};
|
||||||
|
const hasAmount = body.amount !== undefined && body.amount !== null && body.amount !== '';
|
||||||
|
const hasAmountHuman = body.amountHuman !== undefined && body.amountHuman !== null && body.amountHuman !== '';
|
||||||
|
if (hasAmount && hasAmountHuman) {
|
||||||
|
res.status(400).json({ success: false, error: 'Use either "amount" or "amountHuman", not both' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasAmountHuman) {
|
||||||
|
const originCurrency = String(body.originCurrency ?? '');
|
||||||
|
const originChainId = Number(body.originChainId);
|
||||||
|
const originChain = RELAY_CHAINID_TO_CHAIN[originChainId];
|
||||||
|
if (!originChain) {
|
||||||
|
res.status(400).json({ success: false, error: `originChainId ${originChainId} not in allowlist (needed для amountHuman → decimals)` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dec = getDecimalsByContract(originChain, originCurrency);
|
||||||
|
if (dec == null) {
|
||||||
|
res.status(400).json({ success: false, error: `Unknown originCurrency "${originCurrency}" — supply "amount" (smallest units) directly` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resolved = parseHumanAmount(String(body.amountHuman), dec);
|
||||||
|
req.body.amount = resolved;
|
||||||
|
delete req.body.amountHuman;
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ success: false, error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map local /cost-estimate → real Relay /quote endpoint.
|
||||||
|
const upstreamPath = isCostEstimate ? '/quote' : relayPath;
|
||||||
|
const relayUrl = new URL(`${RELAY_API_URL}${upstreamPath}`);
|
||||||
|
|
||||||
Object.entries(req.query).forEach(([key, value]) => {
|
Object.entries(req.query).forEach(([key, value]) => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
@@ -169,6 +214,42 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /cost-estimate — trim response (только fees + details, без steps[]).
|
||||||
|
if (isCostEstimate) {
|
||||||
|
let trimmed: any;
|
||||||
|
try {
|
||||||
|
const full = JSON.parse(text);
|
||||||
|
const fees = full?.fees ?? {};
|
||||||
|
let totalUsd: number | null = 0;
|
||||||
|
for (const k of ['gas', 'relayer', 'app']) {
|
||||||
|
const u = Number(fees?.[k]?.amountUsd);
|
||||||
|
if (Number.isFinite(u)) totalUsd += u;
|
||||||
|
else { totalUsd = null; break; }
|
||||||
|
}
|
||||||
|
trimmed = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
fees: {
|
||||||
|
gas: fees.gas ?? null,
|
||||||
|
relayer: fees.relayer ?? null,
|
||||||
|
app: fees.app ?? null,
|
||||||
|
total: { amountUsd: totalUsd },
|
||||||
|
},
|
||||||
|
rate: full?.details?.rate ?? null,
|
||||||
|
priceImpactPct: full?.details?.totalImpact?.percent ?? null,
|
||||||
|
priceImpactUsd: full?.details?.totalImpact?.usd ?? null,
|
||||||
|
timeEstimate: full?.details?.timeEstimate ?? null,
|
||||||
|
currencyIn: full?.details?.currencyIn ?? null,
|
||||||
|
currencyOut: full?.details?.currencyOut ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
trimmed = { success: false, error: 'Relay returned non-JSON for /cost-estimate' };
|
||||||
|
}
|
||||||
|
res.json(trimmed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Send raw text если это валидный JSON, иначе обернём
|
// Send raw text если это валидный JSON, иначе обернём
|
||||||
try {
|
try {
|
||||||
res.send(text);
|
res.send(text);
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ router.get('/portfolio', WalletController.getPortfolio);
|
|||||||
router.get('/:chain/balance', WalletController.getChainBalance);
|
router.get('/:chain/balance', WalletController.getChainBalance);
|
||||||
router.get('/:chain/transactions', WalletController.getChainTransactions);
|
router.get('/:chain/transactions', WalletController.getChainTransactions);
|
||||||
router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions);
|
router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions);
|
||||||
|
// IMPORTANT: more specific paths ДОЛЖНЫ быть зарегистрированы РАНЬШЕ — Express сматчит first.
|
||||||
|
// /:chain/send/cost-estimate ПЕРЕД /:chain/send
|
||||||
|
// /:chain/swap/quote ПЕРЕД /:chain/swap
|
||||||
|
// /:chain/swap/cost-estimate ПЕРЕД /:chain/swap
|
||||||
|
router.post('/:chain/send/cost-estimate', WalletController.estimateSendCost);
|
||||||
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/quote', WalletController.quoteSwap);
|
||||||
|
router.post('/:chain/swap/cost-estimate', WalletController.estimateSwapCost);
|
||||||
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);
|
||||||
|
|
||||||
|
|||||||
@@ -426,8 +426,7 @@
|
|||||||
"SendRequest": {
|
"SendRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"to",
|
"to"
|
||||||
"amount"
|
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"to": {
|
"to": {
|
||||||
@@ -452,6 +451,11 @@
|
|||||||
],
|
],
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"description": "Default 'normal'. ETH/BSC: eth_feeHistory p25/p50/p75 priority. BTC: blockstream targets 144/6/1 блок. TRX/SOL: игнорится."
|
"description": "Default 'normal'. ETH/BSC: eth_feeHistory p25/p50/p75 priority. BTC: blockstream targets 144/6/1 блок. TRX/SOL: игнорится."
|
||||||
|
},
|
||||||
|
"amountHuman": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.",
|
||||||
|
"example": "0.01"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -702,6 +706,196 @@
|
|||||||
"description": "EVM gas units (BSC). Null для TRX/SOL."
|
"description": "EVM gas units (BSC). Null для TRX/SOL."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"SendCostEstimateResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"chain": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "BSC"
|
||||||
|
},
|
||||||
|
"fee": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"asset": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "BNB"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "65000000000000"
|
||||||
|
},
|
||||||
|
"amountFormatted": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "0.000065"
|
||||||
|
},
|
||||||
|
"amountUsd": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
|
"example": 0.04
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"amountUsd": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
|
"example": 0.04
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"breakdown": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SwapCostEstimateResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"chain": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "BSC"
|
||||||
|
},
|
||||||
|
"fee": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"asset": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "BNB"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amountFormatted": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amountUsd": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"amountUsd": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"route": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"example": [
|
||||||
|
"USDT",
|
||||||
|
"BNB"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"approveRequired": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"estimatedGasUnits": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"slippageBps": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"BridgeCostEstimateResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"fees": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"gas": {
|
||||||
|
"type": "object",
|
||||||
|
"nullable": true,
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"relayer": {
|
||||||
|
"type": "object",
|
||||||
|
"nullable": true,
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"type": "object",
|
||||||
|
"nullable": true,
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"amountUsd": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rate": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"priceImpactPct": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"priceImpactUsd": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"timeEstimate": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Estimate в секундах"
|
||||||
|
},
|
||||||
|
"currencyIn": {
|
||||||
|
"type": "object",
|
||||||
|
"nullable": true,
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"currencyOut": {
|
||||||
|
"type": "object",
|
||||||
|
"nullable": true,
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1312,6 +1506,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "ULID quote id, полученный от POST /:chain/swap/quote.",
|
"description": "ULID quote id, полученный от POST /:chain/swap/quote.",
|
||||||
"example": "q_01KRKD8GA4XZJ5W4E7VFT2N9M3"
|
"example": "q_01KRKD8GA4XZJ5W4E7VFT2N9M3"
|
||||||
|
},
|
||||||
|
"amountHuman": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.",
|
||||||
|
"example": "0.01"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1346,6 +1545,11 @@
|
|||||||
"normal",
|
"normal",
|
||||||
"fast"
|
"fast"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"amountHuman": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.",
|
||||||
|
"example": "0.01"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1372,6 +1576,11 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 1000,
|
"maximum": 1000,
|
||||||
"default": 50
|
"default": 50
|
||||||
|
},
|
||||||
|
"amountHuman": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.",
|
||||||
|
"example": "0.01"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1823,6 +2032,11 @@
|
|||||||
"EXACT_INPUT",
|
"EXACT_INPUT",
|
||||||
"EXACT_OUTPUT"
|
"EXACT_OUTPUT"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"amountHuman": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.",
|
||||||
|
"example": "0.01"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2068,6 +2282,11 @@
|
|||||||
"fast"
|
"fast"
|
||||||
],
|
],
|
||||||
"description": "BSC only"
|
"description": "BSC only"
|
||||||
|
},
|
||||||
|
"amountHuman": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.",
|
||||||
|
"example": "0.01"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2098,6 +2317,11 @@
|
|||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 1000,
|
"maximum": 1000,
|
||||||
"default": 50
|
"default": 50
|
||||||
|
},
|
||||||
|
"amountHuman": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.",
|
||||||
|
"example": "0.01"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2140,6 +2364,285 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/wallets/{chain}/send/cost-estimate": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Estimate USD cost of a /send call (read-only, без broadcast)",
|
||||||
|
"description": "Read-only USD-оценка сколько будет стоить broadcast tx (gas/network fee).\n\nНе дёргает mnemonic, не резервирует idempotency cache, не делает RPC broadcast.\n\nBody — те же поля что у /send МИНУС `to`. Можно прислать `amount` (smallest units, legacy) ИЛИ `amountHuman` (\"0.01\").",
|
||||||
|
"tags": [
|
||||||
|
"Wallet Ops"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "chain",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"ETH",
|
||||||
|
"BSC",
|
||||||
|
"BTC",
|
||||||
|
"TRX",
|
||||||
|
"SOL"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"token": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Token symbol (USDT, USDC, ...). Пусто = native."
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Smallest units (legacy)"
|
||||||
|
},
|
||||||
|
"amountHuman": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.",
|
||||||
|
"example": "0.01"
|
||||||
|
},
|
||||||
|
"feeTier": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"slow",
|
||||||
|
"normal",
|
||||||
|
"fast"
|
||||||
|
],
|
||||||
|
"default": "normal",
|
||||||
|
"description": "EVM only (ETH/BSC); ignored для TRX/SOL/BTC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Cost estimate",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SendCostEstimateResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Validation error"
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Gas oracle / price oracle unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/wallets/{chain}/swap/cost-estimate": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Estimate USD cost of a swap (без cache, без quoteId)",
|
||||||
|
"description": "Те же поля что у /swap/quote, но возвращает ТОЛЬКО fee + route + approveRequired (без quoteId/expiry/cache).\n\nIdempotent — можно вызывать много раз. Используется для отображения USD-цены свапа в UI ДО того как юзер решит подтвердить.",
|
||||||
|
"tags": [
|
||||||
|
"Wallet Ops"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "chain",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"BSC",
|
||||||
|
"TRX",
|
||||||
|
"SOL"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"title": "BSC/TRX",
|
||||||
|
"properties": {
|
||||||
|
"from": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Smallest units (legacy)"
|
||||||
|
},
|
||||||
|
"amountHuman": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.",
|
||||||
|
"example": "0.01"
|
||||||
|
},
|
||||||
|
"slippageBps": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 1000,
|
||||||
|
"default": 50
|
||||||
|
},
|
||||||
|
"feeTier": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"slow",
|
||||||
|
"normal",
|
||||||
|
"fast"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"title": "SOL",
|
||||||
|
"properties": {
|
||||||
|
"inputMint": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"outputMint": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Smallest units (legacy)"
|
||||||
|
},
|
||||||
|
"amountHuman": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.",
|
||||||
|
"example": "0.01"
|
||||||
|
},
|
||||||
|
"slippageBps": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 1000,
|
||||||
|
"default": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Swap cost estimate",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SwapCostEstimateResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Validation error"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Wallet not found"
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Upstream RPC / quote failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/relay/cost-estimate": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Estimate USD cost of a bridge (Relay quote — trimmed, без steps[])",
|
||||||
|
"description": "Вызывает Relay /quote внутри и фильтрует response — отдаёт только fees + details (rate, time, impact, currencyIn/Out).\n\nБез `steps[]` (которые тяжёлые и содержат unsigned txs). Поведение JWT-binding (body.user, body.recipient) — то же что у /relay/quote.",
|
||||||
|
"tags": [
|
||||||
|
"Bridge (Relay)"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"user": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Sender address (должен совпадать с user's wallet origin chain)"
|
||||||
|
},
|
||||||
|
"recipient": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"originChainId": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "1=ETH, 56=BSC, 792703809=SOL"
|
||||||
|
},
|
||||||
|
"destinationChainId": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"originCurrency": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Contract address (для EVM) или mint (для SOL)"
|
||||||
|
},
|
||||||
|
"destinationCurrency": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Smallest units (legacy)"
|
||||||
|
},
|
||||||
|
"amountHuman": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable amount (e.g. \"0.01\"). Server конвертит в smallest units через token decimals. Используется ВМЕСТО поля `amount` — НЕ передавай оба одновременно.",
|
||||||
|
"example": "0.01"
|
||||||
|
},
|
||||||
|
"tradeType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"EXACT_INPUT",
|
||||||
|
"EXACT_OUTPUT"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Bridge cost estimate",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BridgeCostEstimateResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Validation error / unknown originCurrency для amountHuman"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "body.user/recipient не совпадает с user wallets"
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Relay upstream error"
|
||||||
|
},
|
||||||
|
"504": {
|
||||||
|
"description": "Relay timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user