init
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user