-
+
{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
+ ) : (
+ <>
+
+
+
+ | User |
+ Loan Asset |
+ Market |
+ Side |
+
+ Tx Hash |
+
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
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 */}
+
+
+ }
+ className="bg-surface min-w-[160px] border border-divider font-zen hover:bg-default-100 active:bg-default-200"
+ >
+ {selectedLoanAssets.length === 0
+ ? 'All loan assets'
+ : selectedLoanAssets.length === 1
+ ? uniqueLoanAssets.find((asset) => {
+ const assetKey = asset.networks
+ .map((n) => `${n.address}-${n.chain.id}`)
+ .join('|');
+ return selectedLoanAssets.includes(assetKey);
+ })?.symbol ?? 'Selected'
+ : `${selectedLoanAssets.length} selected`}
+
+
+ {
+ 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 */}
+
+
+ }
+ className="bg-surface min-w-[140px] border border-divider font-zen hover:bg-default-100 active:bg-default-200"
+ >
+ {selectedSides.length === 0
+ ? 'All sides'
+ : selectedSides.length === 1
+ ? selectedSides[0]
+ : `${selectedSides.length} selected`}
+
+
+ {
+ 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 (
+