From 4ff256e53fea8d6243ce20838fb46c153a07ebb1 Mon Sep 17 00:00:00 2001 From: Gleiser Oliveira Date: Tue, 26 May 2026 19:23:58 -0600 Subject: [PATCH 1/7] feat: add Stats - Overview --- apps/evm/src/pages/Stats/index.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/evm/src/pages/Stats/index.tsx b/apps/evm/src/pages/Stats/index.tsx index ccc90a269a..2ef3276835 100644 --- a/apps/evm/src/pages/Stats/index.tsx +++ b/apps/evm/src/pages/Stats/index.tsx @@ -1,10 +1,13 @@ import { Page, Tabs } from 'components'; import { useTranslation } from 'libs/translations'; +import { EModeTable } from './EModeTable'; import { Header } from './Header'; import { HistoricalDominanceChart } from './HistoricalDominanceChart'; import { HistoricalLiquidityChart } from './HistoricalLiquidityChart'; import { HistoricalMarketChart } from './HistoricalMarketChart'; import { MarketKpis } from './MarketKpis'; +import { MarketsTable } from './MarketsTable'; +import { RiskParametersTable } from './RiskParametersTable'; import { TopWallets } from './TopWallets'; import { TransactionsVolume } from './TransactionsVolume'; import { WalletKpis } from './WalletKpis'; @@ -27,6 +30,15 @@ const Overview: React.FC = () => ( ); +const Markets: React.FC = () => ( +
+ + + + +
+); + const Stats: React.FC = () => { const { t } = useTranslation(); @@ -39,7 +51,7 @@ const Stats: React.FC = () => { navType="searchParam" tabs={[ { id: 'overview', title: t('statsPage.tabs.overview'), content: }, - { id: 'markets', title: t('statsPage.tabs.markets'), content: null }, + { id: 'markets', title: t('statsPage.tabs.markets'), content: }, { id: 'wallets', title: t('statsPage.tabs.wallets'), content: null }, { id: 'liquidations', title: t('statsPage.tabs.liquidations'), content: null }, ]} From d64b339c92470c0bb3f1b390eceecd871254dc0c Mon Sep 17 00:00:00 2001 From: Gleiser Oliveira Date: Wed, 27 May 2026 20:32:54 -0600 Subject: [PATCH 2/7] chore: add changeset --- .changeset/angry-experts-grow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/angry-experts-grow.md diff --git a/.changeset/angry-experts-grow.md b/.changeset/angry-experts-grow.md new file mode 100644 index 0000000000..948f4f3a4b --- /dev/null +++ b/.changeset/angry-experts-grow.md @@ -0,0 +1,5 @@ +--- +"@venusprotocol/evm": minor +--- + +Add Stats page - Markets tab From 1500a9ea37353d81cc01249c5b7e7a9867589e87 Mon Sep 17 00:00:00 2001 From: Gleiser Oliveira Date: Wed, 27 May 2026 20:33:23 -0600 Subject: [PATCH 3/7] feat: add getRiskDashboardMarkets and getRiskDashboardEModePools --- apps/evm/src/clients/api/index.ts | 4 ++ .../clients/api/queries/fetchRiskDashboard.ts | 35 +++++++++++ .../getRiskDashboardEModePools/index.ts | 26 ++++++++ .../useGetRiskDashboardEModePools.ts | 29 +++++++++ .../queries/getRiskDashboardMarkets/index.ts | 63 +++++++++++++++++++ .../useGetRiskDashboardMarkets.ts | 29 +++++++++ apps/evm/src/constants/functionKey.ts | 2 + 7 files changed, 188 insertions(+) create mode 100644 apps/evm/src/clients/api/queries/fetchRiskDashboard.ts create mode 100644 apps/evm/src/clients/api/queries/getRiskDashboardEModePools/index.ts create mode 100644 apps/evm/src/clients/api/queries/getRiskDashboardEModePools/useGetRiskDashboardEModePools.ts create mode 100644 apps/evm/src/clients/api/queries/getRiskDashboardMarkets/index.ts create mode 100644 apps/evm/src/clients/api/queries/getRiskDashboardMarkets/useGetRiskDashboardMarkets.ts diff --git a/apps/evm/src/clients/api/index.ts b/apps/evm/src/clients/api/index.ts index e03cea4a83..234be237af 100644 --- a/apps/evm/src/clients/api/index.ts +++ b/apps/evm/src/clients/api/index.ts @@ -270,6 +270,10 @@ export * from './queries/getTradeReduceSwapQuotes/useGetTradeReduceSwapQuotes'; export * from './queries/getRiskDashboardMarketAggregates'; export * from './queries/getRiskDashboardMarketAggregates/useGetRiskDashboardMarketAggregates'; +export * from './queries/getRiskDashboardMarkets'; +export * from './queries/getRiskDashboardMarkets/useGetRiskDashboardMarkets'; +export * from './queries/getRiskDashboardEModePools'; +export * from './queries/getRiskDashboardEModePools/useGetRiskDashboardEModePools'; export * from './queries/getRiskDashboardMarketSnapshots'; export * from './queries/getRiskDashboardMarketSnapshots/useGetRiskDashboardMarketSnapshots'; export * from './queries/getRiskDashboardWalletAggregates'; diff --git a/apps/evm/src/clients/api/queries/fetchRiskDashboard.ts b/apps/evm/src/clients/api/queries/fetchRiskDashboard.ts new file mode 100644 index 0000000000..6809dd5b17 --- /dev/null +++ b/apps/evm/src/clients/api/queries/fetchRiskDashboard.ts @@ -0,0 +1,35 @@ +import { VError } from 'libs/errors'; +import type { ChainId } from 'types'; +import { restService } from 'utilities'; + +export async function fetchRiskDashboard({ + endpoint, + chainId, + params, +}: { + endpoint: string; + chainId: ChainId; + params?: Record; +}) { + const response = await restService({ + endpoint, + method: 'GET', + params: { chainId, ...params }, + }); + + const payload = response.data; + + if (payload && 'error' in payload) { + throw new VError({ + type: 'unexpected', + code: 'somethingWentWrong', + data: { exception: payload.error }, + }); + } + + if (!payload) { + throw new VError({ type: 'unexpected', code: 'somethingWentWrong' }); + } + + return payload; +} diff --git a/apps/evm/src/clients/api/queries/getRiskDashboardEModePools/index.ts b/apps/evm/src/clients/api/queries/getRiskDashboardEModePools/index.ts new file mode 100644 index 0000000000..d2eba02e5a --- /dev/null +++ b/apps/evm/src/clients/api/queries/getRiskDashboardEModePools/index.ts @@ -0,0 +1,26 @@ +import type { ChainId } from 'types'; +import type { Address } from 'viem'; +import { fetchRiskDashboard } from '../fetchRiskDashboard'; + +export interface ApiRiskDashboardEModeSetting { + poolId: string; + label: string; + isActive: boolean; + marketAddress: Address; + collateralFactorMantissa: string; + liquidationThresholdMantissa: string; + liquidationIncentiveMantissa: string; + canBeCollateral: boolean; + isBorrowable: boolean; +} + +export interface GetRiskDashboardEModePoolsResponse { + chainId: string; + settings: ApiRiskDashboardEModeSetting[]; +} + +export const getRiskDashboardEModePools = ({ chainId }: { chainId: ChainId }) => + fetchRiskDashboard({ + endpoint: '/risk-dashboard/emode-pools', + chainId, + }); diff --git a/apps/evm/src/clients/api/queries/getRiskDashboardEModePools/useGetRiskDashboardEModePools.ts b/apps/evm/src/clients/api/queries/getRiskDashboardEModePools/useGetRiskDashboardEModePools.ts new file mode 100644 index 0000000000..40a752a7f0 --- /dev/null +++ b/apps/evm/src/clients/api/queries/getRiskDashboardEModePools/useGetRiskDashboardEModePools.ts @@ -0,0 +1,29 @@ +import { type QueryObserverOptions, useQuery } from '@tanstack/react-query'; + +import FunctionKey from 'constants/functionKey'; +import { useChainId } from 'libs/wallet'; +import type { ChainId } from 'types'; +import { type GetRiskDashboardEModePoolsResponse, getRiskDashboardEModePools } from '.'; + +export type UseGetRiskDashboardEModePoolsQueryKey = [ + FunctionKey.GET_RISK_DASHBOARD_EMODE_POOLS, + { chainId: ChainId }, +]; + +type Options = QueryObserverOptions< + GetRiskDashboardEModePoolsResponse, + Error, + GetRiskDashboardEModePoolsResponse, + GetRiskDashboardEModePoolsResponse, + UseGetRiskDashboardEModePoolsQueryKey +>; + +export const useGetRiskDashboardEModePools = (options?: Partial) => { + const { chainId } = useChainId(); + + return useQuery({ + queryKey: [FunctionKey.GET_RISK_DASHBOARD_EMODE_POOLS, { chainId }], + queryFn: () => getRiskDashboardEModePools({ chainId }), + ...options, + }); +}; diff --git a/apps/evm/src/clients/api/queries/getRiskDashboardMarkets/index.ts b/apps/evm/src/clients/api/queries/getRiskDashboardMarkets/index.ts new file mode 100644 index 0000000000..e779483bfb --- /dev/null +++ b/apps/evm/src/clients/api/queries/getRiskDashboardMarkets/index.ts @@ -0,0 +1,63 @@ +import { VError } from 'libs/errors'; +import type { ChainId } from 'types'; +import { restService } from 'utilities'; +import type { Address } from 'viem'; +import type { ApiRiskDashboardAsOf } from '../getRiskDashboardMarketAggregates'; + +export interface ApiRiskDashboardMarket { + marketAddress: Address; + underlyingAddress: Address | null; + vTokenDecimals: number; + totalSupplyUsdCents: string; + totalBorrowsUsdCents: string; + liquidityUsdCents: string; + supplyCapUsdCents: string | null; + borrowCapUsdCents: string | null; + priceMantissa: string; + supplyRatePerBlockMantissa: string; + borrowRatePerBlockMantissa: string; + collateralFactorMantissa: string; + reserveFactorMantissa: string; + liquidationThresholdMantissa: string; + liquidationIncentiveMantissa: string; + topSupplierUsdCents: string | null; + topBorrowerUsdCents: string | null; + totalDebtAgainstUsdCents: string | null; + stablecoinDebtUsdCents: string | null; + bnbDebtUsdCents: string | null; + otherDebtUsdCents: string | null; +} + +export interface GetRiskDashboardMarketsInput { + chainId: ChainId; +} + +export interface GetRiskDashboardMarketsResponse { + chainId: string; + asOf: ApiRiskDashboardAsOf | null; + markets: ApiRiskDashboardMarket[]; +} + +export async function getRiskDashboardMarkets({ chainId }: GetRiskDashboardMarketsInput) { + const response = await restService({ + endpoint: '/risk-dashboard/markets', + method: 'GET', + params: { chainId }, + }); + + const payload = response.data; + + if (payload && 'error' in payload) { + throw new VError({ + type: 'unexpected', + code: 'somethingWentWrong', + data: { exception: payload.error }, + }); + } + + if (!payload) { + throw new VError({ type: 'unexpected', code: 'somethingWentWrong' }); + } + + return payload; +} diff --git a/apps/evm/src/clients/api/queries/getRiskDashboardMarkets/useGetRiskDashboardMarkets.ts b/apps/evm/src/clients/api/queries/getRiskDashboardMarkets/useGetRiskDashboardMarkets.ts new file mode 100644 index 0000000000..b91707789c --- /dev/null +++ b/apps/evm/src/clients/api/queries/getRiskDashboardMarkets/useGetRiskDashboardMarkets.ts @@ -0,0 +1,29 @@ +import { type QueryObserverOptions, useQuery } from '@tanstack/react-query'; + +import FunctionKey from 'constants/functionKey'; +import { useChainId } from 'libs/wallet'; +import type { ChainId } from 'types'; +import { type GetRiskDashboardMarketsResponse, getRiskDashboardMarkets } from '.'; + +export type UseGetRiskDashboardMarketsQueryKey = [ + FunctionKey.GET_RISK_DASHBOARD_MARKETS, + { chainId: ChainId }, +]; + +type Options = QueryObserverOptions< + GetRiskDashboardMarketsResponse, + Error, + GetRiskDashboardMarketsResponse, + GetRiskDashboardMarketsResponse, + UseGetRiskDashboardMarketsQueryKey +>; + +export const useGetRiskDashboardMarkets = (options?: Partial) => { + const { chainId } = useChainId(); + + return useQuery({ + queryKey: [FunctionKey.GET_RISK_DASHBOARD_MARKETS, { chainId }], + queryFn: () => getRiskDashboardMarkets({ chainId }), + ...options, + }); +}; diff --git a/apps/evm/src/constants/functionKey.ts b/apps/evm/src/constants/functionKey.ts index 606ceec3f4..05c08f1fd1 100644 --- a/apps/evm/src/constants/functionKey.ts +++ b/apps/evm/src/constants/functionKey.ts @@ -81,6 +81,8 @@ enum FunctionKey { GET_TOKEN_PAIR_K_LINE_CANDLES = 'GET_TOKEN_PAIR_K_LINE_CANDLES', GET_TRADE_REDUCE_SWAP_QUOTES = 'GET_TRADE_REDUCE_SWAP_QUOTES', GET_RISK_DASHBOARD_MARKET_AGGREGATES = 'GET_RISK_DASHBOARD_MARKET_AGGREGATES', + GET_RISK_DASHBOARD_MARKETS = 'GET_RISK_DASHBOARD_MARKETS', + GET_RISK_DASHBOARD_EMODE_POOLS = 'GET_RISK_DASHBOARD_EMODE_POOLS', GET_RISK_DASHBOARD_MARKET_SNAPSHOTS = 'GET_RISK_DASHBOARD_MARKET_SNAPSHOTS', GET_RISK_DASHBOARD_WALLET_AGGREGATES = 'GET_RISK_DASHBOARD_WALLET_AGGREGATES', GET_RISK_DASHBOARD_TOP_WALLETS = 'GET_RISK_DASHBOARD_TOP_WALLETS', From 74f561a8b548ee528c451cfb73145cbdb271c00b Mon Sep 17 00:00:00 2001 From: Gleiser Oliveira Date: Wed, 27 May 2026 20:36:07 -0600 Subject: [PATCH 4/7] feat: add EModeTable --- apps/evm/src/pages/Stats/EModeTable/index.tsx | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 apps/evm/src/pages/Stats/EModeTable/index.tsx diff --git a/apps/evm/src/pages/Stats/EModeTable/index.tsx b/apps/evm/src/pages/Stats/EModeTable/index.tsx new file mode 100644 index 0000000000..67be44bf4b --- /dev/null +++ b/apps/evm/src/pages/Stats/EModeTable/index.tsx @@ -0,0 +1,148 @@ +import { type ApiRiskDashboardEModeSetting, useGetRiskDashboardEModePools } from 'clients/api'; +import { Spinner, Table, type TableColumn, TokenIconWithSymbol } from 'components'; +import { useGetVTokens } from 'libs/tokens/hooks/useGetVTokens'; +import { useTranslation } from 'libs/translations'; +import { useMemo } from 'react'; +import type { Token } from 'types'; +import { convertPercentageFromSmartContract, formatPercentageToReadableValue } from 'utilities'; +import { getAddress } from 'viem'; + +interface EModeRow extends ApiRiskDashboardEModeSetting { + underlyingToken: Token | null; +} + +const sortByMantissa = (a: string, b: string, direction: 'asc' | 'desc') => { + const aValue = convertPercentageFromSmartContract(a); + const bValue = convertPercentageFromSmartContract(b); + return direction === 'asc' ? aValue - bValue : bValue - aValue; +}; + +export const EModeTable: React.FC = () => { + const { t } = useTranslation(); + const vTokens = useGetVTokens(); + const { data, isLoading, isError } = useGetRiskDashboardEModePools(); + + const underlyingByVToken = useMemo(() => { + const map = new Map(); + for (const vToken of vTokens) { + map.set(getAddress(vToken.address), vToken.underlyingToken); + } + return map; + }, [vTokens]); + + const rows = useMemo(() => { + if (!data) { + return []; + } + return data.settings.map(setting => ({ + ...setting, + underlyingToken: underlyingByVToken.get(setting.marketAddress) ?? null, + })); + }, [data, underlyingByVToken]); + + const columns = useMemo[]>( + () => [ + { + key: 'group', + label: t('statsPage.eModeTable.columns.group'), + selectOptionLabel: t('statsPage.eModeTable.columns.group'), + align: 'left', + renderCell: row => row.label, + sortRows: (a, b, direction) => + direction === 'asc' ? a.label.localeCompare(b.label) : b.label.localeCompare(a.label), + }, + { + key: 'asset', + label: t('statsPage.eModeTable.columns.asset'), + selectOptionLabel: t('statsPage.eModeTable.columns.asset'), + align: 'left', + renderCell: row => + row.underlyingToken ? ( + + ) : ( + row.marketAddress + ), + }, + { + key: 'collateralFactor', + label: t('statsPage.eModeTable.columns.collateralFactor'), + selectOptionLabel: t('statsPage.eModeTable.columns.collateralFactor'), + align: 'right', + renderCell: row => + formatPercentageToReadableValue( + convertPercentageFromSmartContract(row.collateralFactorMantissa), + ), + sortRows: (a, b, direction) => + sortByMantissa(a.collateralFactorMantissa, b.collateralFactorMantissa, direction), + }, + { + key: 'liquidationThreshold', + label: t('statsPage.eModeTable.columns.liquidationThreshold'), + selectOptionLabel: t('statsPage.eModeTable.columns.liquidationThreshold'), + align: 'right', + renderCell: row => + formatPercentageToReadableValue( + convertPercentageFromSmartContract(row.liquidationThresholdMantissa), + ), + sortRows: (a, b, direction) => + sortByMantissa(a.liquidationThresholdMantissa, b.liquidationThresholdMantissa, direction), + }, + { + key: 'liquidationIncentive', + label: t('statsPage.eModeTable.columns.liquidationIncentive'), + selectOptionLabel: t('statsPage.eModeTable.columns.liquidationIncentive'), + align: 'right', + renderCell: row => + formatPercentageToReadableValue( + convertPercentageFromSmartContract(row.liquidationIncentiveMantissa), + ), + sortRows: (a, b, direction) => + sortByMantissa(a.liquidationIncentiveMantissa, b.liquidationIncentiveMantissa, direction), + }, + { + key: 'collateral', + label: t('statsPage.eModeTable.columns.collateral'), + selectOptionLabel: t('statsPage.eModeTable.columns.collateral'), + align: 'right', + renderCell: row => + row.canBeCollateral ? t('statsPage.eModeTable.yes') : t('statsPage.eModeTable.no'), + }, + { + key: 'borrowable', + label: t('statsPage.eModeTable.columns.borrowable'), + selectOptionLabel: t('statsPage.eModeTable.columns.borrowable'), + align: 'right', + renderCell: row => + row.isBorrowable ? t('statsPage.eModeTable.yes') : t('statsPage.eModeTable.no'), + }, + ], + [t], + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !data) { + return

{t('statsPage.eModeTable.unavailable')}

; + } + + if (rows.length === 0) { + return

{t('statsPage.eModeTable.noData')}

; + } + + return ( + `${row.poolId}-${row.marketAddress}`} + minWidth="1000px" + tableLayout="auto" + /> + ); +}; From 96960fdd1d0005d5899c81e00b8ef1442f176f15 Mon Sep 17 00:00:00 2001 From: Gleiser Oliveira Date: Wed, 27 May 2026 20:36:26 -0600 Subject: [PATCH 5/7] feat: add MarketsTable --- .../src/pages/Stats/MarketsTable/index.tsx | 314 ++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 apps/evm/src/pages/Stats/MarketsTable/index.tsx diff --git a/apps/evm/src/pages/Stats/MarketsTable/index.tsx b/apps/evm/src/pages/Stats/MarketsTable/index.tsx new file mode 100644 index 0000000000..b52e89c65a --- /dev/null +++ b/apps/evm/src/pages/Stats/MarketsTable/index.tsx @@ -0,0 +1,314 @@ +import { getBlockTimeByChainId } from '@venusprotocol/chains'; +import BigNumber from 'bignumber.js'; +import { type ApiRiskDashboardMarket, useGetRiskDashboardMarkets } from 'clients/api'; +import { Spinner, Table, type TableColumn, TokenIconWithSymbol } from 'components'; +import { PLACEHOLDER_KEY } from 'constants/placeholders'; +import { useGetVTokens } from 'libs/tokens/hooks/useGetVTokens'; +import { useTranslation } from 'libs/translations'; +import { useChainId } from 'libs/wallet'; +import { useMemo } from 'react'; +import type { Token } from 'types'; +import { + calculateDailyTokenRate, + calculateYearlyPercentageRate, + formatCentsToReadableValue, + formatPercentageToReadableValue, +} from 'utilities'; +import { getAddress } from 'viem'; + +interface MarketRow extends ApiRiskDashboardMarket { + underlyingToken: Token | null; + utilization: number | null; + supplyApyPercentage: number | null; + borrowApyPercentage: number | null; + supplyCapFilledFraction: number | null; + borrowCapFilledFraction: number | null; + topSupplierShareFraction: number | null; + topBorrowerShareFraction: number | null; +} + +const toNumber = (cents: string | null) => (cents === null ? null : Number(cents)); + +const ratio = (numerator: string | null, denominator: string) => { + if (numerator === null) { + return null; + } + const denominatorBn = new BigNumber(denominator); + if (denominatorBn.isZero()) { + return null; + } + return new BigNumber(numerator).dividedBy(denominatorBn).toNumber(); +}; + +const calculateApyPercentage = (ratePerBlockMantissa: string, blocksPerDay?: number) => { + const rateMantissa = new BigNumber(ratePerBlockMantissa); + if (rateMantissa.isZero()) { + return 0; + } + const dailyPercentageRate = calculateDailyTokenRate({ + rateMantissa, + blocksPerDay, + }); + return calculateYearlyPercentageRate({ dailyPercentageRate }).toNumber(); +}; + +const renderUsdCell = (cents: string | null) => { + if (cents === null) { + return PLACEHOLDER_KEY; + } + return formatCentsToReadableValue({ value: cents }); +}; + +const renderPercentageCell = (fraction: number | null) => { + if (fraction === null) { + return PLACEHOLDER_KEY; + } + return formatPercentageToReadableValue(fraction * 100); +}; + +const compareNullable = (a: number | null, b: number | null, direction: 'asc' | 'desc') => { + if (a === null && b === null) return 0; + if (a === null) return 1; + if (b === null) return -1; + return direction === 'asc' ? a - b : b - a; +}; + +export const MarketsTable: React.FC = () => { + const { t } = useTranslation(); + const { chainId } = useChainId(); + const vTokens = useGetVTokens({ chainId }); + const { data, isLoading, isError } = useGetRiskDashboardMarkets(); + + const blocksPerDay = getBlockTimeByChainId({ chainId })?.blocksPerDay; + + const underlyingByVToken = useMemo(() => { + const map = new Map(); + for (const vToken of vTokens) { + map.set(getAddress(vToken.address), vToken.underlyingToken); + } + return map; + }, [vTokens]); + + const rows = useMemo(() => { + if (!data) { + return []; + } + return data.markets.map(market => ({ + ...market, + underlyingToken: underlyingByVToken.get(market.marketAddress) ?? null, + utilization: ratio(market.totalBorrowsUsdCents, market.totalSupplyUsdCents), + supplyApyPercentage: calculateApyPercentage(market.supplyRatePerBlockMantissa, blocksPerDay), + borrowApyPercentage: calculateApyPercentage(market.borrowRatePerBlockMantissa, blocksPerDay), + supplyCapFilledFraction: ratio(market.totalSupplyUsdCents, market.supplyCapUsdCents ?? '0'), + borrowCapFilledFraction: ratio(market.totalBorrowsUsdCents, market.borrowCapUsdCents ?? '0'), + topSupplierShareFraction: ratio(market.topSupplierUsdCents, market.totalSupplyUsdCents), + topBorrowerShareFraction: ratio(market.topBorrowerUsdCents, market.totalBorrowsUsdCents), + })); + }, [data, underlyingByVToken, blocksPerDay]); + + const columns = useMemo[]>( + () => [ + { + key: 'asset', + label: t('statsPage.marketsTable.columns.asset'), + selectOptionLabel: t('statsPage.marketsTable.columns.asset'), + align: 'left', + renderCell: row => + row.underlyingToken ? ( + + ) : ( + row.marketAddress + ), + sortRows: (a, b, direction) => { + const symbolA = a.underlyingToken?.symbol ?? a.marketAddress; + const symbolB = b.underlyingToken?.symbol ?? b.marketAddress; + return direction === 'asc' + ? symbolA.localeCompare(symbolB) + : symbolB.localeCompare(symbolA); + }, + }, + { + key: 'totalSupplyUsdCents', + label: t('statsPage.marketsTable.columns.totalSupply'), + selectOptionLabel: t('statsPage.marketsTable.columns.totalSupply'), + align: 'right', + renderCell: row => renderUsdCell(row.totalSupplyUsdCents), + sortRows: (a, b, direction) => + compareNullable( + toNumber(a.totalSupplyUsdCents), + toNumber(b.totalSupplyUsdCents), + direction, + ), + }, + { + key: 'totalBorrowsUsdCents', + label: t('statsPage.marketsTable.columns.totalBorrows'), + selectOptionLabel: t('statsPage.marketsTable.columns.totalBorrows'), + align: 'right', + renderCell: row => renderUsdCell(row.totalBorrowsUsdCents), + sortRows: (a, b, direction) => + compareNullable( + toNumber(a.totalBorrowsUsdCents), + toNumber(b.totalBorrowsUsdCents), + direction, + ), + }, + { + key: 'utilization', + label: t('statsPage.marketsTable.columns.utilization'), + selectOptionLabel: t('statsPage.marketsTable.columns.utilization'), + align: 'right', + renderCell: row => renderPercentageCell(row.utilization), + sortRows: (a, b, direction) => compareNullable(a.utilization, b.utilization, direction), + }, + { + key: 'supplyApy', + label: t('statsPage.marketsTable.columns.supplyApy'), + selectOptionLabel: t('statsPage.marketsTable.columns.supplyApy'), + align: 'right', + renderCell: row => + row.supplyApyPercentage === null + ? PLACEHOLDER_KEY + : formatPercentageToReadableValue(row.supplyApyPercentage), + sortRows: (a, b, direction) => + compareNullable(a.supplyApyPercentage, b.supplyApyPercentage, direction), + }, + { + key: 'borrowApy', + label: t('statsPage.marketsTable.columns.borrowApy'), + selectOptionLabel: t('statsPage.marketsTable.columns.borrowApy'), + align: 'right', + renderCell: row => + row.borrowApyPercentage === null + ? PLACEHOLDER_KEY + : formatPercentageToReadableValue(row.borrowApyPercentage), + sortRows: (a, b, direction) => + compareNullable(a.borrowApyPercentage, b.borrowApyPercentage, direction), + }, + { + key: 'totalDebtAgainst', + label: t('statsPage.marketsTable.columns.totalDebtAgainst'), + selectOptionLabel: t('statsPage.marketsTable.columns.totalDebtAgainst'), + align: 'right', + renderCell: row => renderUsdCell(row.totalDebtAgainstUsdCents), + }, + { + key: 'stablecoinDebt', + label: t('statsPage.marketsTable.columns.stablecoinDebt'), + selectOptionLabel: t('statsPage.marketsTable.columns.stablecoinDebt'), + align: 'right', + renderCell: row => renderUsdCell(row.stablecoinDebtUsdCents), + }, + { + key: 'bnbDebt', + label: t('statsPage.marketsTable.columns.bnbDebt'), + selectOptionLabel: t('statsPage.marketsTable.columns.bnbDebt'), + align: 'right', + renderCell: row => renderUsdCell(row.bnbDebtUsdCents), + }, + { + key: 'otherDebt', + label: t('statsPage.marketsTable.columns.otherDebt'), + selectOptionLabel: t('statsPage.marketsTable.columns.otherDebt'), + align: 'right', + renderCell: row => renderUsdCell(row.otherDebtUsdCents), + }, + { + key: 'liquidityUsdCents', + label: t('statsPage.marketsTable.columns.liquidity'), + selectOptionLabel: t('statsPage.marketsTable.columns.liquidity'), + align: 'right', + renderCell: row => renderUsdCell(row.liquidityUsdCents), + sortRows: (a, b, direction) => + compareNullable(toNumber(a.liquidityUsdCents), toNumber(b.liquidityUsdCents), direction), + }, + { + key: 'supplyCap', + label: t('statsPage.marketsTable.columns.supplyCap'), + selectOptionLabel: t('statsPage.marketsTable.columns.supplyCap'), + align: 'right', + renderCell: row => renderUsdCell(row.supplyCapUsdCents), + sortRows: (a, b, direction) => + compareNullable(toNumber(a.supplyCapUsdCents), toNumber(b.supplyCapUsdCents), direction), + }, + { + key: 'borrowCap', + label: t('statsPage.marketsTable.columns.borrowCap'), + selectOptionLabel: t('statsPage.marketsTable.columns.borrowCap'), + align: 'right', + renderCell: row => renderUsdCell(row.borrowCapUsdCents), + sortRows: (a, b, direction) => + compareNullable(toNumber(a.borrowCapUsdCents), toNumber(b.borrowCapUsdCents), direction), + }, + { + key: 'supplyCapFilled', + label: t('statsPage.marketsTable.columns.supplyCapFilled'), + selectOptionLabel: t('statsPage.marketsTable.columns.supplyCapFilled'), + align: 'right', + renderCell: row => renderPercentageCell(row.supplyCapFilledFraction), + sortRows: (a, b, direction) => + compareNullable(a.supplyCapFilledFraction, b.supplyCapFilledFraction, direction), + }, + { + key: 'borrowCapFilled', + label: t('statsPage.marketsTable.columns.borrowCapFilled'), + selectOptionLabel: t('statsPage.marketsTable.columns.borrowCapFilled'), + align: 'right', + renderCell: row => renderPercentageCell(row.borrowCapFilledFraction), + sortRows: (a, b, direction) => + compareNullable(a.borrowCapFilledFraction, b.borrowCapFilledFraction, direction), + }, + { + key: 'topSupplierShare', + label: t('statsPage.marketsTable.columns.topSupplierShare'), + selectOptionLabel: t('statsPage.marketsTable.columns.topSupplierShare'), + align: 'right', + renderCell: row => renderPercentageCell(row.topSupplierShareFraction), + sortRows: (a, b, direction) => + compareNullable(a.topSupplierShareFraction, b.topSupplierShareFraction, direction), + }, + { + key: 'topBorrowerShare', + label: t('statsPage.marketsTable.columns.topBorrowerShare'), + selectOptionLabel: t('statsPage.marketsTable.columns.topBorrowerShare'), + align: 'right', + renderCell: row => renderPercentageCell(row.topBorrowerShareFraction), + sortRows: (a, b, direction) => + compareNullable(a.topBorrowerShareFraction, b.topBorrowerShareFraction, direction), + }, + ], + [t], + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !data) { + return

{t('statsPage.marketsTable.unavailable')}

; + } + + if (rows.length === 0) { + return

{t('statsPage.marketsTable.noData')}

; + } + + const totalSupplyColumn = columns.find(col => col.key === 'totalSupplyUsdCents'); + + return ( +
row.marketAddress} + minWidth="1600px" + tableLayout="auto" + initialOrder={ + totalSupplyColumn ? { orderBy: totalSupplyColumn, orderDirection: 'desc' } : undefined + } + /> + ); +}; From 05dae799ae8a8b786a0333b9504c45e0730aeff4 Mon Sep 17 00:00:00 2001 From: Gleiser Oliveira Date: Wed, 27 May 2026 20:36:37 -0600 Subject: [PATCH 6/7] feat: add RiskParametersTable --- .../pages/Stats/RiskParametersTable/index.tsx | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 apps/evm/src/pages/Stats/RiskParametersTable/index.tsx diff --git a/apps/evm/src/pages/Stats/RiskParametersTable/index.tsx b/apps/evm/src/pages/Stats/RiskParametersTable/index.tsx new file mode 100644 index 0000000000..4578753d1b --- /dev/null +++ b/apps/evm/src/pages/Stats/RiskParametersTable/index.tsx @@ -0,0 +1,195 @@ +import { type ApiRiskDashboardMarket, useGetRiskDashboardMarkets } from 'clients/api'; +import { Spinner, Table, type TableColumn, TokenIconWithSymbol } from 'components'; +import { PLACEHOLDER_KEY } from 'constants/placeholders'; +import { useGetVTokens } from 'libs/tokens/hooks/useGetVTokens'; +import { useTranslation } from 'libs/translations'; +import { useMemo } from 'react'; +import type { Token } from 'types'; +import { + calculatePercentage, + convertPercentageFromSmartContract, + convertPriceMantissaToDollars, + formatCentsToReadableValue, + formatPercentageToReadableValue, +} from 'utilities'; +import { getAddress } from 'viem'; + +interface RiskRow extends ApiRiskDashboardMarket { + underlyingToken: Token | null; + priceCents: number | null; + utilization: number; +} + +const sortByMantissa = (a: string, b: string, direction: 'asc' | 'desc') => { + const aValue = convertPercentageFromSmartContract(a); + const bValue = convertPercentageFromSmartContract(b); + return direction === 'asc' ? aValue - bValue : bValue - aValue; +}; + +export const RiskParametersTable: React.FC = () => { + const { t } = useTranslation(); + const vTokens = useGetVTokens(); + const { data, isLoading, isError } = useGetRiskDashboardMarkets(); + + const underlyingByVToken = useMemo(() => { + const map = new Map(); + for (const vToken of vTokens) { + map.set(getAddress(vToken.address), vToken.underlyingToken); + } + return map; + }, [vTokens]); + + const rows = useMemo(() => { + if (!data) { + return []; + } + return data.markets.map(market => { + const underlyingToken = underlyingByVToken.get(market.marketAddress) ?? null; + const priceCents = underlyingToken + ? convertPriceMantissaToDollars({ + priceMantissa: market.priceMantissa, + decimals: underlyingToken.decimals, + }) + .multipliedBy(100) + .toNumber() + : null; + const utilization = calculatePercentage({ + numerator: Number(market.totalBorrowsUsdCents), + denominator: Number(market.totalSupplyUsdCents), + }); + return { ...market, underlyingToken, priceCents, utilization }; + }); + }, [data, underlyingByVToken]); + + const columns = useMemo[]>( + () => [ + { + key: 'asset', + label: t('statsPage.riskParametersTable.columns.asset'), + selectOptionLabel: t('statsPage.riskParametersTable.columns.asset'), + align: 'left', + renderCell: row => + row.underlyingToken ? ( + + ) : ( + row.marketAddress + ), + }, + { + key: 'price', + label: t('statsPage.riskParametersTable.columns.price'), + selectOptionLabel: t('statsPage.riskParametersTable.columns.price'), + align: 'right', + renderCell: row => + row.priceCents === null + ? PLACEHOLDER_KEY + : formatCentsToReadableValue({ value: row.priceCents }), + }, + { + key: 'collateralFactor', + label: t('statsPage.riskParametersTable.columns.collateralFactor'), + selectOptionLabel: t('statsPage.riskParametersTable.columns.collateralFactor'), + align: 'right', + renderCell: row => + formatPercentageToReadableValue( + convertPercentageFromSmartContract(row.collateralFactorMantissa), + ), + sortRows: (a, b, direction) => + sortByMantissa(a.collateralFactorMantissa, b.collateralFactorMantissa, direction), + }, + { + key: 'liquidationThreshold', + label: t('statsPage.riskParametersTable.columns.liquidationThreshold'), + selectOptionLabel: t('statsPage.riskParametersTable.columns.liquidationThreshold'), + align: 'right', + renderCell: row => + formatPercentageToReadableValue( + convertPercentageFromSmartContract(row.liquidationThresholdMantissa), + ), + sortRows: (a, b, direction) => + sortByMantissa(a.liquidationThresholdMantissa, b.liquidationThresholdMantissa, direction), + }, + { + key: 'liquidationIncentive', + label: t('statsPage.riskParametersTable.columns.liquidationIncentive'), + selectOptionLabel: t('statsPage.riskParametersTable.columns.liquidationIncentive'), + align: 'right', + renderCell: row => + formatPercentageToReadableValue( + convertPercentageFromSmartContract(row.liquidationIncentiveMantissa), + ), + sortRows: (a, b, direction) => + sortByMantissa(a.liquidationIncentiveMantissa, b.liquidationIncentiveMantissa, direction), + }, + { + key: 'reserveFactor', + label: t('statsPage.riskParametersTable.columns.reserveFactor'), + selectOptionLabel: t('statsPage.riskParametersTable.columns.reserveFactor'), + align: 'right', + renderCell: row => + formatPercentageToReadableValue( + convertPercentageFromSmartContract(row.reserveFactorMantissa), + ), + sortRows: (a, b, direction) => + sortByMantissa(a.reserveFactorMantissa, b.reserveFactorMantissa, direction), + }, + { + key: 'supplyCap', + label: t('statsPage.riskParametersTable.columns.supplyCap'), + selectOptionLabel: t('statsPage.riskParametersTable.columns.supplyCap'), + align: 'right', + renderCell: row => + row.supplyCapUsdCents === null + ? PLACEHOLDER_KEY + : formatCentsToReadableValue({ value: row.supplyCapUsdCents }), + }, + { + key: 'borrowCap', + label: t('statsPage.riskParametersTable.columns.borrowCap'), + selectOptionLabel: t('statsPage.riskParametersTable.columns.borrowCap'), + align: 'right', + renderCell: row => + row.borrowCapUsdCents === null + ? PLACEHOLDER_KEY + : formatCentsToReadableValue({ value: row.borrowCapUsdCents }), + }, + { + key: 'utilization', + label: t('statsPage.riskParametersTable.columns.utilization'), + selectOptionLabel: t('statsPage.riskParametersTable.columns.utilization'), + align: 'right', + renderCell: row => formatPercentageToReadableValue(row.utilization), + sortRows: (a, b, direction) => + direction === 'asc' ? a.utilization - b.utilization : b.utilization - a.utilization, + }, + ], + [t], + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !data) { + return

{t('statsPage.riskParametersTable.unavailable')}

; + } + + if (rows.length === 0) { + return

{t('statsPage.riskParametersTable.noData')}

; + } + + return ( +
row.marketAddress} + minWidth="1200px" + tableLayout="auto" + /> + ); +}; From 1b7539498985f5060d2a746e075fac0d8be7c732 Mon Sep 17 00:00:00 2001 From: Gleiser Oliveira Date: Wed, 27 May 2026 20:37:13 -0600 Subject: [PATCH 7/7] chore: translations for the market tables --- .../libs/translations/translations/en.json | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/apps/evm/src/libs/translations/translations/en.json b/apps/evm/src/libs/translations/translations/en.json index 22e5999eb0..a68cc8dc7f 100644 --- a/apps/evm/src/libs/translations/translations/en.json +++ b/apps/evm/src/libs/translations/translations/en.json @@ -1590,6 +1590,62 @@ "unavailable": "Market data unavailable.", "utilization": "Utilization" }, + "marketsTable": { + "columns": { + "asset": "Asset", + "bnbDebt": "BNB Debt", + "borrowApy": "Borrow APY", + "borrowCap": "Borrow Cap", + "borrowCapFilled": "Borrow Cap %", + "liquidity": "Liquidity", + "otherDebt": "Other Debt", + "stablecoinDebt": "Stablecoin Debt", + "supplyApy": "Supply APY", + "supplyCap": "Supply Cap", + "supplyCapFilled": "Supply Cap %", + "topBorrowerShare": "Top Borrower %", + "topSupplierShare": "Top Supplier %", + "totalBorrows": "Total Borrows", + "totalDebtAgainst": "Total Debt Against", + "totalSupply": "Total Supply", + "utilization": "Utilization" + }, + "noData": "No market snapshot yet.", + "title": "Markets", + "unavailable": "Markets data unavailable." + }, + "riskParametersTable": { + "columns": { + "asset": "Asset", + "borrowCap": "Borrow Cap", + "collateralFactor": "Collateral Factor", + "liquidationIncentive": "Liquidation Incentive", + "liquidationThreshold": "Liquidation Threshold", + "price": "Price", + "reserveFactor": "Reserve Factor", + "supplyCap": "Supply Cap", + "utilization": "Utilization" + }, + "noData": "No market snapshot yet.", + "title": "Risk Parameters by Market", + "unavailable": "Risk parameters unavailable." + }, + "eModeTable": { + "columns": { + "asset": "Asset", + "borrowable": "Borrowable", + "collateral": "Collateral", + "collateralFactor": "Collateral Factor", + "group": "E-Mode Group", + "liquidationIncentive": "Liquidation Incentive", + "liquidationThreshold": "Liquidation Threshold" + }, + "no": "No", + "noData": "No E-Mode groups configured.", + "title": "E-Mode Groups", + "unavailable": "E-Mode data unavailable.", + "yes": "Yes" + }, "historicalDominance": { "borrows": { "noData": "No historical snapshots yet.",