This commit is contained in:
ZOMBIIIIIII
2026-05-28 13:51:30 +03:00
parent d2086b86e3
commit e86ff7c063
25 changed files with 4214 additions and 437 deletions

View File

@@ -11,6 +11,7 @@ import { Request, Response } from 'express';
import { getCoingeckoId } from '../lib/token-registry';
import { ALL_CHAINS } from '../services/wallet-generator.service';
import { getPricesBySymbols } from '../services/price-oracle.service';
// getPricesWithChangeByIds импортируется dynamic'но в getDynamics handler ниже.
import type { ChainCode } from '../lib/address-validators';
import { logger } from '../lib/logger';
@@ -135,4 +136,87 @@ export const PricesController = {
res.status(502).json({ success: false, error: 'Upstream price oracle error' });
}
},
/**
* GET /api/prices/dynamics?symbols=BTC,ETH,BNB,SOL,TRX
*
* Возвращает USD-цену + 24h % изменения для списка symbols.
* Default symbols (если query не задан): BTC,ETH,BNB,SOL,TRX.
* Source: CoinGecko `include_24hr_change=true` (rolling 24h, не anchored).
*
* Response 200:
* { success: true, data: { "BTC": { "usd": 67432.12, "change24h": -1.38 }, ... } }
*/
async getDynamics(req: Request, res: Response) {
try {
const rawSymbols = String(req.query.symbols || '').trim();
const symbols = rawSymbols
? rawSymbols.split(',').map((s) => s.trim().toUpperCase()).filter((s) => s.length > 0)
: ['BTC', 'ETH', 'BNB', 'SOL', 'TRX'];
if (symbols.length === 0) {
res.status(400).json({ success: false, error: 'symbols list is empty' });
return;
}
if (symbols.length > MAX_SYMBOLS_PER_REQUEST) {
res.status(400).json({
success: false,
error: `Too many symbols (max ${MAX_SYMBOLS_PER_REQUEST})`,
});
return;
}
for (const s of symbols) {
if (!SYMBOL_RE.test(s)) {
res.status(400).json({ success: false, error: `Invalid symbol: ${s}` });
return;
}
}
// Resolve каждый symbol в CoinGecko id напрямую.
// Native tickers: BTC=bitcoin, ETH=ethereum, BNB=binancecoin, SOL=solana, TRX=tron.
// Для non-native: пытаемся getCoingeckoId через chain fallback.
const NATIVE_TICKER_TO_COINGECKO: Record<string, string> = {
BTC: 'bitcoin',
ETH: 'ethereum',
BNB: 'binancecoin',
SOL: 'solana',
TRX: 'tron',
};
const fallbackChains: ChainCode[] = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC'];
const symbolToCgId = new Map<string, string>();
for (const sym of symbols) {
let cgId: string | null = NATIVE_TICKER_TO_COINGECKO[sym] ?? null;
if (!cgId) {
for (const c of fallbackChains) {
const id = getCoingeckoId(c, sym);
if (id) { cgId = id; break; }
}
}
if (!cgId) {
res.status(400).json({ success: false, error: `Unknown symbol: ${sym}` });
return;
}
symbolToCgId.set(sym, cgId);
}
const { getPricesWithChangeByIds } = await import('../services/price-oracle.service');
const rich = await getPricesWithChangeByIds(Array.from(new Set(symbolToCgId.values())));
const data: Record<string, { usd: number | null; change24h: number | null }> = {};
for (const sym of symbols) {
const cgId = symbolToCgId.get(sym)!;
const v = rich[cgId];
data[sym] = {
usd: v?.usd ?? null,
change24h: v?.change24h ?? null,
};
}
res.json({ success: true, data });
} catch (err: any) {
logger.error(`getDynamics failed: ${err?.stack || err?.message || 'unknown'}`);
res.status(502).json({ success: false, error: 'Upstream price oracle error' });
}
},
};

View File

