swaggerready
This commit is contained in:
@@ -6,6 +6,8 @@ import { ethers } from 'ethers';
|
||||
import { createHash } from 'crypto';
|
||||
import { env } from '../config/env';
|
||||
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
|
||||
import { getPricesBySymbols } from './price-oracle.service';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||
|
||||
@@ -30,6 +32,17 @@ export interface FormattedAmount {
|
||||
raw: string; // smallest units (string-encoded BigInt — без потери точности)
|
||||
formatted: string; // human-readable, e.g. "0.003"
|
||||
decimals: number; // decimals chain'а/токена
|
||||
/**
|
||||
* USD price per 1 целая единица (e.g. $67432.12 за 1 BTC).
|
||||
* `null` если symbol не в реестре с `coingeckoId` или upstream price oracle недоступен.
|
||||
* Источник: CoinGecko free API, cache 5 мин в KeyDB.
|
||||
*/
|
||||
usdPrice: number | null;
|
||||
/**
|
||||
* Совокупная USD-стоимость holding'а: `Number(formatted) × usdPrice`.
|
||||
* Округлено до 8 знаков. `null` если `usdPrice === null` или вычисление дало `Infinity/NaN`.
|
||||
*/
|
||||
usdValue: number | null;
|
||||
}
|
||||
|
||||
export interface BalanceResult {
|
||||
@@ -70,7 +83,67 @@ export function formatUnits(raw: string, decimals: number): string {
|
||||
}
|
||||
|
||||
function fmt(raw: string, decimals: number): FormattedAmount {
|
||||
return { raw, formatted: formatUnits(raw, decimals), decimals };
|
||||
return {
|
||||
raw,
|
||||
formatted: formatUnits(raw, decimals),
|
||||
decimals,
|
||||
usdPrice: null, // populated post-build via populatePrices()
|
||||
usdValue: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Округление USD значения до 8 знаков после запятой (избавляемся от float-мусора).
|
||||
* S10 — `Infinity`/`NaN` → `null`.
|
||||
*/
|
||||
function roundUsd(n: number): number | null {
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return Math.round(n * 1e8) / 1e8;
|
||||
}
|
||||
|
||||
/**
|
||||
* Мутирует BalanceResult, проставляя `usdPrice` и `usdValue` каждому FormattedAmount.
|
||||
* Никогда не throws — если price oracle упал, поля остаются `null`.
|
||||
*/
|
||||
async function populatePrices(result: BalanceResult): Promise<void> {
|
||||
try {
|
||||
const pairs: { chain: ChainCode; symbol: string }[] = [
|
||||
{ chain: result.chain, symbol: result.chain }, // native (BTC/ETH/BSC/TRX/SOL)
|
||||
];
|
||||
if (result.tokens) {
|
||||
for (const sym of Object.keys(result.tokens)) {
|
||||
pairs.push({ chain: result.chain, symbol: sym });
|
||||
}
|
||||
}
|
||||
const prices = await getPricesBySymbols(pairs);
|
||||
|
||||
// Native
|
||||
const nativeKey = `${result.chain}:${result.chain}`;
|
||||
const nativePrice = prices.get(nativeKey) ?? null;
|
||||
result.native.usdPrice = nativePrice;
|
||||
if (nativePrice != null) {
|
||||
const formattedNum = Number(result.native.formatted);
|
||||
result.native.usdValue = Number.isFinite(formattedNum)
|
||||
? roundUsd(formattedNum * nativePrice)
|
||||
: null;
|
||||
}
|
||||
|
||||
// Tokens
|
||||
if (result.tokens) {
|
||||
for (const [sym, amt] of Object.entries(result.tokens)) {
|
||||
const key = `${result.chain}:${sym}`;
|
||||
const p = prices.get(key) ?? null;
|
||||
amt.usdPrice = p;
|
||||
if (p != null) {
|
||||
const fNum = Number(amt.formatted);
|
||||
amt.usdValue = Number.isFinite(fNum) ? roundUsd(fNum * p) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Не валим запрос — balance вернётся без цен.
|
||||
logger.warn(`populatePrices(${result.chain}) failed: ${err?.message || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTokens(
|
||||
@@ -86,20 +159,23 @@ function fmtTokens(
|
||||
|
||||
export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> {
|
||||
const nativeDecimals = NATIVE_DECIMALS[chain];
|
||||
let result: BalanceResult;
|
||||
switch (chain) {
|
||||
case 'BTC':
|
||||
return {
|
||||
result = {
|
||||
chain, address,
|
||||
native: fmt(await btcBalance(address), nativeDecimals),
|
||||
};
|
||||
break;
|
||||
case 'TRX': {
|
||||
const { trx, tokens } = await trxBalance(address);
|
||||
const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals]));
|
||||
return {
|
||||
result = {
|
||||
chain, address,
|
||||
native: fmt(trx, nativeDecimals),
|
||||
tokens: fmtTokens(tokens, decimalsMap),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'BSC':
|
||||
case 'ETH': {
|
||||
@@ -111,22 +187,28 @@ export async function getBalance(chain: ChainCode, address: string): Promise<Bal
|
||||
tokenList.map((t) => ({ symbol: t.symbol, addr: t.contractAddress })),
|
||||
);
|
||||
const decimalsMap = Object.fromEntries(tokenList.map((t) => [t.symbol, t.decimals]));
|
||||
return {
|
||||
result = {
|
||||
chain, address,
|
||||
native: fmt(native, nativeDecimals),
|
||||
tokens: fmtTokens(tokens, decimalsMap),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'SOL': {
|
||||
const { native, tokens } = await solBalance(address);
|
||||
const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals]));
|
||||
return {
|
||||
result = {
|
||||
chain, address,
|
||||
native: fmt(native, nativeDecimals),
|
||||
tokens: fmtTokens(tokens, decimalsMap),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate USD prices (graceful — never throws, fields stay null on failure).
|
||||
await populatePrices(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function btcBalance(address: string): Promise<string> {
|
||||
|
||||
Reference in New Issue
Block a user