diff --git a/app/admin/stats/components/AssetMetricsTable.tsx b/app/admin/stats/components/AssetMetricsTable.tsx index d798e8fc..0d538124 100644 --- a/app/admin/stats/components/AssetMetricsTable.tsx +++ b/app/admin/stats/components/AssetMetricsTable.tsx @@ -1,11 +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'; @@ -14,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'); @@ -42,12 +71,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; @@ -72,130 +95,63 @@ export function AssetMetricsTable({ data, selectedNetwork }: AssetMetricsTablePr }, [processedData, sortKey, sortDirection]); 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..f465d0d3 --- /dev/null +++ b/app/admin/stats/components/TransactionTableBody.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +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 { SupportedNetworks } from '@/utils/networks'; +import { getTruncatedAssetName } from '@/utils/oracle'; +import { findToken } from '@/utils/tokens'; +import { Market } from '@/utils/types'; + +type TransactionOperation = { + txId: string; + txHash: string; + timestamp: string; + user: string; + loanAddress: string; + loanSymbol: string; + side: 'Supply' | 'Withdraw'; + amount: string; + marketId: string; + market?: Market; +}; + +type TransactionTableBodyProps = { + operations: TransactionOperation[]; + selectedNetwork: SupportedNetworks; +}; + +const formatTimeAgo = (timestamp: string): string => { + const now = Date.now(); + const txTime = Number(timestamp) * 1000; + const diffInSeconds = Math.floor((now - txTime) / 1000); + + if (diffInSeconds < 60) return `${diffInSeconds}s ago`; + + const diffInMinutes = Math.floor(diffInSeconds / 60); + if (diffInMinutes < 60) return `${diffInMinutes}m ago`; + + const diffInHours = Math.floor(diffInMinutes / 60); + if (diffInHours < 24) return `${diffInHours}h ago`; + + const diffInDays = Math.floor(diffInHours / 24); + if (diffInDays < 30) return `${diffInDays}d ago`; + + const diffInMonths = Math.floor(diffInDays / 30); + if (diffInMonths < 12) return `${diffInMonths}mo ago`; + + const diffInYears = Math.floor(diffInMonths / 12); + return `${diffInYears}y ago`; +}; + +const formatAmount = ( + amount: string, + side: 'Supply' | 'Withdraw', + loanAddress: string, + chainId: number, +): string => { + if (!amount || amount === '0') return '—'; + + const token = findToken(loanAddress, chainId); + const decimals = token?.decimals ?? 18; + const symbol = token?.symbol ?? ''; + + const formatted = formatUnits(BigInt(amount), decimals); + const sign = side === 'Supply' ? '+' : '-'; + return `${sign}${formatReadable(Number(formatted))} ${symbol}`; +}; + +export function TransactionTableBody({ + operations, + selectedNetwork, +}: TransactionTableBodyProps) { + + return ( + + {operations.map((op) => { + const marketPath = op.market + ? `/market/${selectedNetwork}/${op.market.uniqueKey}` + : null; + + return ( + + {/* User Address */} + + + + + {/* Loan Asset */} + +
+ + + {getTruncatedAssetName(op.loanSymbol)} + +
+ + + {/* Market */} + + {op.market && marketPath ? ( + +
+ + +
+ + ) : ( + + )} + + + {/* Side */} + + + {op.side} + + + + {/* Amount */} + + + {formatAmount(op.amount, op.side, op.loanAddress, selectedNetwork)} + + + + {/* Transaction Hash */} + + + + + {/* Time */} + + + {formatTimeAgo(op.timestamp)} + + + + ); + })} + + ); +} diff --git a/app/admin/stats/components/TransactionsTable.tsx b/app/admin/stats/components/TransactionsTable.tsx new file mode 100644 index 00000000..050d5eb0 --- /dev/null +++ b/app/admin/stats/components/TransactionsTable.tsx @@ -0,0 +1,257 @@ +import React, { useState, useMemo } from 'react'; +import { FiChevronUp, FiChevronDown } from 'react-icons/fi'; +import { SupportedNetworks } from '@/utils/networks'; +import { Transaction } from '@/utils/statsUtils'; +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' | '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'); + const [currentPage, setCurrentPage] = useState(1); + const entriesPerPage = 10; + + const handleSort = (key: SortKey) => { + if (key === sortKey) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortDirection('desc'); + } + }; + + // Flatten transactions into operations + const operations = useMemo(() => { + const ops: TransactionOperation[] = []; + + // Create a map for faster market lookup + const marketMap = new Map(); + allMarkets.forEach((market) => { + if (market.uniqueKey) { + marketMap.set(market.uniqueKey.toLowerCase(), market); + } + }); + + 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()); + + 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()); + + 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, + }); + } + }); + }); + + 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; + let valueB: number; + + if (sortKey === 'timestamp') { + valueA = Number(a.timestamp); + valueB = Number(b.timestamp); + } else { + // amount + valueA = Number(a.amount); + valueB = Number(b.amount); + } + + 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); + + // Convert operations count to display + const totalOperations = sortedData.length; + + return ( +
+
+

Transactions

+

+ {totalOperations} operation{totalOperations !== 1 ? 's' : ''} in selected timeframe +

+
+
+ {sortedData.length === 0 ? ( +
No transaction data available
+ ) : ( + <> + + + + + + + + + + + + + +
UserLoan AssetMarketSideTx Hash
+
+ 0} + /> +
+ + )} +
+
+ ); +} diff --git a/app/admin/stats/page.tsx b/app/admin/stats/page.tsx index ad01129b..c50eb646 100644 --- a/app/admin/stats/page.tsx +++ b/app/admin/stats/page.tsx @@ -7,18 +7,23 @@ 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 { PlatformStats, TimeFrame, AssetVolumeData } from '@/utils/statsUtils'; +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'; 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 +33,13 @@ 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 [selectedSides, setSelectedSides] = useState<('Supply' | 'Withdraw')[]>([]); + const [uniqueLoanAssets, setUniqueLoanAssets] = useState<(ERC20Token | UnknownERC20Token)[]>([]); const [stats, setStats] = useState<{ platformStats: PlatformStats; assetMetrics: AssetVolumeData[]; + transactions: Transaction[]; }>({ platformStats: { uniqueUsers: 0, @@ -44,8 +53,11 @@ export default function StatsPage() { activeMarkets: 0, }, assetMetrics: [], + transactions: [], }); + const { allMarkets } = useMarkets(); + useEffect(() => { const loadStats = async () => { setIsLoading(true); @@ -75,6 +87,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 +99,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: getViemChain(selectedNetwork), + 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' }, @@ -187,7 +265,109 @@ 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/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: [], }; } }; 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; };