diff --git a/src/app/leaderboard/layout.tsx b/src/app/leaderboard/layout.tsx
new file mode 100644
index 0000000..74e0aaa
--- /dev/null
+++ b/src/app/leaderboard/layout.tsx
@@ -0,0 +1,11 @@
+import { DashboardLayout } from 'src/layouts/dashboard'
+
+// ----------------------------------------------------------------------
+
+type Props = {
+ children: React.ReactNode
+}
+
+export default function Layout({ children }: Props) {
+ return {children}
+}
diff --git a/src/app/leaderboard/loading.tsx b/src/app/leaderboard/loading.tsx
new file mode 100644
index 0000000..eb8c308
--- /dev/null
+++ b/src/app/leaderboard/loading.tsx
@@ -0,0 +1,7 @@
+import { LoadingScreen } from 'src/components/loading-screen'
+
+// ----------------------------------------------------------------------
+
+export default function Loading() {
+ return
+}
diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx
new file mode 100644
index 0000000..17cc5d7
--- /dev/null
+++ b/src/app/leaderboard/page.tsx
@@ -0,0 +1,30 @@
+'use client'
+
+import { Box, Typography } from '@mui/material'
+import { LeaderboardTable } from 'src/components/leaderboard'
+import { DashboardContent } from 'src/layouts/dashboard'
+import { Iconify } from 'src/components/iconify'
+
+export default function LeaderboardPage() {
+ return (
+
+ {/* Page Header */}
+
+
+
+ Leaderboard
+
+
+ Top bridge users ranked by volume and transaction activity
+
+
+
+ {/* Leaderboard Table */}
+
+
+ )
+}
diff --git a/src/components/leaderboard/activity-sparkline.tsx b/src/components/leaderboard/activity-sparkline.tsx
new file mode 100644
index 0000000..98c36b3
--- /dev/null
+++ b/src/components/leaderboard/activity-sparkline.tsx
@@ -0,0 +1,129 @@
+import { Box } from '@mui/material'
+import { useTheme, alpha } from '@mui/material/styles'
+import { useMemo, useId } from 'react'
+
+interface ActivitySparklineProps {
+ data: number[]
+ width?: number
+ height?: number
+ color?: string
+}
+
+interface Point {
+ x: number
+ y: number
+}
+
+export function ActivitySparkline({
+ data,
+ width = 80,
+ height = 30,
+ color,
+}: ActivitySparklineProps) {
+ const theme = useTheme()
+ const strokeColor = color || theme.palette.primary.main
+ const gradientId = useId()
+
+ const { path, points, hasActivity } = useMemo(() => {
+ if (!data || data.length === 0) {
+ return { path: '', points: [] as Point[], hasActivity: false }
+ }
+
+ const max = Math.max(...data, 1) // Ensure at least 1 to avoid division by zero
+ const hasAct = data.some(v => v > 0)
+
+ const padding = 2
+ const chartWidth = width - padding * 2
+ const chartHeight = height - padding * 2
+
+ const pts = data.map((value, index) => {
+ const x = padding + (index / (data.length - 1 || 1)) * chartWidth
+ const y = padding + chartHeight - (value / max) * chartHeight
+ return { x, y }
+ })
+
+ // Create SVG path
+ const pathData = pts
+ .map((point, index) => {
+ if (index === 0) return `M ${point.x} ${point.y}`
+ return `L ${point.x} ${point.y}`
+ })
+ .join(' ')
+
+ return { path: pathData, points: pts, hasActivity: hasAct }
+ }, [data, width, height])
+
+ // Create area path (for fill) - reuse points from above
+ const areaPath = useMemo(() => {
+ if (points.length === 0 || !path) return ''
+
+ const padding = 2
+ const chartHeight = height - padding * 2
+ const bottomY = padding + chartHeight
+ const firstX = points[0]?.x || padding
+ const lastX = points[points.length - 1]?.x || width - padding
+
+ return `${path} L ${lastX} ${bottomY} L ${firstX} ${bottomY} Z`
+ }, [points, path, width, height])
+
+ // No activity - show placeholder (after all hooks)
+ if (!hasActivity) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/leaderboard/address-cell.tsx b/src/components/leaderboard/address-cell.tsx
new file mode 100644
index 0000000..806e8f8
--- /dev/null
+++ b/src/components/leaderboard/address-cell.tsx
@@ -0,0 +1,101 @@
+import { Box, IconButton, Tooltip, Typography } from '@mui/material'
+import { useTheme, alpha } from '@mui/material/styles'
+import { useState } from 'react'
+import { Iconify } from 'src/components/iconify'
+import { truncateAddress } from 'src/config/helper'
+
+interface AddressCellProps {
+ address: string
+ addressType: 'sui' | 'eth'
+ onClick?: () => void
+}
+
+export function AddressCell({ address, addressType, onClick }: AddressCellProps) {
+ const theme = useTheme()
+ const [copied, setCopied] = useState(false)
+
+ const handleCopy = async (e: React.MouseEvent) => {
+ e.stopPropagation()
+ try {
+ const fullAddress = `0x${address}`
+ await navigator.clipboard.writeText(fullAddress)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ } catch (err) {
+ console.error('Failed to copy address:', err)
+ }
+ }
+
+ const chainColor = addressType === 'sui' ? '#4DA2FF' : '#627EEA'
+ const chainIcon =
+ addressType === 'sui' ? '/assets/icons/brands/sui.svg' : '/assets/icons/brands/eth.svg'
+
+ return (
+
+ {/* Chain icon */}
+
+
+
+
+
+
+ {/* Address text */}
+
+ {truncateAddress(address, 6)}
+
+
+ {/* Copy button */}
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/leaderboard/index.ts b/src/components/leaderboard/index.ts
new file mode 100644
index 0000000..eb0a217
--- /dev/null
+++ b/src/components/leaderboard/index.ts
@@ -0,0 +1,5 @@
+export * from './leaderboard-table'
+export * from './leaderboard-stats'
+export * from './medal-rank'
+export * from './activity-sparkline'
+export * from './address-cell'
diff --git a/src/components/leaderboard/leaderboard-stats.tsx b/src/components/leaderboard/leaderboard-stats.tsx
new file mode 100644
index 0000000..c7970d8
--- /dev/null
+++ b/src/components/leaderboard/leaderboard-stats.tsx
@@ -0,0 +1,125 @@
+import { Grid, Card, Box, Typography, Skeleton } from '@mui/material'
+import { useTheme } from '@mui/material/styles'
+import { Iconify } from 'src/components/iconify'
+import AnimatedNumbers from 'react-animated-numbers'
+
+interface LeaderboardStatsProps {
+ totalUsers: number
+ totalVolumeUsd: number
+ avgVolumePerUser: number
+ topUserVolume: number
+ isLoading?: boolean
+}
+
+interface StatCardProps {
+ title: string
+ value: number | string
+ icon: string
+ color: string
+ isDollar?: boolean
+ isLoading?: boolean
+}
+
+function StatCard({ title, value, icon, color, isDollar, isLoading }: StatCardProps) {
+ const textStyle = {
+ fontSize: '1.5rem',
+ fontWeight: 'bold',
+ fontFamily: 'Barlow',
+ }
+
+ return (
+
+
+
+
+ {title}
+
+
+
+ {isLoading ? (
+
+ ) : typeof value === 'number' ? (
+
+ {isDollar && $}
+
+
+ ) : (
+
+ {value}
+
+ )}
+
+ )
+}
+
+export function LeaderboardStats({
+ totalUsers,
+ totalVolumeUsd,
+ avgVolumePerUser,
+ topUserVolume,
+ isLoading,
+}: LeaderboardStatsProps) {
+ const theme = useTheme()
+
+ const stats = [
+ {
+ title: 'Total Users',
+ value: totalUsers,
+ icon: 'solar:users-group-rounded-bold-duotone',
+ color: theme.palette.info.main,
+ isDollar: false,
+ },
+ {
+ title: 'Total Volume',
+ value: totalVolumeUsd,
+ icon: 'solar:wallet-money-bold-duotone',
+ color: theme.palette.success.main,
+ isDollar: true,
+ },
+ {
+ title: 'Avg Volume/User',
+ value: avgVolumePerUser,
+ icon: 'solar:chart-square-bold-duotone',
+ color: theme.palette.warning.main,
+ isDollar: true,
+ },
+ {
+ title: 'Top User Volume',
+ value: topUserVolume,
+ icon: 'solar:crown-bold-duotone',
+ color: '#FFD700',
+ isDollar: true,
+ },
+ ]
+
+ return (
+
+ {stats.map((stat, index) => (
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/components/leaderboard/leaderboard-table.tsx b/src/components/leaderboard/leaderboard-table.tsx
new file mode 100644
index 0000000..3dc3f74
--- /dev/null
+++ b/src/components/leaderboard/leaderboard-table.tsx
@@ -0,0 +1,389 @@
+'use client'
+
+import { useState, useEffect, useMemo } from 'react'
+import {
+ Box,
+ Card,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TablePagination,
+ Tabs,
+ Tab,
+ Typography,
+ Skeleton,
+ Chip,
+ ToggleButtonGroup,
+ ToggleButton,
+} from '@mui/material'
+import { useTheme, alpha } from '@mui/material/styles'
+import { useRouter } from 'next/navigation'
+import useSWR from 'swr'
+import { endpoints, fetcher } from 'src/utils/axios'
+import { getNetwork } from 'src/hooks/get-network-storage'
+import { useGlobalContext } from 'src/provider/global-provider'
+import { fCurrency, fNumber } from 'src/utils/format-number'
+import { fDate } from 'src/utils/format-time'
+import { Iconify } from 'src/components/iconify'
+import { paths } from 'src/routes/paths'
+
+import { MedalRank } from './medal-rank'
+import { AddressCell } from './address-cell'
+import { ActivitySparkline } from './activity-sparkline'
+import { LeaderboardStats } from './leaderboard-stats'
+
+import type { LeaderboardResponse } from 'src/pages/api/leaderboard'
+import type { SparklineResponse } from 'src/pages/api/leaderboard-sparklines'
+
+type AddressTypeFilter = 'all' | 'sui' | 'eth'
+type SortByOption = 'volume' | 'count'
+
+const ROWS_PER_PAGE = 25
+
+export function LeaderboardTable() {
+ const theme = useTheme()
+ const router = useRouter()
+ const network = getNetwork()
+ const { timePeriod } = useGlobalContext()
+
+ const [page, setPage] = useState(0)
+ const [addressType, setAddressType] = useState('all')
+ const [sortBy, setSortBy] = useState('volume')
+
+ // Reset page when filters change
+ useEffect(() => {
+ setPage(0)
+ }, [addressType, sortBy, timePeriod])
+
+ // Build query string
+ const queryString = useMemo(() => {
+ const params = new URLSearchParams({
+ network,
+ period: timePeriod,
+ addressType,
+ sortBy,
+ limit: String(ROWS_PER_PAGE),
+ offset: String(page * ROWS_PER_PAGE),
+ })
+ return params.toString()
+ }, [network, timePeriod, addressType, sortBy, page])
+
+ // Fetch leaderboard data
+ const { data, isLoading } = useSWR(
+ `${endpoints.leaderboard}?${queryString}`,
+ fetcher,
+ {
+ revalidateOnFocus: false,
+ dedupingInterval: 30000,
+ },
+ )
+
+ // Get addresses for sparklines
+ const addresses = useMemo(() => {
+ return data?.users?.map(u => u.address) || []
+ }, [data?.users])
+
+ // Fetch sparklines data
+ const { data: sparklineData } = useSWR(
+ addresses.length > 0
+ ? `${endpoints.leaderboardSparklines}?network=${network}&period=${encodeURIComponent(timePeriod)}&addresses=${addresses.join(',')}`
+ : null,
+ fetcher,
+ {
+ revalidateOnFocus: false,
+ dedupingInterval: 60000,
+ },
+ )
+
+ const handleChangePage = (_: unknown, newPage: number) => {
+ setPage(newPage)
+ }
+
+ const handleAddressTypeChange = (_: React.SyntheticEvent, newValue: AddressTypeFilter) => {
+ if (newValue !== null) {
+ setAddressType(newValue)
+ }
+ }
+
+ const handleSortChange = (_: React.MouseEvent, newSort: SortByOption) => {
+ if (newSort !== null) {
+ setSortBy(newSort)
+ }
+ }
+
+ const handleRowClick = (address: string, addressType: 'sui' | 'eth') => {
+ const queryParam = addressType === 'sui' ? 'suiAddress' : 'ethAddress'
+ router.push(`${paths.profile.root}?${queryParam}=0x${address}`)
+ }
+
+ const users = data?.users || []
+ const total = data?.total || 0
+ const stats = data?.stats
+
+ return (
+
+ {/* Stats Cards */}
+
+
+ {/* Main Card */}
+
+ {/* Header with tabs and filters */}
+
+ {/* Address Type Tabs */}
+
+ }
+ iconPosition="start"
+ />
+
+ }
+ iconPosition="start"
+ />
+
+ }
+ iconPosition="start"
+ />
+
+
+ {/* Sort By Toggle */}
+
+
+ Sort by:
+
+
+
+
+ Volume
+
+
+
+ Tx Count
+
+
+
+
+
+ {/* Table */}
+
+
+
+
+
+ Rank
+
+ Address
+ Volume (USD)
+ Transactions
+ Top Token
+
+ Activity
+
+ First Active
+ Last Active
+
+
+
+ {isLoading ? (
+ // Loading skeletons
+ Array.from({ length: 10 }).map((_, index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))
+ ) : users.length === 0 ? (
+
+
+
+
+
+ No users found for the selected filters
+
+
+
+
+ ) : (
+ users.map((user, index) => {
+ const globalRank = page * ROWS_PER_PAGE + index + 1
+ const sparklineValues =
+ sparklineData?.sparklines?.[user.address] || []
+
+ return (
+
+ handleRowClick(user.address, user.address_type)
+ }
+ >
+
+
+
+
+
+
+
+
+ {fCurrency(user.total_volume_usd)}
+
+
+
+
+ {fNumber(user.transaction_count)}
+
+
+
+
+
+
+
+
+
+
+ {fDate(user.first_tx_date)}
+
+
+
+
+ {fDate(user.last_tx_date)}
+
+
+
+ )
+ })
+ )}
+
+
+
+
+ {/* Pagination */}
+
+
+
+ )
+}
diff --git a/src/components/leaderboard/medal-rank.tsx b/src/components/leaderboard/medal-rank.tsx
new file mode 100644
index 0000000..e6baf1b
--- /dev/null
+++ b/src/components/leaderboard/medal-rank.tsx
@@ -0,0 +1,76 @@
+import { Box, Typography } from '@mui/material'
+import { useTheme, alpha } from '@mui/material/styles'
+
+interface MedalRankProps {
+ rank: number
+}
+
+const MEDAL_COLORS = {
+ 1: { primary: '#FFD700', secondary: '#FFA500' }, // Gold
+ 2: { primary: '#C0C0C0', secondary: '#A8A8A8' }, // Silver
+ 3: { primary: '#CD7F32', secondary: '#B87333' }, // Bronze
+}
+
+export function MedalRank({ rank }: MedalRankProps) {
+ const theme = useTheme()
+ const medalConfig = MEDAL_COLORS[rank as keyof typeof MEDAL_COLORS]
+
+ if (!medalConfig) {
+ // Regular rank display for non-medal positions
+ return (
+
+
+ {rank}
+
+
+ )
+ }
+
+ // Medal display for top 3
+ return (
+
+
+ {rank}
+
+
+ )
+}
diff --git a/src/layouts/config-nav-dashboard.tsx b/src/layouts/config-nav-dashboard.tsx
index 531edcc..5a7e976 100644
--- a/src/layouts/config-nav-dashboard.tsx
+++ b/src/layouts/config-nav-dashboard.tsx
@@ -53,6 +53,10 @@ export const navData = [
],
},
+ {
+ items: [{ title: 'Leaderboard', path: paths.leaderboard.root, icon: ICONS.order }],
+ },
+
{
items: [{ title: 'Profile', path: paths.profile.root, icon: ICONS.course }],
},
diff --git a/src/pages/api/leaderboard-sparklines.ts b/src/pages/api/leaderboard-sparklines.ts
new file mode 100644
index 0000000..0908329
--- /dev/null
+++ b/src/pages/api/leaderboard-sparklines.ts
@@ -0,0 +1,108 @@
+import { NextApiRequest, NextApiResponse } from 'next'
+import { getNetworkConfig, TimePeriod } from 'src/config/helper'
+import { sendError, sendReply } from './utils'
+import db from './database'
+import { computerIntervals } from './cards'
+
+export type SparklineData = {
+ address: string
+ data: number[] // Transaction counts per segment
+}
+
+export type SparklineResponse = {
+ sparklines: Record
+}
+
+// Number of segments for the sparkline based on time period
+const SPARKLINE_SEGMENTS = 7
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const networkConfig = getNetworkConfig({ req })
+ const timePeriod = (req.query.period as TimePeriod) || 'Last Week'
+
+ // Get addresses from query (comma-separated)
+ const addressesParam = req.query.addresses?.toString() || ''
+ const addresses = addressesParam
+ .split(',')
+ .map(a => a.trim().toLowerCase())
+ .filter(a => a.length > 0)
+
+ if (addresses.length === 0) {
+ sendReply(res, { sparklines: {} })
+ return
+ }
+
+ // Limit to max 50 addresses per request
+ const limitedAddresses = addresses.slice(0, 50)
+
+ // Use same time intervals as leaderboard
+ const { fromInterval, toInterval } = computerIntervals(timePeriod, false)
+
+ // Calculate segment duration based on time period
+ const totalDuration = toInterval - fromInterval
+ const segmentDuration = Math.floor(totalDuration / SPARKLINE_SEGMENTS)
+
+ // Generate segment boundaries
+ const segments: { start: number; end: number }[] = []
+ for (let i = 0; i < SPARKLINE_SEGMENTS; i++) {
+ segments.push({
+ start: fromInterval + i * segmentDuration,
+ end: fromInterval + (i + 1) * segmentDuration,
+ })
+ }
+
+ // Query to get transaction counts per segment for each address
+ const sparklineQuery = await db[networkConfig.network]`
+ WITH user_transactions AS (
+ SELECT
+ encode(sender_address, 'hex') as address,
+ timestamp_ms
+ FROM public.token_transfer_data
+ WHERE
+ is_finalized = true
+ AND timestamp_ms BETWEEN ${fromInterval} AND ${toInterval}
+ AND sender_address IS NOT NULL
+ AND encode(sender_address, 'hex') = ANY(${limitedAddresses})
+ )
+ SELECT
+ address,
+ ${db[networkConfig.network].unsafe(
+ segments
+ .map(
+ (seg, i) =>
+ `COUNT(*) FILTER (WHERE timestamp_ms >= ${seg.start} AND timestamp_ms < ${seg.end}) as seg_${i}`,
+ )
+ .join(', '),
+ )}
+ FROM user_transactions
+ GROUP BY address
+ `
+
+ // Build response map
+ const sparklines: Record = {}
+
+ sparklineQuery.forEach((row: any) => {
+ const counts: number[] = []
+ for (let i = 0; i < SPARKLINE_SEGMENTS; i++) {
+ counts.push(Number(row[`seg_${i}`]) || 0)
+ }
+ sparklines[row.address] = counts
+ })
+
+ // Fill in empty arrays for addresses with no data
+ limitedAddresses.forEach(addr => {
+ if (!sparklines[addr]) {
+ sparklines[addr] = Array(SPARKLINE_SEGMENTS).fill(0)
+ }
+ })
+
+ sendReply(res, { sparklines })
+ } catch (error) {
+ console.error('Sparklines API error:', error)
+ sendError(res, {
+ code: 500,
+ message: error instanceof Error ? error.message : 'Internal server error',
+ })
+ }
+}
diff --git a/src/pages/api/leaderboard.ts b/src/pages/api/leaderboard.ts
new file mode 100644
index 0000000..f28ae96
--- /dev/null
+++ b/src/pages/api/leaderboard.ts
@@ -0,0 +1,211 @@
+import { NextApiRequest, NextApiResponse } from 'next'
+import { getNetworkConfig, TimePeriod } from 'src/config/helper'
+import { sendError, sendReply } from './utils'
+import db from './database'
+import { computerIntervals } from './cards'
+import { getPrices } from './prices'
+
+export type LeaderboardUser = {
+ rank: number
+ address: string
+ address_type: 'sui' | 'eth'
+ total_volume_usd: number
+ transaction_count: number
+ tokens_used: number[]
+ most_used_token: string
+ most_used_token_count: number
+ first_tx_date: number
+ last_tx_date: number
+}
+
+export type LeaderboardResponse = {
+ users: LeaderboardUser[]
+ total: number
+ stats: {
+ total_users: number
+ total_volume_usd: number
+ avg_volume_per_user: number
+ top_user_address: string
+ top_user_volume: number
+ }
+}
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ const networkConfig = getNetworkConfig({ req })
+ const timePeriod = (req.query.period as TimePeriod) || 'Last Week'
+ const addressType = (req.query.addressType as 'all' | 'sui' | 'eth') || 'all'
+ const sortBy = (req.query.sortBy as 'volume' | 'count') || 'volume'
+ const limit = Math.min(Number(req.query.limit) || 25, 100)
+ const offset = Number(req.query.offset) || 0
+
+ const { fromInterval, toInterval } = computerIntervals(timePeriod, false)
+ const prices = await getPrices(networkConfig.network)
+
+ // Build price lookup map
+ const priceMap: Record = {}
+ prices.forEach((p: any) => {
+ priceMap[p.token_id] = {
+ price: Number(p.price),
+ deno: networkConfig.config.coins[p.token_id]?.deno || 1,
+ name: networkConfig.config.coins[p.token_id]?.name || 'Unknown',
+ }
+ })
+
+ // Build address type filter for WHERE clause
+ const addressTypeFilter =
+ addressType === 'all'
+ ? db[networkConfig.network]``
+ : addressType === 'sui'
+ ? db[
+ networkConfig.network
+ ]`AND t.destination_chain = ${networkConfig.config.networkId.ETH}`
+ : db[
+ networkConfig.network
+ ]`AND t.destination_chain = ${networkConfig.config.networkId.SUI}`
+
+ const orderByClause =
+ sortBy === 'volume'
+ ? db[networkConfig.network]`total_volume_usd DESC NULLS LAST`
+ : db[networkConfig.network]`transaction_count DESC`
+
+ // Run both queries in parallel for better performance
+ const [leaderboardQuery, globalStatsQuery] = await Promise.all([
+ // Main leaderboard query
+ db[networkConfig.network]`
+ WITH base_transactions AS (
+ SELECT
+ encode(t.sender_address, 'hex') as address,
+ t.destination_chain,
+ t.token_id,
+ t.amount,
+ t.timestamp_ms,
+ p.price,
+ p.denominator,
+ (t.amount::NUMERIC / p.denominator) * p.price::NUMERIC as amount_usd
+ FROM public.token_transfer_data t
+ JOIN public.prices p ON t.token_id = p.token_id
+ WHERE
+ t.is_finalized = true
+ AND t.timestamp_ms BETWEEN ${fromInterval} AND ${toInterval}
+ AND t.sender_address IS NOT NULL
+ ${addressTypeFilter}
+ ),
+ user_stats AS (
+ SELECT
+ address,
+ COUNT(*) as transaction_count,
+ array_agg(DISTINCT token_id) as tokens_used,
+ MIN(timestamp_ms) as first_tx_date,
+ MAX(timestamp_ms) as last_tx_date,
+ SUM(amount_usd) as total_volume_usd,
+ -- Determine address type based on majority of transactions
+ CASE
+ WHEN SUM(CASE WHEN destination_chain = ${networkConfig.config.networkId.SUI} THEN 1 ELSE 0 END) >
+ SUM(CASE WHEN destination_chain = ${networkConfig.config.networkId.ETH} THEN 1 ELSE 0 END)
+ THEN 'eth'
+ ELSE 'sui'
+ END as address_type
+ FROM base_transactions
+ GROUP BY address
+ ),
+ ranked_tokens AS (
+ SELECT
+ address,
+ token_id,
+ COUNT(*) as token_count,
+ ROW_NUMBER() OVER (
+ PARTITION BY address
+ ORDER BY COUNT(*) DESC
+ ) as rn
+ FROM base_transactions
+ GROUP BY address, token_id
+ )
+ SELECT
+ us.address,
+ us.address_type,
+ us.transaction_count,
+ us.tokens_used,
+ us.first_tx_date,
+ us.last_tx_date,
+ COALESCE(us.total_volume_usd, 0) as total_volume_usd,
+ rt.token_id as most_used_token_id,
+ rt.token_count as most_used_token_count
+ FROM user_stats us
+ LEFT JOIN ranked_tokens rt ON us.address = rt.address AND rt.rn = 1
+ ORDER BY ${orderByClause}
+ LIMIT ${limit} OFFSET ${offset}
+ `,
+ // Global stats query - always calculates across ALL users (not affected by sort/pagination)
+ db[networkConfig.network]`
+ WITH base_transactions AS (
+ SELECT
+ encode(t.sender_address, 'hex') as address,
+ t.token_id,
+ t.amount,
+ p.price,
+ p.denominator,
+ (t.amount::NUMERIC / p.denominator) * p.price::NUMERIC as amount_usd
+ FROM public.token_transfer_data t
+ JOIN public.prices p ON t.token_id = p.token_id
+ WHERE
+ t.is_finalized = true
+ AND t.timestamp_ms BETWEEN ${fromInterval} AND ${toInterval}
+ AND t.sender_address IS NOT NULL
+ ${addressTypeFilter}
+ ),
+ user_volumes AS (
+ SELECT
+ address,
+ SUM(amount_usd) as total_volume_usd
+ FROM base_transactions
+ GROUP BY address
+ )
+ SELECT
+ COUNT(*) as total_users,
+ COALESCE(SUM(total_volume_usd), 0) as total_volume_usd,
+ COALESCE(AVG(total_volume_usd), 0) as avg_volume_per_user,
+ (SELECT address FROM user_volumes ORDER BY total_volume_usd DESC NULLS LAST LIMIT 1) as top_user_address,
+ (SELECT total_volume_usd FROM user_volumes ORDER BY total_volume_usd DESC NULLS LAST LIMIT 1) as top_user_volume
+ FROM user_volumes
+ `,
+ ])
+
+ // Transform results
+ const users: LeaderboardUser[] = leaderboardQuery.map((row: any, index: number) => ({
+ rank: offset + index + 1,
+ address: row.address,
+ address_type: row.address_type as 'sui' | 'eth',
+ total_volume_usd: Number(row.total_volume_usd) || 0,
+ transaction_count: Number(row.transaction_count),
+ tokens_used: row.tokens_used || [],
+ most_used_token: priceMap[row.most_used_token_id]?.name || 'Unknown',
+ most_used_token_count: Number(row.most_used_token_count) || 0,
+ first_tx_date: Number(row.first_tx_date),
+ last_tx_date: Number(row.last_tx_date),
+ }))
+
+ // Use global stats from the dedicated query
+ const globalStats = globalStatsQuery[0]
+
+ const response: LeaderboardResponse = {
+ users,
+ total: Number(globalStats?.total_users) || 0,
+ stats: {
+ total_users: Number(globalStats?.total_users) || 0,
+ total_volume_usd: Number(globalStats?.total_volume_usd) || 0,
+ avg_volume_per_user: Number(globalStats?.avg_volume_per_user) || 0,
+ top_user_address: globalStats?.top_user_address || '',
+ top_user_volume: Number(globalStats?.top_user_volume) || 0,
+ },
+ }
+
+ sendReply(res, response)
+ } catch (error) {
+ console.error('Leaderboard API error:', error)
+ sendError(res, {
+ code: 500,
+ message: error instanceof Error ? error.message : 'Internal server error',
+ })
+ }
+}
diff --git a/src/routes/paths.ts b/src/routes/paths.ts
index ff8baff..b1bacd3 100644
--- a/src/routes/paths.ts
+++ b/src/routes/paths.ts
@@ -5,6 +5,7 @@ const ROOTS = {
DASHBOARD: '/dashboard',
TRANSACTIONS: '/transactions',
PROFILE: '/profile',
+ LEADERBOARD: '/leaderboard',
}
// ----------------------------------------------------------------------
@@ -63,4 +64,9 @@ export const paths = {
profile: {
root: ROOTS.PROFILE,
},
+
+ // LEADERBOARD
+ leaderboard: {
+ root: ROOTS.LEADERBOARD,
+ },
}
diff --git a/src/utils/axios.ts b/src/utils/axios.ts
index 6954bf5..5677f29 100644
--- a/src/utils/axios.ts
+++ b/src/utils/axios.ts
@@ -40,6 +40,8 @@ export const endpoints = {
outflows: '/api/outflows',
bridgeMetrics: '/api/bridge-metrics', // Add this line
fees: '/api/fees',
+ leaderboard: '/api/leaderboard',
+ leaderboardSparklines: '/api/leaderboard-sparklines',
volume: {
daily: '/api/volume/daily',
hourly: '/api/volume/hourly',