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).

View 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);
}

View File

@@ -5,6 +5,7 @@ import { WalletModel } from '../models/wallet.model';
import type { ChainCode } from '../lib/address-validators';
import { indexRelayExecuteResponse } from '../lib/relay-trusted-cache';
import { proxiedFetch } from '../lib/outbound-proxy';
import { getDecimalsByContract, parseHumanAmount } from '../lib/amount-units';
const router = Router();
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.
// Relay API: `POST /quote` (раньше был `GET /quote/v2` — upstream сменили в 2025).
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([
'swap',
'bridge',
@@ -55,10 +58,14 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
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.
// Без этого authenticated user может set recipient=attacker → Relay строит quote →
// 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;
if (!userId) {
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]) => {
if (Array.isArray(value)) {
@@ -169,6 +214,42 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
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, иначе обернём
try {
res.send(text);

View File

@@ -13,11 +13,15 @@ router.get('/portfolio', WalletController.getPortfolio);
router.get('/:chain/balance', WalletController.getChainBalance);
router.get('/:chain/transactions', WalletController.getChainTransactions);
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/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/cost-estimate', WalletController.estimateSwapCost);
router.post('/:chain/swap', WalletController.swapOnChain);
router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx);

View File

@@ -426,8 +426,7 @@
"SendRequest": {
"type": "object",
"required": [
"to",
"amount"
"to"
],
"properties": {
"to": {
@@ -452,6 +451,11 @@
],
"nullable": true,
"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."
}
}
},
"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",
"description": "ULID quote id, полученный от POST /:chain/swap/quote.",
"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",
"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,
"maximum": 1000,
"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_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"
],
"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,
"maximum": 1000,
"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"
}
}
}
}
}
}