@@ -6,7 +6,7 @@ import { getBalance, getTransactions, getPortfolio as getPortfolioService } from
import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service';
import { signAndBroadcast, signAndBroadcastRawEvm, signAndBroadcastSolanaTx, signAndBroadcastSolanaInstructions } from '../services/wallet-signer.service';
import { signAndBroadcast, signAndBroadcastRawEvm, signAndBroadcastSolanaTx, signAndBroadcastSolanaInstructions, signAndBroadcastBscFeeTx } from '../services/wallet-signer.service';
import {
quoteBsc, executeBsc,
quoteTrx, executeTrx,
@@ -659,7 +659,8 @@ export const WalletController = {
return;
}
const { to, data, value, chainId, gas, maxFeePerGas, maxPriorityFeePerGas, feeTier } = req.body ?? {};
const { to, data, value, chainId, gas, maxFeePerGas, maxPriorityFeePerGas, feeTier,
bridgeAmount, bridgeToken } = req.body ?? {};
let normalizedFeeTier: FeeTier | undefined;
if (feeTier !== undefined && feeTier !== null) {
@@ -670,6 +671,25 @@ export const WalletController = {
normalizedFeeTier = feeTier;
}
// BSC bridge fee 0.7% — optional, только если chain==='BSC' и bridgeAmount задан.
// Защита: bridgeToken должен быть либо undefined/null, либо валидный 0x-адрес.
let bscFeeBridgeAmount: string | null = null;
let bscFeeBridgeToken: string | null = null;
if (bridgeAmount !== undefined && bridgeAmount !== null && bridgeAmount !== '') {
if (typeof bridgeAmount !== 'string' || !/^\d+$/.test(bridgeAmount) || BigInt(bridgeAmount) <= 0n) {
res.status(400).json({ success: false, error: 'Invalid bridgeAmount (must be positive integer string)' });
return;
}
bscFeeBridgeAmount = bridgeAmount;
if (bridgeToken !== undefined && bridgeToken !== null && bridgeToken !== '') {
if (typeof bridgeToken !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(bridgeToken)) {
res.status(400).json({ success: false, error: 'Invalid bridgeToken address' });
return;
}
bscFeeBridgeToken = bridgeToken;
}
}
// Базовая структурная валидация — детальные cap'ы внутри signer'а.
if (typeof to !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(to)) {
res.status(400).json({ success: false, error: 'Invalid "to" address' });
@@ -756,6 +776,35 @@ export const WalletController = {
mnemonic = decryptMnemonic(blob);
// ── BSC bridge app fee 0.7% (off-chain double-tx) — только когда chain='BSC' + bridgeAmount задан ──
let feeTxid: string | undefined;
let feeAmountStr: string | undefined;
if (chain === 'BSC' && bscFeeBridgeAmount) {
try {
const feeResult = await signAndBroadcastBscFeeTx({
mnemonic,
expectedFromAddress: wallet.address,
bridgeAmount: bscFeeBridgeAmount,
bridgeToken: bscFeeBridgeToken,
feeTier: normalizedFeeTier,
});
feeTxid = feeResult.feeTxid;
feeAmountStr = feeResult.feeAmount;
// Audit fee event (best-effort, не blocking).
auditLog({
event: 'wallet.bsc_fee',
userId,
ip: req.ip || null,
result: 'success',
meta: { chain: 'BSC', bridgeAmount: bscFeeBridgeAmount, bridgeToken: bscFeeBridgeToken,
feeAmount: feeAmountStr, feeTxid },
}).catch(() => {});
} catch (feeErr: any) {
await completeAudit(auditId, 'failure', undefined, 'BSC_FEE_TX_FAILED');
throw new Error(`BSC fee tx failed (main tx NOT broadcast): ${feeErr.message}`);
}
}
let result: { txid: string };
try {
result = await signAndBroadcastRawEvm({
@@ -778,8 +827,8 @@ export const WalletController = {
throw signErr;
}
await completeAudit(auditId, 'success', { txid: result.txid });
res.json({ success: true, data: { txid: result.txid, chain } });
await completeAudit(auditId, 'success', { txid: result.txid, feeTxid });
res.json({ success: true, data: { txid: result.txid, chain, feeTxid, feeAmount: feeAmountStr } });
} catch (err: any) {
logger.error(`signRawEvmTx failed for user ${userId} chain ${chain}: ${err.stack || err.message}`);
await auditLog({
@@ -987,8 +1036,14 @@ export const WalletController = {
amountFormatted: fmtUnits(raw.networkFee.amount, networkFeeAssetDecimals),
amountUsd: networkFeeUsd,
},
// App fee 0.7% (BSC only) — сервер шлёт это через off-chain double-tx ПЕРЕД swap.
app: raw.appFee ? {
asset: raw.appFee.asset,
amount: raw.appFee.amount,
amountFormatted: fmtUnits(raw.appFee.amount, raw.fromDecimals),
recipient: raw.appFee.recipient,
} : null,
// dex fee включён в expectedOut (Pancake 0.25%, SunSwap 0.3%+0.7% fee router, Jupiter platform varied).
// Не вычисляем отдельно — слишком много moving parts.
total: { amountUsd: networkFeeUsd },
},
@@ -1522,4 +1577,164 @@ export const WalletController = {
}
}
},
/**
* POST /api/wallets/{chain}/app-fee
*
* Standalone app fee transfer endpoint. Used by Relay frontend hook ПОСЛЕ successful
* Relay execute (frontend explicitly invokes этот endpoint чтобы взимать 0.7% fee).
*
* Body: { amount: "smallest units string", token?: "USDT" }
* Headers: Authorization + optional Idempotency-Key
*
* Server-side:
* 1. JWT-bind: user must have wallet for chain
* 2. Compute fee = amount * 70 / 10000
* 3. signAndBroadcast({chain, to: APP_FEE_WALLET_<chain>, amount: fee, token? })
* 4. Audit + idempotency
*
* НЕ задевает /relay/*, /jumper/*, sign-raw-evm-tx, sign-and-broadcast-tx.
* Просто standard custodial transfer на hardcoded recipient через existing helper.
*/
async appFeeTransfer(req: Request, res: Response) {
const userId = req.auth!.userId;
const chainParam = String(req.params.chain || '').toUpperCase();
const ALLOWED_FEE_CHAINS = new Set(['ETH', 'BSC', 'SOL', 'TRX']);
if (!ALLOWED_FEE_CHAINS.has(chainParam)) {
res.status(400).json({ success: false, error: `Chain ${chainParam} doesn't support app fee (allowed: ETH, BSC, SOL, TRX)` });
return;
}
const chain = chainParam as ChainCode;
const body = req.body || {};
const amountStr = String(body.amount || '');
if (!/^\d+$/.test(amountStr) || amountStr === '0') {
res.status(400).json({ success: false, error: 'amount must be positive integer string (smallest units)' });
return;
}
const tokenSymbol = body.token ? String(body.token).toUpperCase() : undefined;
// Lazy import to avoid circular deps + to keep this handler self-contained
const { computeAppFee, getAppFeeWallet } = await import('../lib/app-fee');
let feeAmountBig: bigint;
try {
feeAmountBig = computeAppFee(amountStr);
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
return;
}
if (feeAmountBig <= 0n) {
res.status(400).json({ success: false, error: 'amount too small — fee = 0' });
return;
}
const feeWallet = getAppFeeWallet(chain);
// C3 — idempotency
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
if (idempKey) {
try {
const claim = await claimIdempotency(userId, idempKey, req.body);
if (!claim.fresh && claim.cached) {
res.status(claim.cached.status).type('application/json').send(claim.cached.body);
return;
}
} catch (err: any) {
res.status(409).json({ success: false, error: err.message });
return;
}
}
// Per-user-per-chain mutex (same pattern as send)
const releaseLock = await acquireSendLock(userId, chain);
let mnemonic: string | null = null;
try {
const wallet = await WalletModel.findByUserAndChain(userId, chain);
if (!wallet) {
res.status(404).json({ success: false, error: `No ${chain} wallet for user` });
return;
}
const blob = await UserModel.getEncryptedMnemonic(userId);
if (!blob) {
res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' });
return;
}
// Audit row BEFORE broadcast
let auditId: string;
try {
auditId = await auditLogStrict({
event: 'wallet.app_fee',
userId,
ip: req.ip || null,
meta: {
chain,
amount: amountStr,
feeAmount: feeAmountBig.toString(),
feeWallet,
token: tokenSymbol,
source: 'standalone',
},
});
} catch (auditErr: any) {
logger.error(`Audit DB INSERT MUST succeed for wallet.app_fee: ${auditErr.message}`);
res.status(503).json({ success: false, error: 'Audit service unavailable' });
return;
}
mnemonic = decryptMnemonic(blob);
let txid: string;
try {
const sendRes = await signAndBroadcast({
chain,
mnemonic,
expectedFromAddress: wallet.address,
to: feeWallet,
amount: feeAmountBig.toString(),
token: tokenSymbol,
});
txid = sendRes.txid;
} catch (sendErr: any) {
await completeAudit(auditId, 'failure', undefined, 'BROADCAST_FAILED');
throw sendErr;
}
await completeAudit(auditId, 'success');
logger.info(`app fee broadcast: user=${userId} chain=${chain} amount=${amountStr} fee=${feeAmountBig}${feeWallet} txid=${txid}`);
const respBody = {
success: true,
data: {
feeTxid: txid,
feeAmount: feeAmountBig.toString(),
feeWallet,
chain,
token: tokenSymbol,
},
};
if (idempKey) {
try {
await saveIdempotencyResponse(userId, idempKey, 200, JSON.stringify(respBody));
} catch { /* ignore */ }
}
res.status(200).json(respBody);
} catch (err: any) {
logger.error(`appFeeTransfer failed for user ${userId} chain ${chain}: ${err?.stack || err?.message}`);
const respBody = {
success: false,
error: err?.message?.slice(0, 200) || 'app fee transfer failed',
};
if (idempKey) {
try {
await saveIdempotencyResponse(userId, idempKey, 502, JSON.stringify(respBody));
} catch { /* ignore */ }
}
res.status(502).json(respBody);
} finally {
mnemonic = null;
releaseLock();
}
},
};