From f9176a19d2f99760f3a3271441577f6336a9beab Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 6 Nov 2025 00:58:49 +0800 Subject: [PATCH 1/3] fix: stats page updates --- .../stats/components/AssetMetricsTable.tsx | 227 +++++--------- .../stats/components/StatsOverviewCards.tsx | 4 +- .../stats/components/TransactionTableBody.tsx | 277 ++++++++++++++++++ .../stats/components/TransactionsTable.tsx | 180 ++++++++++++ app/admin/stats/page.tsx | 96 +++++- src/services/statsService.ts | 8 +- 6 files changed, 631 insertions(+), 161 deletions(-) create mode 100644 app/admin/stats/components/TransactionTableBody.tsx create mode 100644 app/admin/stats/components/TransactionsTable.tsx diff --git a/app/admin/stats/components/AssetMetricsTable.tsx b/app/admin/stats/components/AssetMetricsTable.tsx index d798e8fc..3b65fbc9 100644 --- a/app/admin/stats/components/AssetMetricsTable.tsx +++ b/app/admin/stats/components/AssetMetricsTable.tsx @@ -1,10 +1,7 @@ import React, { useState, useMemo } from 'react'; -import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@heroui/react'; -import Link from 'next/link'; -import { FiChevronUp, FiChevronDown, FiExternalLink } from 'react-icons/fi'; +import { FiChevronUp, FiChevronDown } from 'react-icons/fi'; import { TokenIcon } from '@/components/TokenIcon'; import { formatReadable } from '@/utils/balance'; -import { getAssetURL } from '@/utils/external'; import { SupportedNetworks } from '@/utils/networks'; import { calculateHumanReadableVolumes } from '@/utils/statsDataProcessing'; import { AssetVolumeData } from '@/utils/statsUtils'; @@ -42,12 +39,6 @@ export function AssetMetricsTable({ data, selectedNetwork }: AssetMetricsTablePr })); }, [data]); - // Format address for display - const formatAddress = (address: string) => { - if (address.length < 10) return address; - return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; - }; - const sortedData = useMemo(() => { return [...processedData].sort((a, b) => { let valueA, valueB; @@ -71,131 +62,58 @@ export function AssetMetricsTable({ data, selectedNetwork }: AssetMetricsTablePr }); }, [processedData, sortKey, sortDirection]); + const SortableHeader = ({ + label, + sortKeyValue, + }: { + label: string; + sortKeyValue: SortKey; + }) => ( + handleSort(sortKeyValue)} + style={{ padding: '0.5rem' }} + > +
+
{label}
+ {sortKey === sortKeyValue && + (sortDirection === 'asc' ? ( + + ) : ( + + ))} +
+ + ); + return ( -
+

Asset Activity

-
+
{processedData.length === 0 ? (
No asset data available
) : ( - - - - Asset - - - - - - - - - - - - - - - - - - +
+ + + + + + + + + + + {sortedData.map((asset) => { // Use a determined chainId for display purposes const displayChainId = asset.chainId ?? BASE_CHAIN_ID; return ( - - + + + + + + + + ); })} - -
Asset
-
- - {asset.assetSymbol ?? 'Unknown'} - - - {formatAddress(asset.assetAddress)} - - -
+ + {asset.assetSymbol ?? 'Unknown'} +
- - - {asset.totalVolumeFormatted - ? `${formatReadable(Number(asset.totalVolumeFormatted))} ${ - asset.assetSymbol - }` - : '-'} - - - {(asset.supplyCount + asset.withdrawCount).toLocaleString()} - - {asset.supplyCount.toLocaleString()} - - {asset.withdrawCount.toLocaleString()} - - {asset.uniqueUsers.toLocaleString()} - +
+ + {asset.totalVolumeFormatted + ? `${formatReadable(Number(asset.totalVolumeFormatted))} ${ + asset.assetSymbol + }` + : '—'} + + + + {(asset.supplyCount + asset.withdrawCount).toLocaleString()} + + + {asset.supplyCount.toLocaleString()} + + {asset.withdrawCount.toLocaleString()} + + {asset.uniqueUsers.toLocaleString()} +
+ + )}
diff --git a/app/admin/stats/components/StatsOverviewCards.tsx b/app/admin/stats/components/StatsOverviewCards.tsx index 155f9c27..3c4f76ec 100644 --- a/app/admin/stats/components/StatsOverviewCards.tsx +++ b/app/admin/stats/components/StatsOverviewCards.tsx @@ -17,9 +17,9 @@ function StatCard({ title, value, change, prefix = '' }: StatCardProps) { return ( -

{title}

+

{title}

-

+

{prefix} {value}

diff --git a/app/admin/stats/components/TransactionTableBody.tsx b/app/admin/stats/components/TransactionTableBody.tsx new file mode 100644 index 00000000..4c9ca0ef --- /dev/null +++ b/app/admin/stats/components/TransactionTableBody.tsx @@ -0,0 +1,277 @@ +import React from 'react'; +import { ExternalLinkIcon } from '@radix-ui/react-icons'; +import Link from 'next/link'; +import { formatUnits } from 'viem'; +import { TokenIcon } from '@/components/TokenIcon'; +import { formatReadable } from '@/utils/balance'; +import { getExplorerTxURL, getExplorerURL } from '@/utils/external'; +import { SupportedNetworks } from '@/utils/networks'; +import { Transaction } from '@/utils/statsUtils'; +import { findToken } from '@/utils/tokens'; +import { getTruncatedAssetName } from '@/utils/oracle'; + +type TransactionTableBodyProps = { + currentEntries: Transaction[]; + selectedNetwork: SupportedNetworks; +}; + +type MarketInfo = { + loanAddress: string; + collateralAddress?: string; +}; + +type LoanAssetInfo = { + address: string; + symbol: string; +}; + +const formatAddress = (address: string): string => { + if (address.length < 10) return address; + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; +}; + +const formatTimestamp = (timestamp: string): string => { + const date = new Date(Number(timestamp) * 1000); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +const extractLoanAssets = (tx: Transaction, chainId: number): LoanAssetInfo[] => { + const loanAssetsSet = new Map(); + + // Extract from supplies + tx.supplies?.forEach((supply) => { + if (supply.market?.loan) { + const address = supply.market.loan.toLowerCase(); + if (!loanAssetsSet.has(address)) { + const token = findToken(address, chainId); + loanAssetsSet.set(address, { + address, + symbol: token?.symbol ?? 'Unknown', + }); + } + } + }); + + // Extract from withdrawals + tx.withdrawals?.forEach((withdrawal) => { + if (withdrawal.market?.loan) { + const address = withdrawal.market.loan.toLowerCase(); + if (!loanAssetsSet.has(address)) { + const token = findToken(address, chainId); + loanAssetsSet.set(address, { + address, + symbol: token?.symbol ?? 'Unknown', + }); + } + } + }); + + return Array.from(loanAssetsSet.values()); +}; + +const extractMarkets = (tx: Transaction): MarketInfo[] => { + const marketsSet = new Map(); + + // Extract from supplies + tx.supplies?.forEach((supply) => { + if (supply.market?.loan) { + const key = `${supply.market.loan}-${supply.market.collateral ?? 'none'}`; + if (!marketsSet.has(key)) { + marketsSet.set(key, { + loanAddress: supply.market.loan, + collateralAddress: supply.market.collateral, + }); + } + } + }); + + // Extract from withdrawals + tx.withdrawals?.forEach((withdrawal) => { + if (withdrawal.market?.loan) { + const key = `${withdrawal.market.loan}-${withdrawal.market.collateral ?? 'none'}`; + if (!marketsSet.has(key)) { + marketsSet.set(key, { + loanAddress: withdrawal.market.loan, + collateralAddress: withdrawal.market.collateral, + }); + } + } + }); + + return Array.from(marketsSet.values()); +}; + +const formatVolume = (volume: string, assetAddress: string, chainId: number): string => { + if (!volume || volume === '0') return '—'; + + const token = findToken(assetAddress, chainId); + const decimals = token?.decimals ?? 18; + const symbol = token?.symbol ?? ''; + + const formatted = formatUnits(BigInt(volume), decimals); + return `${formatReadable(Number(formatted))} ${symbol}`; +}; + +export function TransactionTableBody({ + currentEntries, + selectedNetwork, +}: TransactionTableBodyProps) { + return ( + + {currentEntries.map((tx) => { + const loanAssets = extractLoanAssets(tx, selectedNetwork); + const markets = extractMarkets(tx); + const totalVolume = + Number(BigInt(tx.supplyVolume ?? '0') + BigInt(tx.withdrawVolume ?? '0')); + + // Get primary asset for volume formatting (from first supply or withdrawal) + const primaryAsset = + tx.supplies?.[0]?.market?.loan ?? tx.withdrawals?.[0]?.market?.loan ?? ''; + + return ( + + {/* Transaction Hash */} + + e.stopPropagation()} + > + {formatAddress(tx.id)} + + + + + {/* User Address */} + + e.stopPropagation()} + > + {formatAddress(tx.user)} + + + + + {/* Loan Assets */} + +
+ {loanAssets.length > 0 ? ( + loanAssets.slice(0, 2).map((asset, idx) => ( +
+ + + {getTruncatedAssetName(asset.symbol)} + +
+ )) + ) : ( + + )} + {loanAssets.length > 2 && ( + +{loanAssets.length - 2} + )} +
+ + + {/* Markets (Collateral only) */} + +
+ {markets.length > 0 ? ( + markets.slice(0, 3).map((market, idx) => { + const collateralToken = market.collateralAddress + ? findToken(market.collateralAddress, selectedNetwork) + : null; + return ( +
+ {market.collateralAddress ? ( + <> + + + {getTruncatedAssetName(collateralToken?.symbol ?? 'Unknown')} + + + ) : ( + Idle + )} +
+ ); + }) + ) : ( + + )} + {markets.length > 3 && ( + +{markets.length - 3} + )} +
+ + + {/* Timestamp */} + + {formatTimestamp(tx.timestamp)} + + + {/* Supply Volume */} + + + {formatVolume(tx.supplyVolume ?? '0', primaryAsset, selectedNetwork)} + + + + {/* Supply Count */} + + {tx.supplyCount ?? 0} + + + {/* Withdraw Volume */} + + + {formatVolume(tx.withdrawVolume ?? '0', primaryAsset, selectedNetwork)} + + + + {/* Withdraw Count */} + + {tx.withdrawCount ?? 0} + + + {/* Total Volume */} + + + {totalVolume > 0 + ? formatVolume( + (BigInt(tx.supplyVolume ?? '0') + BigInt(tx.withdrawVolume ?? '0')).toString(), + primaryAsset, + selectedNetwork, + ) + : '—'} + + + + ); + })} + + ); +} diff --git a/app/admin/stats/components/TransactionsTable.tsx b/app/admin/stats/components/TransactionsTable.tsx new file mode 100644 index 00000000..e11bf233 --- /dev/null +++ b/app/admin/stats/components/TransactionsTable.tsx @@ -0,0 +1,180 @@ +import React, { useState, useMemo } from 'react'; +import { FiChevronUp, FiChevronDown } from 'react-icons/fi'; +import { SupportedNetworks } from '@/utils/networks'; +import { Transaction } from '@/utils/statsUtils'; +import { TransactionTableBody } from './TransactionTableBody'; +import { Pagination } from '../../../markets/components/Pagination'; + +type TransactionsTableProps = { + data: Transaction[]; + selectedNetwork: SupportedNetworks; + selectedLoanAssets?: string[]; +}; + +type SortKey = + | 'timestamp' + | 'totalVolume' + | 'supplyVolume' + | 'withdrawVolume' + | 'supplyCount' + | 'withdrawCount'; +type SortDirection = 'asc' | 'desc'; + +export function TransactionsTable({ + data, + selectedNetwork, + selectedLoanAssets = [], +}: TransactionsTableProps) { + const [sortKey, setSortKey] = useState('timestamp'); + const [sortDirection, setSortDirection] = useState('desc'); + const [currentPage, setCurrentPage] = useState(1); + const entriesPerPage = 50; + + const handleSort = (key: SortKey) => { + if (key === sortKey) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortDirection('desc'); + } + }; + + // Filter transactions by selected loan assets + const filteredData = useMemo(() => { + if (selectedLoanAssets.length === 0) { + return data; + } + + // Extract addresses from the asset keys (format: "address-chainId|address-chainId|...") + // infoToKey returns "address-chainId", and multiple networks are joined by "|" + const selectedAddresses = selectedLoanAssets.flatMap((assetKey) => + assetKey.split('|').map((key) => key.split('-')[0].toLowerCase()), + ); + + return data.filter((tx) => { + // Check if any supply involves a selected loan asset + const hasMatchingSupply = tx.supplies?.some((supply) => + selectedAddresses.includes(supply.market?.loan?.toLowerCase() ?? ''), + ); + + // Check if any withdrawal involves a selected loan asset + const hasMatchingWithdrawal = tx.withdrawals?.some((withdrawal) => + selectedAddresses.includes(withdrawal.market?.loan?.toLowerCase() ?? ''), + ); + + return hasMatchingSupply || hasMatchingWithdrawal; + }); + }, [data, selectedLoanAssets]); + + const sortedData = useMemo(() => { + return [...filteredData].sort((a, b) => { + let valueA: number | string; + let valueB: number | string; + + if (sortKey === 'timestamp') { + valueA = Number(a.timestamp); + valueB = Number(b.timestamp); + } else if (sortKey === 'totalVolume') { + valueA = Number(BigInt(a.supplyVolume ?? '0') + BigInt(a.withdrawVolume ?? '0')); + valueB = Number(BigInt(b.supplyVolume ?? '0') + BigInt(b.withdrawVolume ?? '0')); + } else if (sortKey === 'supplyVolume') { + valueA = Number(BigInt(a.supplyVolume ?? '0')); + valueB = Number(BigInt(b.supplyVolume ?? '0')); + } else if (sortKey === 'withdrawVolume') { + valueA = Number(BigInt(a.withdrawVolume ?? '0')); + valueB = Number(BigInt(b.withdrawVolume ?? '0')); + } else if (sortKey === 'supplyCount') { + valueA = a.supplyCount ?? 0; + valueB = b.supplyCount ?? 0; + } else { + // withdrawCount + valueA = a.withdrawCount ?? 0; + valueB = b.withdrawCount ?? 0; + } + + if (sortDirection === 'asc') { + return valueA < valueB ? -1 : valueA > valueB ? 1 : 0; + } else { + return valueA > valueB ? -1 : valueA < valueB ? 1 : 0; + } + }); + }, [filteredData, sortKey, sortDirection]); + + // Pagination + const indexOfLastEntry = currentPage * entriesPerPage; + const indexOfFirstEntry = indexOfLastEntry - entriesPerPage; + const currentEntries = sortedData.slice(indexOfFirstEntry, indexOfLastEntry); + const totalPages = Math.ceil(sortedData.length / entriesPerPage); + + const SortableHeader = ({ + label, + sortKeyValue, + }: { + label: string; + sortKeyValue: SortKey; + }) => ( + handleSort(sortKeyValue)} + style={{ padding: '0.5rem' }} + > +
+
{label}
+ {sortKey === sortKeyValue && + (sortDirection === 'asc' ? ( + + ) : ( + + ))} +
+ + ); + + return ( +
+
+

Transactions

+

+ {sortedData.length} transaction{sortedData.length !== 1 ? 's' : ''} in selected timeframe +

+
+
+ {sortedData.length === 0 ? ( +
No transaction data available
+ ) : ( + <> + + + + + + + + + + + + + + + + +
Tx HashUserLoan AssetCollateral
+
+ 0} + /> +
+ + )} +
+
+ ); +} diff --git a/app/admin/stats/page.tsx b/app/admin/stats/page.tsx index ad01129b..32732f6a 100644 --- a/app/admin/stats/page.tsx +++ b/app/admin/stats/page.tsx @@ -9,16 +9,20 @@ import ButtonGroup from '@/components/ButtonGroup'; import { Spinner } from '@/components/common/Spinner'; import { fetchAllStatistics } from '@/services/statsService'; import { SupportedNetworks, getNetworkImg, getNetworkName } from '@/utils/networks'; -import { PlatformStats, TimeFrame, AssetVolumeData } from '@/utils/statsUtils'; +import { PlatformStats, TimeFrame, AssetVolumeData, Transaction } from '@/utils/statsUtils'; import { AssetMetricsTable } from './components/AssetMetricsTable'; import { StatsOverviewCards } from './components/StatsOverviewCards'; +import { TransactionsTable } from './components/TransactionsTable'; +import AssetFilter from '../../markets/components/AssetFilter'; +import { ERC20Token, UnknownERC20Token, TokenSource } from '@/utils/tokens'; +import { findToken as findTokenStatic } from '@/utils/tokens'; const getAPIEndpoint = (network: SupportedNetworks) => { switch (network) { case SupportedNetworks.Base: return 'https://api.studio.thegraph.com/query/94369/monarch-metrics/version/latest'; case SupportedNetworks.Mainnet: - return 'https://api.studio.thegraph.com/query/94369/monarch-metrics-mainnet/version/latest'; + return 'https://api.studio.thegraph.com/query/110397/monarch-metrics-mainnet/version/latest'; default: return undefined; } @@ -28,9 +32,12 @@ export default function StatsPage() { const [timeframe, setTimeframe] = useState('30D'); const [selectedNetwork, setSelectedNetwork] = useState(SupportedNetworks.Base); const [isLoading, setIsLoading] = useState(true); + const [selectedLoanAssets, setSelectedLoanAssets] = useState([]); + const [uniqueLoanAssets, setUniqueLoanAssets] = useState<(ERC20Token | UnknownERC20Token)[]>([]); const [stats, setStats] = useState<{ platformStats: PlatformStats; assetMetrics: AssetVolumeData[]; + transactions: Transaction[]; }>({ platformStats: { uniqueUsers: 0, @@ -44,6 +51,7 @@ export default function StatsPage() { activeMarkets: 0, }, assetMetrics: [], + transactions: [], }); useEffect(() => { @@ -75,6 +83,7 @@ export default function StatsPage() { setStats({ platformStats: allStats.platformStats, assetMetrics: allStats.assetMetrics, + transactions: allStats.transactions, }); } catch (error) { console.error('Error loading stats:', error); @@ -86,6 +95,71 @@ export default function StatsPage() { void loadStats(); }, [timeframe, selectedNetwork]); + // Extract unique loan assets from transactions + useEffect(() => { + if (stats.transactions.length === 0) { + setUniqueLoanAssets([]); + return; + } + + const loanAssetsMap = new Map(); + + stats.transactions.forEach((tx) => { + // Extract from supplies + tx.supplies?.forEach((supply) => { + if (supply.market?.loan) { + const address = supply.market.loan.toLowerCase(); + if (!loanAssetsMap.has(address)) { + const token = findTokenStatic(address, selectedNetwork); + if (token) { + loanAssetsMap.set(address, { + address, + symbol: token.symbol, + decimals: token.decimals, + }); + } + } + } + }); + + // Extract from withdrawals + tx.withdrawals?.forEach((withdrawal) => { + if (withdrawal.market?.loan) { + const address = withdrawal.market.loan.toLowerCase(); + if (!loanAssetsMap.has(address)) { + const token = findTokenStatic(address, selectedNetwork); + if (token) { + loanAssetsMap.set(address, { + address, + symbol: token.symbol, + decimals: token.decimals, + }); + } + } + } + }); + }); + + // Convert to ERC20Token format + const tokens: ERC20Token[] = Array.from(loanAssetsMap.values()).map((asset) => { + const fullToken = findTokenStatic(asset.address, selectedNetwork); + return { + symbol: asset.symbol, + img: fullToken?.img, + decimals: asset.decimals, + networks: [ + { + chain: { id: selectedNetwork } as any, + address: asset.address, + }, + ], + source: 'local' as TokenSource, + }; + }); + + setUniqueLoanAssets(tokens); + }, [stats.transactions, selectedNetwork]); + const timeframeOptions = [ { key: '1D', label: '1D', value: '1D' }, { key: '7D', label: '7D', value: '7D' }, @@ -188,6 +262,24 @@ export default function StatsPage() {
+ + {/* Transaction Filters */} +
+ +
+ +
)}
diff --git a/src/services/statsService.ts b/src/services/statsService.ts index 1a4de0ba..bca87d0b 100644 --- a/src/services/statsService.ts +++ b/src/services/statsService.ts @@ -245,14 +245,18 @@ export const fetchAllStatistics = async ( ): Promise<{ platformStats: PlatformStats; assetMetrics: AssetVolumeData[]; + transactions: Transaction[]; }> => { try { console.log(`Fetching all statistics for timeframe: ${timeframe}, network: ${networkId}`); const startTime = performance.now(); - const [platformStats, assetMetrics] = await Promise.all([ + const { startTime: rangeStartTime, endTime: rangeEndTime } = getTimeRange(timeframe); + + const [platformStats, assetMetrics, transactions] = await Promise.all([ fetchPlatformStats(timeframe, networkId, endpoint), fetchAssetMetrics(timeframe, networkId, endpoint), + fetchTransactionsByTimeRange(rangeStartTime, rangeEndTime, networkId, endpoint), ]); const endTime = performance.now(); @@ -261,6 +265,7 @@ export const fetchAllStatistics = async ( return { platformStats, assetMetrics, + transactions, }; } catch (error) { console.error('Error fetching all statistics:', error); @@ -277,6 +282,7 @@ export const fetchAllStatistics = async ( activeMarkets: 0, }, assetMetrics: [], + transactions: [], }; } }; From 176c8f306c6604ef1ff712db71699f336d500209 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 6 Nov 2025 01:01:53 +0800 Subject: [PATCH 2/3] chore: item per page --- app/admin/stats/components/TransactionsTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/admin/stats/components/TransactionsTable.tsx b/app/admin/stats/components/TransactionsTable.tsx index e11bf233..3ba455c6 100644 --- a/app/admin/stats/components/TransactionsTable.tsx +++ b/app/admin/stats/components/TransactionsTable.tsx @@ -28,7 +28,7 @@ export function TransactionsTable({ const [sortKey, setSortKey] = useState('timestamp'); const [sortDirection, setSortDirection] = useState('desc'); const [currentPage, setCurrentPage] = useState(1); - const entriesPerPage = 50; + const entriesPerPage = 10; const handleSort = (key: SortKey) => { if (key === sortKey) { From 1c96199c762c6e56743a0404c720b2a6da35836d Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 6 Nov 2025 14:09:44 +0800 Subject: [PATCH 3/3] feat: transactions table --- .../stats/components/AssetMetricsTable.tsx | 102 ++++-- .../stats/components/TransactionTableBody.tsx | 315 ++++++------------ .../stats/components/TransactionsTable.tsx | 235 ++++++++----- app/admin/stats/page.tsx | 116 ++++++- src/components/common/AddressIdentity.tsx | 51 +++ src/components/common/TransactionIdentity.tsx | 37 ++ src/components/common/index.ts | 2 + src/utils/statsUtils.ts | 4 + 8 files changed, 519 insertions(+), 343 deletions(-) create mode 100644 src/components/common/AddressIdentity.tsx create mode 100644 src/components/common/TransactionIdentity.tsx diff --git a/app/admin/stats/components/AssetMetricsTable.tsx b/app/admin/stats/components/AssetMetricsTable.tsx index 3b65fbc9..0d538124 100644 --- a/app/admin/stats/components/AssetMetricsTable.tsx +++ b/app/admin/stats/components/AssetMetricsTable.tsx @@ -2,7 +2,6 @@ import React, { useState, useMemo } from 'react'; import { FiChevronUp, FiChevronDown } from 'react-icons/fi'; import { TokenIcon } from '@/components/TokenIcon'; import { formatReadable } from '@/utils/balance'; -import { SupportedNetworks } from '@/utils/networks'; import { calculateHumanReadableVolumes } from '@/utils/statsDataProcessing'; import { AssetVolumeData } from '@/utils/statsUtils'; @@ -11,13 +10,46 @@ const BASE_CHAIN_ID = 8453; // Base network ID type AssetMetricsTableProps = { data: AssetVolumeData[]; - selectedNetwork: SupportedNetworks; }; type SortKey = 'supplyCount' | 'withdrawCount' | 'uniqueUsers' | 'totalCount' | 'totalVolume'; type SortDirection = 'asc' | 'desc'; -export function AssetMetricsTable({ data, selectedNetwork }: AssetMetricsTableProps) { +type SortableHeaderProps = { + label: string; + sortKeyValue: SortKey; + currentSortKey: SortKey; + sortDirection: SortDirection; + onSort: (key: SortKey) => void; +}; + +function SortableHeader({ + label, + sortKeyValue, + currentSortKey, + sortDirection, + onSort, +}: SortableHeaderProps) { + return ( + onSort(sortKeyValue)} + style={{ padding: '0.5rem' }} + > +
+
{label}
+ {currentSortKey === sortKeyValue && + (sortDirection === 'asc' ? ( + + ) : ( + + ))} +
+ + ); +} + +export function AssetMetricsTable({ data }: AssetMetricsTableProps) { const [sortKey, setSortKey] = useState('totalCount'); const [sortDirection, setSortDirection] = useState('desc'); @@ -62,30 +94,6 @@ export function AssetMetricsTable({ data, selectedNetwork }: AssetMetricsTablePr }); }, [processedData, sortKey, sortDirection]); - const SortableHeader = ({ - label, - sortKeyValue, - }: { - label: string; - sortKeyValue: SortKey; - }) => ( - handleSort(sortKeyValue)} - style={{ padding: '0.5rem' }} - > -
-
{label}
- {sortKey === sortKeyValue && - (sortDirection === 'asc' ? ( - - ) : ( - - ))} -
- - ); - return (
@@ -99,11 +107,41 @@ export function AssetMetricsTable({ data, selectedNetwork }: AssetMetricsTablePr Asset - - - - - + + + + + diff --git a/app/admin/stats/components/TransactionTableBody.tsx b/app/admin/stats/components/TransactionTableBody.tsx index 4c9ca0ef..f465d0d3 100644 --- a/app/admin/stats/components/TransactionTableBody.tsx +++ b/app/admin/stats/components/TransactionTableBody.tsx @@ -1,272 +1,151 @@ import React from 'react'; -import { ExternalLinkIcon } from '@radix-ui/react-icons'; import Link from 'next/link'; import { formatUnits } from 'viem'; +import { AddressIdentity } from '@/components/common/AddressIdentity'; +import { TransactionIdentity } from '@/components/common/TransactionIdentity'; +import { MarketIdBadge } from '@/components/MarketIdBadge'; +import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/components/MarketIdentity'; import { TokenIcon } from '@/components/TokenIcon'; import { formatReadable } from '@/utils/balance'; -import { getExplorerTxURL, getExplorerURL } from '@/utils/external'; import { SupportedNetworks } from '@/utils/networks'; -import { Transaction } from '@/utils/statsUtils'; -import { findToken } from '@/utils/tokens'; import { getTruncatedAssetName } from '@/utils/oracle'; +import { findToken } from '@/utils/tokens'; +import { Market } from '@/utils/types'; -type TransactionTableBodyProps = { - currentEntries: Transaction[]; - selectedNetwork: SupportedNetworks; -}; - -type MarketInfo = { +type TransactionOperation = { + txId: string; + txHash: string; + timestamp: string; + user: string; loanAddress: string; - collateralAddress?: string; -}; - -type LoanAssetInfo = { - address: string; - symbol: string; + loanSymbol: string; + side: 'Supply' | 'Withdraw'; + amount: string; + marketId: string; + market?: Market; }; -const formatAddress = (address: string): string => { - if (address.length < 10) return address; - return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; +type TransactionTableBodyProps = { + operations: TransactionOperation[]; + selectedNetwork: SupportedNetworks; }; -const formatTimestamp = (timestamp: string): string => { - const date = new Date(Number(timestamp) * 1000); - return date.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); -}; +const formatTimeAgo = (timestamp: string): string => { + const now = Date.now(); + const txTime = Number(timestamp) * 1000; + const diffInSeconds = Math.floor((now - txTime) / 1000); -const extractLoanAssets = (tx: Transaction, chainId: number): LoanAssetInfo[] => { - const loanAssetsSet = new Map(); + if (diffInSeconds < 60) return `${diffInSeconds}s ago`; - // Extract from supplies - tx.supplies?.forEach((supply) => { - if (supply.market?.loan) { - const address = supply.market.loan.toLowerCase(); - if (!loanAssetsSet.has(address)) { - const token = findToken(address, chainId); - loanAssetsSet.set(address, { - address, - symbol: token?.symbol ?? 'Unknown', - }); - } - } - }); + const diffInMinutes = Math.floor(diffInSeconds / 60); + if (diffInMinutes < 60) return `${diffInMinutes}m ago`; - // Extract from withdrawals - tx.withdrawals?.forEach((withdrawal) => { - if (withdrawal.market?.loan) { - const address = withdrawal.market.loan.toLowerCase(); - if (!loanAssetsSet.has(address)) { - const token = findToken(address, chainId); - loanAssetsSet.set(address, { - address, - symbol: token?.symbol ?? 'Unknown', - }); - } - } - }); + const diffInHours = Math.floor(diffInMinutes / 60); + if (diffInHours < 24) return `${diffInHours}h ago`; - return Array.from(loanAssetsSet.values()); -}; + const diffInDays = Math.floor(diffInHours / 24); + if (diffInDays < 30) return `${diffInDays}d ago`; -const extractMarkets = (tx: Transaction): MarketInfo[] => { - const marketsSet = new Map(); + const diffInMonths = Math.floor(diffInDays / 30); + if (diffInMonths < 12) return `${diffInMonths}mo ago`; - // Extract from supplies - tx.supplies?.forEach((supply) => { - if (supply.market?.loan) { - const key = `${supply.market.loan}-${supply.market.collateral ?? 'none'}`; - if (!marketsSet.has(key)) { - marketsSet.set(key, { - loanAddress: supply.market.loan, - collateralAddress: supply.market.collateral, - }); - } - } - }); - - // Extract from withdrawals - tx.withdrawals?.forEach((withdrawal) => { - if (withdrawal.market?.loan) { - const key = `${withdrawal.market.loan}-${withdrawal.market.collateral ?? 'none'}`; - if (!marketsSet.has(key)) { - marketsSet.set(key, { - loanAddress: withdrawal.market.loan, - collateralAddress: withdrawal.market.collateral, - }); - } - } - }); - - return Array.from(marketsSet.values()); + const diffInYears = Math.floor(diffInMonths / 12); + return `${diffInYears}y ago`; }; -const formatVolume = (volume: string, assetAddress: string, chainId: number): string => { - if (!volume || volume === '0') return '—'; +const formatAmount = ( + amount: string, + side: 'Supply' | 'Withdraw', + loanAddress: string, + chainId: number, +): string => { + if (!amount || amount === '0') return '—'; - const token = findToken(assetAddress, chainId); + const token = findToken(loanAddress, chainId); const decimals = token?.decimals ?? 18; const symbol = token?.symbol ?? ''; - const formatted = formatUnits(BigInt(volume), decimals); - return `${formatReadable(Number(formatted))} ${symbol}`; + const formatted = formatUnits(BigInt(amount), decimals); + const sign = side === 'Supply' ? '+' : '-'; + return `${sign}${formatReadable(Number(formatted))} ${symbol}`; }; export function TransactionTableBody({ - currentEntries, + operations, selectedNetwork, }: TransactionTableBodyProps) { + return ( - {currentEntries.map((tx) => { - const loanAssets = extractLoanAssets(tx, selectedNetwork); - const markets = extractMarkets(tx); - const totalVolume = - Number(BigInt(tx.supplyVolume ?? '0') + BigInt(tx.withdrawVolume ?? '0')); - - // Get primary asset for volume formatting (from first supply or withdrawal) - const primaryAsset = - tx.supplies?.[0]?.market?.loan ?? tx.withdrawals?.[0]?.market?.loan ?? ''; + {operations.map((op) => { + const marketPath = op.market + ? `/market/${selectedNetwork}/${op.market.uniqueKey}` + : null; return ( - - {/* Transaction Hash */} - - e.stopPropagation()} - > - {formatAddress(tx.id)} - - - - + {/* User Address */} - - e.stopPropagation()} - > - {formatAddress(tx.user)} - - - - - {/* Loan Assets */} - -
- {loanAssets.length > 0 ? ( - loanAssets.slice(0, 2).map((asset, idx) => ( -
- - - {getTruncatedAssetName(asset.symbol)} - -
- )) - ) : ( - - )} - {loanAssets.length > 2 && ( - +{loanAssets.length - 2} - )} -
+ + - {/* Markets (Collateral only) */} - -
- {markets.length > 0 ? ( - markets.slice(0, 3).map((market, idx) => { - const collateralToken = market.collateralAddress - ? findToken(market.collateralAddress, selectedNetwork) - : null; - return ( -
- {market.collateralAddress ? ( - <> - - - {getTruncatedAssetName(collateralToken?.symbol ?? 'Unknown')} - - - ) : ( - Idle - )} -
- ); - }) - ) : ( - - )} - {markets.length > 3 && ( - +{markets.length - 3} - )} + {/* Loan Asset */} + +
+ + + {getTruncatedAssetName(op.loanSymbol)} +
- {/* Timestamp */} - - {formatTimestamp(tx.timestamp)} + {/* Market */} + + {op.market && marketPath ? ( + +
+ + +
+ + ) : ( + + )} - {/* Supply Volume */} - - - {formatVolume(tx.supplyVolume ?? '0', primaryAsset, selectedNetwork)} + {/* Side */} + + + {op.side} - {/* Supply Count */} - - {tx.supplyCount ?? 0} - - - {/* Withdraw Volume */} - + {/* Amount */} + - {formatVolume(tx.withdrawVolume ?? '0', primaryAsset, selectedNetwork)} + {formatAmount(op.amount, op.side, op.loanAddress, selectedNetwork)} - {/* Withdraw Count */} - - {tx.withdrawCount ?? 0} + {/* Transaction Hash */} + + - {/* Total Volume */} - - - {totalVolume > 0 - ? formatVolume( - (BigInt(tx.supplyVolume ?? '0') + BigInt(tx.withdrawVolume ?? '0')).toString(), - primaryAsset, - selectedNetwork, - ) - : '—'} + {/* Time */} + + + {formatTimeAgo(op.timestamp)} diff --git a/app/admin/stats/components/TransactionsTable.tsx b/app/admin/stats/components/TransactionsTable.tsx index 3ba455c6..050d5eb0 100644 --- a/app/admin/stats/components/TransactionsTable.tsx +++ b/app/admin/stats/components/TransactionsTable.tsx @@ -2,28 +2,75 @@ import React, { useState, useMemo } from 'react'; import { FiChevronUp, FiChevronDown } from 'react-icons/fi'; import { SupportedNetworks } from '@/utils/networks'; import { Transaction } from '@/utils/statsUtils'; -import { TransactionTableBody } from './TransactionTableBody'; +import { findToken } from '@/utils/tokens'; +import { Market } from '@/utils/types'; import { Pagination } from '../../../markets/components/Pagination'; +import { TransactionTableBody } from './TransactionTableBody'; type TransactionsTableProps = { data: Transaction[]; selectedNetwork: SupportedNetworks; selectedLoanAssets?: string[]; + selectedSides?: ('Supply' | 'Withdraw')[]; + allMarkets: Market[]; }; -type SortKey = - | 'timestamp' - | 'totalVolume' - | 'supplyVolume' - | 'withdrawVolume' - | 'supplyCount' - | 'withdrawCount'; +type SortKey = 'timestamp' | 'amount'; type SortDirection = 'asc' | 'desc'; +type TransactionOperation = { + txId: string; + txHash: string; + timestamp: string; + user: string; + loanAddress: string; + loanSymbol: string; + side: 'Supply' | 'Withdraw'; + amount: string; + marketId: string; + market?: Market; +}; + +type SortableHeaderProps = { + label: string; + sortKeyValue: SortKey; + currentSortKey: SortKey; + sortDirection: SortDirection; + onSort: (key: SortKey) => void; +}; + +function SortableHeader({ + label, + sortKeyValue, + currentSortKey, + sortDirection, + onSort, +}: SortableHeaderProps) { + return ( + onSort(sortKeyValue)} + style={{ padding: '0.5rem' }} + > +
+
{label}
+ {currentSortKey === sortKeyValue && + (sortDirection === 'asc' ? ( + + ) : ( + + ))} +
+ + ); +} + export function TransactionsTable({ data, selectedNetwork, selectedLoanAssets = [], + selectedSides = [], + allMarkets, }: TransactionsTableProps) { const [sortKey, setSortKey] = useState('timestamp'); const [sortDirection, setSortDirection] = useState('desc'); @@ -39,57 +86,99 @@ export function TransactionsTable({ } }; - // Filter transactions by selected loan assets - const filteredData = useMemo(() => { - if (selectedLoanAssets.length === 0) { - return data; - } + // Flatten transactions into operations + const operations = useMemo(() => { + const ops: TransactionOperation[] = []; - // Extract addresses from the asset keys (format: "address-chainId|address-chainId|...") - // infoToKey returns "address-chainId", and multiple networks are joined by "|" - const selectedAddresses = selectedLoanAssets.flatMap((assetKey) => - assetKey.split('|').map((key) => key.split('-')[0].toLowerCase()), - ); + // Create a map for faster market lookup + const marketMap = new Map(); + allMarkets.forEach((market) => { + if (market.uniqueKey) { + marketMap.set(market.uniqueKey.toLowerCase(), market); + } + }); - return data.filter((tx) => { - // Check if any supply involves a selected loan asset - const hasMatchingSupply = tx.supplies?.some((supply) => - selectedAddresses.includes(supply.market?.loan?.toLowerCase() ?? ''), - ); + data.forEach((tx) => { + // Process supplies + tx.supplies?.forEach((supply) => { + if (supply.market?.loan && supply.market?.id) { + const loanToken = findToken(supply.market.loan, selectedNetwork); + const market = marketMap.get(supply.market.id.toLowerCase()); - // Check if any withdrawal involves a selected loan asset - const hasMatchingWithdrawal = tx.withdrawals?.some((withdrawal) => - selectedAddresses.includes(withdrawal.market?.loan?.toLowerCase() ?? ''), - ); + ops.push({ + txId: `${tx.id}-supply-${supply.id}`, + txHash: tx.id, + timestamp: tx.timestamp, + user: tx.user, + loanAddress: supply.market.loan, + loanSymbol: loanToken?.symbol ?? 'Unknown', + side: 'Supply', + amount: supply.amount ?? '0', + marketId: supply.market.id, + market, + }); + } + }); + + // Process withdrawals + tx.withdrawals?.forEach((withdrawal) => { + if (withdrawal.market?.loan && withdrawal.market?.id) { + const loanToken = findToken(withdrawal.market.loan, selectedNetwork); + const market = marketMap.get(withdrawal.market.id.toLowerCase()); - return hasMatchingSupply || hasMatchingWithdrawal; + ops.push({ + txId: `${tx.id}-withdraw-${withdrawal.id}`, + txHash: tx.id, + timestamp: tx.timestamp, + user: tx.user, + loanAddress: withdrawal.market.loan, + loanSymbol: loanToken?.symbol ?? 'Unknown', + side: 'Withdraw', + amount: withdrawal.amount ?? '0', + marketId: withdrawal.market.id, + market, + }); + } + }); }); - }, [data, selectedLoanAssets]); + + return ops; + }, [data, selectedNetwork, allMarkets]); + + // Filter operations by selected loan assets and sides + const filteredData = useMemo(() => { + let filtered = operations; + + // Filter by loan assets + if (selectedLoanAssets.length > 0) { + const selectedAddresses = selectedLoanAssets.flatMap((assetKey) => + assetKey.split('|').map((key) => key.split('-')[0].toLowerCase()), + ); + filtered = filtered.filter((op) => + selectedAddresses.includes(op.loanAddress.toLowerCase()), + ); + } + + // Filter by sides + if (selectedSides.length > 0) { + filtered = filtered.filter((op) => selectedSides.includes(op.side)); + } + + return filtered; + }, [operations, selectedLoanAssets, selectedSides]); const sortedData = useMemo(() => { return [...filteredData].sort((a, b) => { - let valueA: number | string; - let valueB: number | string; + let valueA: number; + let valueB: number; if (sortKey === 'timestamp') { valueA = Number(a.timestamp); valueB = Number(b.timestamp); - } else if (sortKey === 'totalVolume') { - valueA = Number(BigInt(a.supplyVolume ?? '0') + BigInt(a.withdrawVolume ?? '0')); - valueB = Number(BigInt(b.supplyVolume ?? '0') + BigInt(b.withdrawVolume ?? '0')); - } else if (sortKey === 'supplyVolume') { - valueA = Number(BigInt(a.supplyVolume ?? '0')); - valueB = Number(BigInt(b.supplyVolume ?? '0')); - } else if (sortKey === 'withdrawVolume') { - valueA = Number(BigInt(a.withdrawVolume ?? '0')); - valueB = Number(BigInt(b.withdrawVolume ?? '0')); - } else if (sortKey === 'supplyCount') { - valueA = a.supplyCount ?? 0; - valueB = b.supplyCount ?? 0; } else { - // withdrawCount - valueA = a.withdrawCount ?? 0; - valueB = b.withdrawCount ?? 0; + // amount + valueA = Number(a.amount); + valueB = Number(b.amount); } if (sortDirection === 'asc') { @@ -106,36 +195,15 @@ export function TransactionsTable({ const currentEntries = sortedData.slice(indexOfFirstEntry, indexOfLastEntry); const totalPages = Math.ceil(sortedData.length / entriesPerPage); - const SortableHeader = ({ - label, - sortKeyValue, - }: { - label: string; - sortKeyValue: SortKey; - }) => ( - handleSort(sortKeyValue)} - style={{ padding: '0.5rem' }} - > -
-
{label}
- {sortKey === sortKeyValue && - (sortDirection === 'asc' ? ( - - ) : ( - - ))} -
- - ); + // Convert operations count to display + const totalOperations = sortedData.length; return (

Transactions

- {sortedData.length} transaction{sortedData.length !== 1 ? 's' : ''} in selected timeframe + {totalOperations} operation{totalOperations !== 1 ? 's' : ''} in selected timeframe

@@ -146,20 +214,29 @@ export function TransactionsTable({ - - - - - - - - + + + + +
Tx Hash User Loan AssetCollateralMarketSideTx Hash
diff --git a/app/admin/stats/page.tsx b/app/admin/stats/page.tsx index 32732f6a..c50eb646 100644 --- a/app/admin/stats/page.tsx +++ b/app/admin/stats/page.tsx @@ -7,15 +7,16 @@ import Image from 'next/image'; import { FiChevronDown } from 'react-icons/fi'; import ButtonGroup from '@/components/ButtonGroup'; import { Spinner } from '@/components/common/Spinner'; +import { TokenIcon } from '@/components/TokenIcon'; +import { useMarkets } from '@/contexts/MarketsContext'; import { fetchAllStatistics } from '@/services/statsService'; -import { SupportedNetworks, getNetworkImg, getNetworkName } from '@/utils/networks'; +import { SupportedNetworks, getNetworkImg, getNetworkName, getViemChain } from '@/utils/networks'; import { PlatformStats, TimeFrame, AssetVolumeData, Transaction } from '@/utils/statsUtils'; +import { ERC20Token, UnknownERC20Token, TokenSource } from '@/utils/tokens'; +import { findToken as findTokenStatic } from '@/utils/tokens'; import { AssetMetricsTable } from './components/AssetMetricsTable'; import { StatsOverviewCards } from './components/StatsOverviewCards'; import { TransactionsTable } from './components/TransactionsTable'; -import AssetFilter from '../../markets/components/AssetFilter'; -import { ERC20Token, UnknownERC20Token, TokenSource } from '@/utils/tokens'; -import { findToken as findTokenStatic } from '@/utils/tokens'; const getAPIEndpoint = (network: SupportedNetworks) => { switch (network) { @@ -33,6 +34,7 @@ export default function StatsPage() { const [selectedNetwork, setSelectedNetwork] = useState(SupportedNetworks.Base); const [isLoading, setIsLoading] = useState(true); const [selectedLoanAssets, setSelectedLoanAssets] = useState([]); + const [selectedSides, setSelectedSides] = useState<('Supply' | 'Withdraw')[]>([]); const [uniqueLoanAssets, setUniqueLoanAssets] = useState<(ERC20Token | UnknownERC20Token)[]>([]); const [stats, setStats] = useState<{ platformStats: PlatformStats; @@ -54,6 +56,8 @@ export default function StatsPage() { transactions: [], }); + const { allMarkets } = useMarkets(); + useEffect(() => { const loadStats = async () => { setIsLoading(true); @@ -149,7 +153,7 @@ export default function StatsPage() { decimals: asset.decimals, networks: [ { - chain: { id: selectedNetwork } as any, + chain: getViemChain(selectedNetwork), address: asset.address, }, ], @@ -261,24 +265,108 @@ export default function StatsPage() { ) : (
- + {/* Transaction Filters */}
- + {/* Loan Asset Filter */} + + + + + { + const selected = Array.from(keys) as string[]; + setSelectedLoanAssets(selected); + }} + className="font-zen" + > + {uniqueLoanAssets.map((asset) => { + const assetKey = asset.networks + .map((n) => `${n.address}-${n.chain.id}`) + .join('|'); + const firstNetwork = asset.networks[0]; + + return ( + + } + > + {asset.symbol} + + ); + })} + + + + {/* Side Filter */} + + + + + { + const selected = Array.from(keys) as ('Supply' | 'Withdraw')[]; + setSelectedSides(selected); + }} + className="font-zen" + > + + Supply + + + Withdraw + + +
)} diff --git a/src/components/common/AddressIdentity.tsx b/src/components/common/AddressIdentity.tsx new file mode 100644 index 00000000..88e8ceda --- /dev/null +++ b/src/components/common/AddressIdentity.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useMemo } from 'react'; +import { ExternalLinkIcon } from '@radix-ui/react-icons'; +import Link from 'next/link'; +import { Address } from 'viem'; +import { Name } from '@/components/common/Name'; +import { getExplorerURL } from '@/utils/external'; +import { SupportedNetworks } from '@/utils/networks'; + +type AddressIdentityProps = { + address: Address; + chainId: SupportedNetworks; + showExplorerLink?: boolean; + className?: string; +}; + +export function AddressIdentity({ + address, + chainId, + showExplorerLink = true, + className = '', +}: AddressIdentityProps) { + const explorerHref = useMemo(() => { + return getExplorerURL(address as `0x${string}`, chainId); + }, [address, chainId]); + + if (!showExplorerLink) { + return ( +
+ +
+ ); + } + + return ( + e.stopPropagation()} + > + + + + ); +} diff --git a/src/components/common/TransactionIdentity.tsx b/src/components/common/TransactionIdentity.tsx new file mode 100644 index 00000000..a194c884 --- /dev/null +++ b/src/components/common/TransactionIdentity.tsx @@ -0,0 +1,37 @@ +import { ExternalLinkIcon } from '@radix-ui/react-icons'; +import Link from 'next/link'; +import { getExplorerTxURL } from '@/utils/external'; +import { SupportedNetworks } from '@/utils/networks'; + +type TransactionIdentityProps = { + txHash: string; + chainId: SupportedNetworks; + showFullHash?: boolean; + className?: string; +}; + +const formatTxHash = (hash: string, showFull: boolean): string => { + if (showFull) return hash; + if (hash.length < 10) return hash; + return `${hash.substring(0, 6)}...${hash.substring(hash.length - 4)}`; +}; + +export function TransactionIdentity({ + txHash, + chainId, + showFullHash = false, + className = '', +}: TransactionIdentityProps) { + return ( + e.stopPropagation()} + > + {formatTxHash(txHash, showFullHash)} + + + ); +} diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 8b166a86..b61f8abb 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -1 +1,3 @@ export * from './Button'; +export * from './TransactionIdentity'; +export * from './AddressIdentity'; diff --git a/src/utils/statsUtils.ts b/src/utils/statsUtils.ts index cd0abff3..30d2a11a 100644 --- a/src/utils/statsUtils.ts +++ b/src/utils/statsUtils.ts @@ -17,14 +17,18 @@ export type Transaction = { chainId?: number; market?: string; supplies?: { + id: string; market?: { + id: string; loan: string; collateral?: string; }; amount: string; }[]; withdrawals?: { + id: string; market?: { + id: string; loan: string; collateral?: string; };