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

@@ -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);