feat: security audit fixes
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { ethers } from 'ethers';
|
||||
import { env } from '../config/env';
|
||||
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
|
||||
|
||||
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||
|
||||
@@ -16,10 +17,6 @@ const BSC_RPC = 'https://bsc-dataseed.binance.org';
|
||||
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
|
||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||||
|
||||
const USDT_TRC20 = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
||||
const USDT_BEP20 = '0x55d398326f99059fF775485246999027B3197955';
|
||||
const USDT_ERC20 = '0xdAC17F958D2ee523a2206206994597C13D831ec7';
|
||||
|
||||
const ERC20_ABI = [
|
||||
'function balanceOf(address owner) view returns (uint256)',
|
||||
'function transfer(address to, uint256 amount) returns (bool)',
|
||||
@@ -28,31 +25,106 @@ const ERC20_ABI = [
|
||||
|
||||
// ─────────────────────── BALANCE ───────────────────────
|
||||
|
||||
export interface FormattedAmount {
|
||||
raw: string; // smallest units (string-encoded BigInt — без потери точности)
|
||||
formatted: string; // human-readable, e.g. "0.003"
|
||||
decimals: number; // decimals chain'а/токена
|
||||
}
|
||||
|
||||
export interface BalanceResult {
|
||||
chain: ChainCode;
|
||||
address: string;
|
||||
native: string; // в smallest units (satoshi/wei/lamports/sun)
|
||||
tokens?: Record<string, string>; // например { USDT: "12345678" }
|
||||
native: FormattedAmount;
|
||||
tokens?: Record<string, FormattedAmount>;
|
||||
}
|
||||
|
||||
// Native decimals per chain
|
||||
const NATIVE_DECIMALS: Record<ChainCode, number> = {
|
||||
ETH: 18, // wei
|
||||
BSC: 18, // wei (BNB)
|
||||
BTC: 8, // satoshi
|
||||
TRX: 6, // sun
|
||||
SOL: 9, // lamports
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert raw bigint string in smallest units → human-readable decimal string.
|
||||
* Без потери точности (string-based, не Number/Float).
|
||||
*
|
||||
* formatUnits("3000000000000000", 18) → "0.003"
|
||||
* formatUnits("1500000", 6) → "1.5"
|
||||
* formatUnits("123456", 0) → "123456"
|
||||
*/
|
||||
export function formatUnits(raw: string, decimals: number): string {
|
||||
if (!/^-?\d+$/.test(raw)) return '0';
|
||||
if (decimals === 0) return raw;
|
||||
|
||||
const negative = raw.startsWith('-');
|
||||
const abs = negative ? raw.slice(1) : raw;
|
||||
const padded = abs.padStart(decimals + 1, '0');
|
||||
const whole = padded.slice(0, padded.length - decimals);
|
||||
const frac = padded.slice(-decimals).replace(/0+$/, '');
|
||||
const result = frac ? `${whole}.${frac}` : whole;
|
||||
return negative ? `-${result}` : result;
|
||||
}
|
||||
|
||||
function fmt(raw: string, decimals: number): FormattedAmount {
|
||||
return { raw, formatted: formatUnits(raw, decimals), decimals };
|
||||
}
|
||||
|
||||
function fmtTokens(
|
||||
raw: Record<string, string>,
|
||||
decimalsLookup: Record<string, number>,
|
||||
): Record<string, FormattedAmount> {
|
||||
const out: Record<string, FormattedAmount> = {};
|
||||
for (const [sym, val] of Object.entries(raw)) {
|
||||
out[sym] = fmt(val, decimalsLookup[sym] ?? 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> {
|
||||
const nativeDecimals = NATIVE_DECIMALS[chain];
|
||||
switch (chain) {
|
||||
case 'BTC':
|
||||
return { chain, address, native: await btcBalance(address) };
|
||||
return {
|
||||
chain, address,
|
||||
native: fmt(await btcBalance(address), nativeDecimals),
|
||||
};
|
||||
case 'TRX': {
|
||||
const { trx, usdt } = await trxBalance(address);
|
||||
return { chain, address, native: trx, tokens: { USDT: usdt } };
|
||||
}
|
||||
case 'BSC': {
|
||||
const { native, tokens } = await evmBalance(BSC_RPC, address, [{ symbol: 'USDT', addr: USDT_BEP20 }]);
|
||||
return { chain, address, native, tokens };
|
||||
const { trx, tokens } = await trxBalance(address);
|
||||
const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals]));
|
||||
return {
|
||||
chain, address,
|
||||
native: fmt(trx, nativeDecimals),
|
||||
tokens: fmtTokens(tokens, decimalsMap),
|
||||
};
|
||||
}
|
||||
case 'BSC':
|
||||
case 'ETH': {
|
||||
const { native, tokens } = await evmBalance(ETH_RPC, address, [{ symbol: 'USDT', addr: USDT_ERC20 }]);
|
||||
return { chain, address, native, tokens };
|
||||
const tokenList = getEvmTokens(chain);
|
||||
const rpc = chain === 'BSC' ? BSC_RPC : ETH_RPC;
|
||||
const { native, tokens } = await evmBalance(
|
||||
rpc,
|
||||
address,
|
||||
tokenList.map((t) => ({ symbol: t.symbol, addr: t.contractAddress })),
|
||||
);
|
||||
const decimalsMap = Object.fromEntries(tokenList.map((t) => [t.symbol, t.decimals]));
|
||||
return {
|
||||
chain, address,
|
||||
native: fmt(native, nativeDecimals),
|
||||
tokens: fmtTokens(tokens, decimalsMap),
|
||||
};
|
||||
}
|
||||
case 'SOL': {
|
||||
const { native, tokens } = await solBalance(address);
|
||||
const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals]));
|
||||
return {
|
||||
chain, address,
|
||||
native: fmt(native, nativeDecimals),
|
||||
tokens: fmtTokens(tokens, decimalsMap),
|
||||
};
|
||||
}
|
||||
case 'SOL':
|
||||
return { chain, address, native: await solBalance(address) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,29 +135,40 @@ async function btcBalance(address: string): Promise<string> {
|
||||
return sat.toString();
|
||||
}
|
||||
|
||||
async function trxBalance(address: string): Promise<{ trx: string; usdt: string }> {
|
||||
async function trxBalance(address: string): Promise<{ trx: string; tokens: Record<string, string> }> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
const accRes = await fetchJson(`${TRONGRID}/v1/accounts/${address}`, { headers });
|
||||
const trx = accRes.data?.[0]?.balance ? String(accRes.data[0].balance) : '0';
|
||||
|
||||
// USDT TRC20 balance
|
||||
const usdtRes = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
owner_address: address,
|
||||
contract_address: USDT_TRC20,
|
||||
function_selector: 'balanceOf(address)',
|
||||
parameter: tronAddressToHex(address).padStart(64, '0'),
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
const usdtHex = usdtRes.constant_result?.[0];
|
||||
const usdt = usdtHex && !/^0+$/.test(usdtHex) ? BigInt('0x' + usdtHex).toString() : '0';
|
||||
// Parallel TRC20 balanceOf для всех зарегистрированных токенов
|
||||
const tokens: Record<string, string> = {};
|
||||
const addrHex = tronAddressToHex(address).padStart(64, '0');
|
||||
|
||||
return { trx, usdt };
|
||||
await Promise.all(
|
||||
getTrxTokens().map(async ({ symbol, contractAddress }) => {
|
||||
try {
|
||||
const res = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
owner_address: address,
|
||||
contract_address: contractAddress,
|
||||
function_selector: 'balanceOf(address)',
|
||||
parameter: addrHex,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
const hex = res.constant_result?.[0];
|
||||
tokens[symbol] = hex && !/^0+$/.test(hex) ? BigInt('0x' + hex).toString() : '0';
|
||||
} catch {
|
||||
tokens[symbol] = '0';
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { trx, tokens };
|
||||
}
|
||||
|
||||
async function evmBalance(
|
||||
@@ -112,8 +195,9 @@ async function evmBalance(
|
||||
return { native: native.toString(), tokens: tokenBalances };
|
||||
}
|
||||
|
||||
async function solBalance(address: string): Promise<string> {
|
||||
const res = await fetchJson(SOL_RPC, {
|
||||
async function solBalance(address: string): Promise<{ native: string; tokens: Record<string, string> }> {
|
||||
// 1) Native SOL balance
|
||||
const nativeRes = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -123,7 +207,48 @@ async function solBalance(address: string): Promise<string> {
|
||||
params: [address],
|
||||
}),
|
||||
});
|
||||
return String(res.result?.value ?? 0);
|
||||
const native = String(nativeRes.result?.value ?? 0);
|
||||
|
||||
// 2) Все SPL token accounts юзера одним запросом
|
||||
const tokens: Record<string, string> = {};
|
||||
const knownMints = new Map(getSolTokens().map((t) => [t.mint, t.symbol]));
|
||||
|
||||
// Сразу инициализируем все известные символы нулём (чтобы output был consistent)
|
||||
for (const [, sym] of knownMints) tokens[sym] = '0';
|
||||
|
||||
try {
|
||||
const splRes = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getTokenAccountsByOwner',
|
||||
params: [
|
||||
address,
|
||||
{ programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' }, // SPL Token program
|
||||
{ encoding: 'jsonParsed' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const accounts = splRes.result?.value || [];
|
||||
for (const acc of accounts) {
|
||||
const info = acc?.account?.data?.parsed?.info;
|
||||
const mint = info?.mint;
|
||||
const amount = info?.tokenAmount?.amount;
|
||||
if (mint && amount && knownMints.has(mint)) {
|
||||
const symbol = knownMints.get(mint)!;
|
||||
// Суммируем если несколько token accounts для одного mint
|
||||
const prev = BigInt(tokens[symbol] || '0');
|
||||
tokens[symbol] = (prev + BigInt(amount)).toString();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// SOL RPC недоступен — оставляем нули
|
||||
}
|
||||
|
||||
return { native, tokens };
|
||||
}
|
||||
|
||||
// ─────────────────────── TRANSACTIONS ───────────────────────
|
||||
|
||||
Reference in New Issue
Block a user