From 278a514fba5c96887bb3fbdf0c4648b0f0a2d522 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 25 Jan 2026 17:06:47 +0800 Subject: [PATCH 1/8] feat: stats v2 --- app/admin/stats-v2/page.tsx | 141 +++++++ app/api/admin/auth/route.ts | 86 +++++ app/api/admin/monarch-indexer/route.ts | 56 +++ src/constants/chartColors.ts | 5 + src/data-sources/monarch-indexer/fetchers.ts | 44 +++ src/data-sources/monarch-indexer/index.ts | 17 + .../monarch-indexer/transactions.ts | 146 +++++++ .../components/chain-volume-chart.tsx | 239 ++++++++++++ .../admin-v2/components/password-gate.tsx | 84 ++++ .../components/stats-overview-cards.tsx | 71 ++++ .../components/stats-transactions-table.tsx | 365 ++++++++++++++++++ .../components/stats-volume-chart.tsx | 165 ++++++++ src/features/admin-v2/index.ts | 14 + src/hooks/useMonarchTransactions.ts | 347 +++++++++++++++++ src/stores/useAdminAuth.ts | 96 +++++ 15 files changed, 1876 insertions(+) create mode 100644 app/admin/stats-v2/page.tsx create mode 100644 app/api/admin/auth/route.ts create mode 100644 app/api/admin/monarch-indexer/route.ts create mode 100644 src/data-sources/monarch-indexer/fetchers.ts create mode 100644 src/data-sources/monarch-indexer/index.ts create mode 100644 src/data-sources/monarch-indexer/transactions.ts create mode 100644 src/features/admin-v2/components/chain-volume-chart.tsx create mode 100644 src/features/admin-v2/components/password-gate.tsx create mode 100644 src/features/admin-v2/components/stats-overview-cards.tsx create mode 100644 src/features/admin-v2/components/stats-transactions-table.tsx create mode 100644 src/features/admin-v2/components/stats-volume-chart.tsx create mode 100644 src/features/admin-v2/index.ts create mode 100644 src/hooks/useMonarchTransactions.ts create mode 100644 src/stores/useAdminAuth.ts diff --git a/app/admin/stats-v2/page.tsx b/app/admin/stats-v2/page.tsx new file mode 100644 index 00000000..46c07ca0 --- /dev/null +++ b/app/admin/stats-v2/page.tsx @@ -0,0 +1,141 @@ +'use client'; + +/** + * Stats V2 Dashboard (Experimental) + * + * This page uses a new cross-chain indexer API that provides Monarch transaction + * data across all chains with a single API call. + * + * NOTE: This API is experimental and may be reverted due to cost concerns. + * The old stats page at /admin/stats should be kept as a fallback. + * + * Features: + * - Cross-chain volume aggregation + * - Volume breakdown by chain + * - Supply and withdraw transaction tables + * - ETH/BTC price estimation for USD values + */ + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import ButtonGroup from '@/components/ui/button-group'; +import { Spinner } from '@/components/ui/spinner'; +import { PasswordGate } from '@/features/admin-v2/components/password-gate'; +import { StatsOverviewCards } from '@/features/admin-v2/components/stats-overview-cards'; +import { StatsVolumeChart } from '@/features/admin-v2/components/stats-volume-chart'; +import { ChainVolumeChart } from '@/features/admin-v2/components/chain-volume-chart'; +import { StatsTransactionsTable } from '@/features/admin-v2/components/stats-transactions-table'; +import { useMonarchTransactions, type TimeFrame } from '@/hooks/useMonarchTransactions'; +import { useAdminAuth } from '@/stores/useAdminAuth'; + +function StatsV2Content() { + const [timeframe, setTimeframe] = useState('30D'); + const { logout } = useAdminAuth(); + + const { + transactions, + supplies, + withdraws, + chainStats, + dailyVolumes, + totalSupplyVolumeUsd, + totalWithdrawVolumeUsd, + totalVolumeUsd, + isLoading, + error, + } = useMonarchTransactions(timeframe); + + const timeframeOptions = [ + { key: '1D', label: '1D', value: '1D' }, + { key: '7D', label: '7D', value: '7D' }, + { key: '30D', label: '30D', value: '30D' }, + { key: '90D', label: '90D', value: '90D' }, + { key: 'ALL', label: 'ALL', value: 'ALL' }, + ]; + + if (error) { + return ( +
+
+

Error Loading Data

+

{error.message}

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

Stats V2

+ Experimental +
+

+ Cross-chain Monarch transaction analytics. This API may be reverted due to cost concerns. +

+
+
+ setTimeframe(value as TimeFrame)} + size="sm" + variant="default" + /> + +
+
+ + {isLoading ? ( +
+ +
+ ) : ( +
+ {/* Overview Cards */} + + + {/* Charts Grid */} +
+ {/* Aggregated Volume Chart */} + + + {/* Chain Breakdown Chart */} + +
+ + {/* Transactions Table */} + +
+ )} +
+ ); +} + +export default function StatsV2Page() { + return ( + + + + ); +} diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts new file mode 100644 index 00000000..018ccdab --- /dev/null +++ b/app/api/admin/auth/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +/** + * Admin authentication endpoint + * + * POST: Login - validates password, sets httpOnly cookie + * DELETE: Logout - clears the cookie + * + * Environment variable (server-side only): + * - ADMIN_V2_PASSWORD_HASH: Expected password hash + * + * To generate a hash, run in Node: + * node -e "let h=0;for(const c of 'your-password'){h=(h<<5)-h+c.charCodeAt(0);h=h&h;}console.log(h.toString(16))" + */ + +const EXPECTED_HASH = process.env.ADMIN_V2_PASSWORD_HASH; +const COOKIE_NAME = 'monarch_admin_session'; + +function hashPassword(password: string): string { + let hash = 0; + for (const char of password) { + const charCode = char.charCodeAt(0); + hash = (hash << 5) - hash + charCode; + hash &= hash; + } + return hash.toString(16); +} + +export async function POST(request: NextRequest) { + if (!EXPECTED_HASH) { + return NextResponse.json( + { error: 'Authentication not configured' }, + { status: 500 }, + ); + } + + try { + const { password } = await request.json(); + + if (!password || typeof password !== 'string') { + return NextResponse.json({ error: 'Password required' }, { status: 400 }); + } + + const hash = hashPassword(password); + + console.log('real hash', hash) + + if (hash !== EXPECTED_HASH) { + await new Promise((resolve) => setTimeout(resolve, 200)); + return NextResponse.json({ error: 'Invalid password' }, { status: 401 }); + } + + // Set httpOnly cookie - client can't read this via JS + const cookieStore = await cookies(); + cookieStore.set(COOKIE_NAME, hash, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/api/admin', + maxAge: 60 * 60 * 24 * 7, // 7 days + }); + + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } +} + +export async function DELETE() { + const cookieStore = await cookies(); + cookieStore.delete(COOKIE_NAME); + return NextResponse.json({ success: true }); +} + +export async function GET() { + // Check if user is authenticated + const cookieStore = await cookies(); + const session = cookieStore.get(COOKIE_NAME); + + if (!session?.value || session.value !== EXPECTED_HASH) { + return NextResponse.json({ authenticated: false }); + } + + return NextResponse.json({ authenticated: true }); +} diff --git a/app/api/admin/monarch-indexer/route.ts b/app/api/admin/monarch-indexer/route.ts new file mode 100644 index 00000000..43e0d877 --- /dev/null +++ b/app/api/admin/monarch-indexer/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +/** + * Proxy API route for Monarch Indexer + * + * Validates auth via httpOnly cookie, then proxies to the actual indexer. + * The real endpoint URL is never exposed to the client. + * + * Environment variables (server-side only): + * - MONARCH_INDEXER_ENDPOINT: The actual GraphQL endpoint URL + * - ADMIN_V2_PASSWORD_HASH: Expected password hash (for cookie validation) + */ + +const INDEXER_ENDPOINT = process.env.MONARCH_INDEXER_ENDPOINT; +const EXPECTED_HASH = process.env.ADMIN_V2_PASSWORD_HASH; +const COOKIE_NAME = 'monarch_admin_session'; + +export async function POST(request: NextRequest) { + // Validate auth cookie + const cookieStore = await cookies(); + const session = cookieStore.get(COOKIE_NAME); + + if (!EXPECTED_HASH || !session?.value || session.value !== EXPECTED_HASH) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!INDEXER_ENDPOINT) { + return NextResponse.json({ error: 'Indexer endpoint not configured' }, { status: 500 }); + } + + try { + const body = await request.json(); + + const response = await fetch(INDEXER_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + return NextResponse.json( + { error: `Indexer request failed: ${response.status}` }, + { status: response.status }, + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Monarch indexer proxy error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/constants/chartColors.ts b/src/constants/chartColors.ts index 6afbe5f3..a3385e14 100644 --- a/src/constants/chartColors.ts +++ b/src/constants/chartColors.ts @@ -14,6 +14,7 @@ type ChartColorConfig = { type ChartPaletteConfig = { supply: ChartColorConfig; borrow: ChartColorConfig; + withdraw: ChartColorConfig; apyAtTarget: ChartColorConfig; risk: ChartColorConfig; pie: readonly string[]; @@ -33,6 +34,7 @@ export const CHART_PALETTES: Record = { classic: { supply: createColorConfig('#4E79A7'), // Blue borrow: createColorConfig('#59A14F'), // Green + withdraw: createColorConfig('#F28E2B'), // Orange apyAtTarget: createColorConfig('#EDC948'), // Yellow risk: createColorConfig('#E15759'), // Red pie: [ @@ -53,6 +55,7 @@ export const CHART_PALETTES: Record = { earth: { supply: createColorConfig('#B26333'), // Burnt sienna borrow: createColorConfig('#89392D'), // Rust/terracotta + withdraw: createColorConfig('#CD853F'), // Peru apyAtTarget: createColorConfig('#A48A7A'), // Taupe risk: createColorConfig('#411E1D'), // Dark maroon pie: [ @@ -73,6 +76,7 @@ export const CHART_PALETTES: Record = { forest: { supply: createColorConfig('#223A30'), // Dark forest green borrow: createColorConfig('#8DA99D'), // Sage green + withdraw: createColorConfig('#6B8E23'), // Olive drab apyAtTarget: createColorConfig('#727472'), // Medium gray risk: createColorConfig('#7A7C7B'), // Gray pie: [ @@ -93,6 +97,7 @@ export const CHART_PALETTES: Record = { simple: { supply: createColorConfig('#2563EB'), // Blue-600 borrow: createColorConfig('#16A34A'), // Green-600 + withdraw: createColorConfig('#F97316'), // Orange-500 apyAtTarget: createColorConfig('#EAB308'), // Yellow-500 risk: createColorConfig('#DC2626'), // Red-600 pie: [ diff --git a/src/data-sources/monarch-indexer/fetchers.ts b/src/data-sources/monarch-indexer/fetchers.ts new file mode 100644 index 00000000..6f82e72e --- /dev/null +++ b/src/data-sources/monarch-indexer/fetchers.ts @@ -0,0 +1,44 @@ +/** + * Monarch Indexer GraphQL Fetcher + * + * Calls our internal API route which proxies to the actual indexer. + * Auth is handled via httpOnly cookie (set during login). + * + * NOTE: This API is experimental and may be reverted due to cost concerns. + * The old stats page at /admin/stats should be kept as a fallback. + */ + +type GraphQLVariables = Record; + +/** + * Fetches data from the monarch indexer via our API proxy. + * Requires valid session cookie (set via /api/admin/auth). + */ +export async function monarchIndexerFetcher(query: string, variables?: GraphQLVariables): Promise { + const response = await fetch('/api/admin/monarch-indexer', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', // Include cookies + body: JSON.stringify({ + query, + variables, + }), + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Unauthorized - please log in again'); + } + throw new Error(`Monarch Indexer request failed: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + + if (result.errors) { + throw new Error(`Monarch Indexer GraphQL error: ${JSON.stringify(result.errors)}`); + } + + return result as T; +} diff --git a/src/data-sources/monarch-indexer/index.ts b/src/data-sources/monarch-indexer/index.ts new file mode 100644 index 00000000..64ab5bc2 --- /dev/null +++ b/src/data-sources/monarch-indexer/index.ts @@ -0,0 +1,17 @@ +/** + * Monarch Indexer Data Source + * + * A new cross-chain indexer API for fetching Monarch transactions + * across all chains with a single API call. + * + * NOTE: This API is experimental and may be reverted due to cost concerns. + * The old stats page at /admin/stats should be kept as a fallback. + */ + +export { monarchIndexerFetcher } from './fetchers'; +export { + fetchMonarchTransactions, + type MonarchSupplyTransaction, + type MonarchWithdrawTransaction, + type TimeRange, +} from './transactions'; diff --git a/src/data-sources/monarch-indexer/transactions.ts b/src/data-sources/monarch-indexer/transactions.ts new file mode 100644 index 00000000..fac226a4 --- /dev/null +++ b/src/data-sources/monarch-indexer/transactions.ts @@ -0,0 +1,146 @@ +/** + * Monarch Indexer Transactions + * + * Fetches Monarch supply/withdraw transactions across all chains. + * Auth is handled via httpOnly cookie. + * Supports pagination to fetch all transactions beyond the 1000 limit. + */ + +import { monarchIndexerFetcher } from './fetchers'; + +export type MonarchSupplyTransaction = { + txHash: string; + timestamp: number; + market_id: string; + assets: string; + chainId: number; +}; + +export type MonarchWithdrawTransaction = { + txHash: string; + timestamp: number; + market_id: string; + assets: string; + chainId: number; +}; + +type MonarchTransactionsResponse = { + data: { + Morpho_Supply?: MonarchSupplyTransaction[]; + Morpho_Withdraw?: MonarchWithdrawTransaction[]; + }; +}; + +export type TimeRange = { + startTimestamp: number; + endTimestamp?: number; +}; + +/** + * Fetches a single page of transactions + */ +async function fetchTransactionsPage( + timeRange: TimeRange, + limit: number, + offset: number, +): Promise { + const hasEndTimestamp = timeRange.endTimestamp !== undefined; + + const query = hasEndTimestamp + ? ` + query MonarchTxs($startTimestamp: numeric!, $endTimestamp: numeric!, $limit: Int!, $offset: Int!) { + Morpho_Supply( + where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp, _lte: $endTimestamp}}, + limit: $limit, + offset: $offset, + order_by: {timestamp: desc} + ) { + txHash + timestamp + market_id + assets + chainId + } + Morpho_Withdraw( + where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp, _lte: $endTimestamp}}, + limit: $limit, + offset: $offset, + order_by: {timestamp: desc} + ) { + txHash + timestamp + market_id + assets + chainId + } + } + ` + : ` + query MonarchTxs($startTimestamp: numeric!, $limit: Int!, $offset: Int!) { + Morpho_Supply( + where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp}}, + limit: $limit, + offset: $offset, + order_by: {timestamp: desc} + ) { + txHash + timestamp + market_id + assets + chainId + } + Morpho_Withdraw( + where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp}}, + limit: $limit, + offset: $offset, + order_by: {timestamp: desc} + ) { + txHash + timestamp + market_id + assets + chainId + } + } + `; + + const variables = hasEndTimestamp + ? { startTimestamp: timeRange.startTimestamp, endTimestamp: timeRange.endTimestamp, limit, offset } + : { startTimestamp: timeRange.startTimestamp, limit, offset }; + + return monarchIndexerFetcher(query, variables); +} + +/** + * Fetches all supply and withdraw transactions with automatic pagination. + * Will continue fetching until we get less than `limit` results. + */ +export async function fetchMonarchTransactions( + timeRange: TimeRange, + limit = 1000, +): Promise<{ + supplies: MonarchSupplyTransaction[]; + withdraws: MonarchWithdrawTransaction[]; +}> { + const allSupplies: MonarchSupplyTransaction[] = []; + const allWithdraws: MonarchWithdrawTransaction[] = []; + let offset = 0; + + while (true) { + const response = await fetchTransactionsPage(timeRange, limit, offset); + const supplies = response.data.Morpho_Supply ?? []; + const withdraws = response.data.Morpho_Withdraw ?? []; + + allSupplies.push(...supplies); + allWithdraws.push(...withdraws); + + // If both returned less than limit, we've fetched everything + if (supplies.length < limit && withdraws.length < limit) { + break; + } + + offset += limit; + } + + return { supplies: allSupplies, withdraws: allWithdraws }; +} diff --git a/src/features/admin-v2/components/chain-volume-chart.tsx b/src/features/admin-v2/components/chain-volume-chart.tsx new file mode 100644 index 00000000..efd29542 --- /dev/null +++ b/src/features/admin-v2/components/chain-volume-chart.tsx @@ -0,0 +1,239 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import Image from 'next/image'; +import { Card } from '@/components/ui/card'; +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import { Spinner } from '@/components/ui/spinner'; +import { useChartColors, CHART_PALETTES } from '@/constants/chartColors'; +import { formatReadable } from '@/utils/balance'; +import { getNetworkImg, getNetworkName, type SupportedNetworks } from '@/utils/networks'; +import { ChartGradients, chartTooltipCursor, chartLegendStyle } from '@/features/market-detail/components/charts/chart-utils'; +import type { DailyVolume, ChainStats } from '@/hooks/useMonarchTransactions'; + +type ChainVolumeChartProps = { + dailyVolumes: DailyVolume[]; + chainStats: ChainStats[]; + isLoading: boolean; +}; + +// Chain color mapping using pie colors +const CHAIN_COLORS: Record = { + 1: CHART_PALETTES.classic.pie[0], // Mainnet - Blue + 8453: CHART_PALETTES.classic.pie[1], // Base - Green + 137: CHART_PALETTES.classic.pie[3], // Polygon - Purple + 130: CHART_PALETTES.classic.pie[4], // Unichain - Teal + 42161: CHART_PALETTES.classic.pie[6], // Arbitrum - Orange + 999: CHART_PALETTES.classic.pie[5], // HyperEVM - Pink + 143: CHART_PALETTES.classic.pie[2], // Monad - Yellow +}; + +function getChainColor(chainId: number): string { + return CHAIN_COLORS[chainId] ?? CHART_PALETTES.classic.pie[8]; +} + +export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainVolumeChartProps) { + const chartColors = useChartColors(); + + // Get unique chain IDs from stats + const chainIds = useMemo(() => chainStats.map((s) => s.chainId), [chainStats]); + + const [visibleChains, setVisibleChains] = useState>(() => { + const initial: Record = {}; + for (const chainId of chainIds) { + initial[chainId] = true; + } + return initial; + }); + + // Update visible chains when chainIds change + useMemo(() => { + setVisibleChains((prev) => { + const updated = { ...prev }; + for (const chainId of chainIds) { + if (updated[chainId] === undefined) { + updated[chainId] = true; + } + } + return updated; + }); + }, [chainIds]); + + const chartData = useMemo(() => { + return dailyVolumes.map((v) => { + const dataPoint: Record = { x: v.timestamp }; + for (const chainId of chainIds) { + const chainData = v.byChain[chainId]; + dataPoint[`chain_${chainId}`] = chainData ? chainData.supplyVolumeUsd + chainData.withdrawVolumeUsd : 0; + } + return dataPoint; + }); + }, [dailyVolumes, chainIds]); + + // Filter chart data to only include visible chains (enables Y-axis auto-rescale) + const filteredChartData = useMemo(() => { + return chartData.map((dataPoint) => { + const filtered: Record = { x: dataPoint.x }; + for (const chainId of chainIds) { + if (visibleChains[chainId]) { + filtered[`chain_${chainId}`] = dataPoint[`chain_${chainId}`] ?? 0; + } + } + return filtered; + }); + }, [chartData, chainIds, visibleChains]); + + // Get only visible chain IDs for rendering + const visibleChainIds = useMemo(() => { + return chainIds.filter((chainId) => visibleChains[chainId]); + }, [chainIds, visibleChains]); + + const gradients = useMemo(() => { + return chainIds.map((chainId) => ({ + id: `chain_${chainId}Gradient`, + color: getChainColor(chainId), + })); + }, [chainIds]); + + const formatYAxis = (value: number) => `$${formatReadable(value)}`; + + const handleLegendClick = (e: { dataKey?: string }) => { + if (!e.dataKey) return; + const chainId = Number(e.dataKey.replace('chain_', '')); + setVisibleChains((prev) => ({ + ...prev, + [chainId]: !prev[chainId], + })); + }; + + const legendFormatter = (value: string, entry: { dataKey?: string }) => { + if (!entry.dataKey) return value; + const chainId = Number(entry.dataKey.replace('chain_', '')); + const isVisible = visibleChains[chainId] ?? true; + return ( + + {value} + + ); + }; + + return ( + + {/* Header: Chain Stats */} +
+

Volume by Chain

+
+ {chainStats.map((stat) => { + const networkImg = getNetworkImg(stat.chainId); + const networkName = getNetworkName(stat.chainId) ?? `Chain ${stat.chainId}`; + return ( +
+ {networkImg && ( + {networkName} + )} +
+

{networkName}

+

+ ${formatReadable(stat.totalVolumeUsd)} +

+
+
+ ); + })} +
+
+ + {/* Chart Body */} +
+ {isLoading ? ( +
+ +
+ ) : filteredChartData.length === 0 ? ( +
No data available
+ ) : ( + + + + + + new Date(time * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + } + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + + { + if (!active || !payload) return null; + return ( +
+

+ {new Date((label ?? 0) * 1000).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} +

+
+ {payload.map((entry: { dataKey?: string; value?: number; color?: string }) => { + if (!entry.dataKey) return null; + const chainId = Number(entry.dataKey.replace('chain_', '')); + const networkName = getNetworkName(chainId) ?? `Chain ${chainId}`; + return ( +
+
+ + {networkName} +
+ ${formatReadable(entry.value ?? 0)} +
+ ); + })} +
+
+ ); + }} + /> + + {visibleChainIds.map((chainId) => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/src/features/admin-v2/components/password-gate.tsx b/src/features/admin-v2/components/password-gate.tsx new file mode 100644 index 00000000..f0b1ee8e --- /dev/null +++ b/src/features/admin-v2/components/password-gate.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useState, useEffect, type FormEvent } from 'react'; +import { Card, CardBody } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; +import { useAdminAuth } from '@/stores/useAdminAuth'; + +type PasswordGateProps = { + children: React.ReactNode; +}; + +export function PasswordGate({ children }: PasswordGateProps) { + const { isAuthenticated, isLoading, isCheckingAuth, error, authenticate, checkAuth } = useAdminAuth(); + const [password, setPassword] = useState(''); + + // Check auth status on mount (validates existing cookie) + useEffect(() => { + void checkAuth(); + }, [checkAuth]); + + // Show spinner while checking existing auth + if (isCheckingAuth) { + return ( +
+ +
+ ); + } + + if (isAuthenticated) { + return <>{children}; + } + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const success = await authenticate(password); + if (!success) { + setPassword(''); + } + }; + + return ( +
+ + +
+

Stats V2 (Experimental)

+

+ This page uses an experimental API that may be reverted due to cost concerns. +

+
+ +
+ + + +
+ +

+ Contact the team if you need access credentials. +

+
+
+
+ ); +} diff --git a/src/features/admin-v2/components/stats-overview-cards.tsx b/src/features/admin-v2/components/stats-overview-cards.tsx new file mode 100644 index 00000000..d75654d3 --- /dev/null +++ b/src/features/admin-v2/components/stats-overview-cards.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { Card, CardBody } from '@/components/ui/card'; +import { formatReadable } from '@/utils/balance'; +import type { ChainStats } from '@/hooks/useMonarchTransactions'; + +type StatsOverviewCardsProps = { + totalSupplyVolumeUsd: number; + totalWithdrawVolumeUsd: number; + totalVolumeUsd: number; + supplyCount: number; + withdrawCount: number; + chainStats: ChainStats[]; +}; + +type StatCardProps = { + title: string; + value: string; + subtitle?: string; +}; + +function StatCard({ title, value, subtitle }: StatCardProps) { + return ( + + +

{title}

+
+

{value}

+ {subtitle &&

{subtitle}

} +
+
+
+ ); +} + +export function StatsOverviewCards({ + totalSupplyVolumeUsd, + totalWithdrawVolumeUsd, + totalVolumeUsd, + supplyCount, + withdrawCount, + chainStats, +}: StatsOverviewCardsProps) { + const totalTransactions = supplyCount + withdrawCount; + const activeChains = chainStats.length; + + return ( +
+ + + + +
+ ); +} diff --git a/src/features/admin-v2/components/stats-transactions-table.tsx b/src/features/admin-v2/components/stats-transactions-table.tsx new file mode 100644 index 00000000..e6f7f31d --- /dev/null +++ b/src/features/admin-v2/components/stats-transactions-table.tsx @@ -0,0 +1,365 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import moment from 'moment'; +import { ChevronUpIcon, ChevronDownIcon } from '@radix-ui/react-icons'; +import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from '@/components/ui/table'; +import { TablePagination } from '@/components/shared/table-pagination'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { TransactionIdentity } from '@/components/shared/transaction-identity'; +import { MarketIdBadge } from '@/features/markets/components/market-id-badge'; +import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuCheckboxItem, +} from '@/components/ui/dropdown-menu'; +import { formatReadable } from '@/utils/balance'; +import { getNetworkImg, getNetworkName, type SupportedNetworks } from '@/utils/networks'; +import { getTruncatedAssetName } from '@/utils/oracle'; +import type { EnrichedTransaction } from '@/hooks/useMonarchTransactions'; + +type StatsTransactionsTableProps = { + transactions: EnrichedTransaction[]; + isLoading: boolean; +}; + +type SortKey = 'timestamp' | 'usdValue'; +type SortDirection = 'asc' | 'desc'; + +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 StatsTransactionsTable({ transactions, isLoading }: StatsTransactionsTableProps) { + const [sortKey, setSortKey] = useState('timestamp'); + const [sortDirection, setSortDirection] = useState('desc'); + const [currentPage, setCurrentPage] = useState(1); + const [selectedChains, setSelectedChains] = useState([]); + const [selectedTypes, setSelectedTypes] = useState<('supply' | 'withdraw')[]>([]); + const entriesPerPage = 15; + + // Get unique chain IDs from transactions + const uniqueChainIds = useMemo(() => { + const chains = new Set(transactions.map((tx) => tx.chainId)); + return Array.from(chains).sort((a, b) => a - b); + }, [transactions]); + + const handleSort = (key: SortKey) => { + if (key === sortKey) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortDirection('desc'); + } + }; + + // Filter transactions by selected chains and types + const filteredData = useMemo(() => { + let filtered = transactions; + + if (selectedChains.length > 0) { + filtered = filtered.filter((tx) => selectedChains.includes(tx.chainId)); + } + + if (selectedTypes.length > 0) { + filtered = filtered.filter((tx) => selectedTypes.includes(tx.type)); + } + + return filtered; + }, [transactions, selectedChains, selectedTypes]); + + // Sort filtered data + const sortedData = useMemo(() => { + return [...filteredData].sort((a, b) => { + const valueA = sortKey === 'timestamp' ? a.timestamp : a.usdValue; + const valueB = sortKey === 'timestamp' ? b.timestamp : b.usdValue; + + if (sortDirection === 'asc') { + return valueA < valueB ? -1 : valueA > valueB ? 1 : 0; + } + return valueA > valueB ? -1 : valueA < valueB ? 1 : 0; + }); + }, [filteredData, sortKey, sortDirection]); + + const indexOfLastEntry = currentPage * entriesPerPage; + const indexOfFirstEntry = indexOfLastEntry - entriesPerPage; + const currentEntries = sortedData.slice(indexOfFirstEntry, indexOfLastEntry); + const totalPages = Math.ceil(sortedData.length / entriesPerPage); + + // Reset to page 1 when filters change + const handleChainToggle = (chainId: number, checked: boolean) => { + if (checked) { + setSelectedChains([...selectedChains, chainId]); + } else { + setSelectedChains(selectedChains.filter((c) => c !== chainId)); + } + setCurrentPage(1); + }; + + const handleTypeToggle = (type: 'supply' | 'withdraw', checked: boolean) => { + if (checked) { + setSelectedTypes([...selectedTypes, type]); + } else { + setSelectedTypes(selectedTypes.filter((t) => t !== type)); + } + setCurrentPage(1); + }; + + return ( +
+
+
+
+

Recent Transactions

+

+ {filteredData.length} transaction{filteredData.length !== 1 ? 's' : ''} + {filteredData.length !== transactions.length && ` (filtered from ${transactions.length})`} +

+
+
+ {/* Chain Filter */} + + + + + + {uniqueChainIds.map((chainId) => { + const networkImg = getNetworkImg(chainId); + const networkName = getNetworkName(chainId) ?? `Chain ${chainId}`; + return ( + handleChainToggle(chainId, !!checked)} + startContent={ + networkImg ? ( + {networkName} + ) : undefined + } + > + {networkName} + + ); + })} + + + + {/* Type Filter */} + + + + + + handleTypeToggle('supply', !!checked)} + > + Supply + + handleTypeToggle('withdraw', !!checked)} + > + Withdraw + + + +
+
+
+
+ {sortedData.length === 0 ? ( +
+ {isLoading ? 'Loading transactions...' : 'No transaction data available'} +
+ ) : ( + <> + + + + Chain + Type + Asset + Market + + Tx Hash + + + + + {currentEntries.map((tx, idx) => { + const networkImg = getNetworkImg(tx.chainId); + const networkName = getNetworkName(tx.chainId) ?? `Chain ${tx.chainId}`; + const marketPath = tx.market ? `/market/${tx.chainId}/${tx.market.uniqueKey}` : null; + + return ( + + {/* Chain */} + +
+ {networkImg && ( + + )} + {networkName} +
+
+ + {/* Type */} + + + {tx.type === 'supply' ? 'Supply' : 'Withdraw'} + + + + {/* Asset */} + +
+ {tx.market && ( + + )} + + {getTruncatedAssetName(tx.loanSymbol ?? 'Unknown')} + +
+
+ + {/* Market */} + + {tx.market && marketPath ? ( + +
+ + +
+ + ) : ( + + )} +
+ + {/* Amount (USD) */} + + + ${formatReadable(tx.usdValue)} + + ({formatReadable(tx.assetsFormatted)} {tx.loanSymbol}) + + + + + {/* Tx Hash */} + + + + + {/* Time */} + + + {moment.unix(tx.timestamp).fromNow()} + + +
+ ); + })} +
+
+
+ +
+ + )} +
+
+ ); +} diff --git a/src/features/admin-v2/components/stats-volume-chart.tsx b/src/features/admin-v2/components/stats-volume-chart.tsx new file mode 100644 index 00000000..971f7df9 --- /dev/null +++ b/src/features/admin-v2/components/stats-volume-chart.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Card } from '@/components/ui/card'; +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import { Spinner } from '@/components/ui/spinner'; +import { useChartColors } from '@/constants/chartColors'; +import { formatReadable } from '@/utils/balance'; +import { + ChartGradients, + ChartTooltipContent, + createLegendClickHandler, + chartTooltipCursor, + chartLegendStyle, +} from '@/features/market-detail/components/charts/chart-utils'; +import type { DailyVolume } from '@/hooks/useMonarchTransactions'; + +type StatsVolumeChartProps = { + dailyVolumes: DailyVolume[]; + totalSupplyVolumeUsd: number; + totalWithdrawVolumeUsd: number; + isLoading: boolean; +}; + +function createStatsVolumeGradients(colors: ReturnType) { + return [ + { id: 'supplyGradient', color: colors.supply.stroke }, + { id: 'withdrawGradient', color: colors.withdraw.stroke }, + ]; +} + +export function StatsVolumeChart({ + dailyVolumes, + totalSupplyVolumeUsd, + totalWithdrawVolumeUsd, + isLoading, +}: StatsVolumeChartProps) { + const chartColors = useChartColors(); + const [visibleLines, setVisibleLines] = useState({ + supply: true, + withdraw: true, + }); + + const chartData = useMemo(() => { + return dailyVolumes.map((v) => ({ + x: v.timestamp, + supply: v.supplyVolumeUsd, + withdraw: v.withdrawVolumeUsd, + })); + }, [dailyVolumes]); + + const formatYAxis = (value: number) => `$${formatReadable(value)}`; + const formatValue = (value: number) => `$${formatReadable(value)}`; + + const legendHandlers = createLegendClickHandler({ visibleLines, setVisibleLines }); + + const totalVolume = totalSupplyVolumeUsd + totalWithdrawVolumeUsd; + + return ( + + {/* Header: Live Stats */} +
+
+
+

Total Volume

+ ${formatReadable(totalVolume)} +
+
+

Supply Volume

+ + ${formatReadable(totalSupplyVolumeUsd)} + +
+
+

Withdraw Volume

+ + ${formatReadable(totalWithdrawVolumeUsd)} + +
+
+
+ + {/* Chart Body */} +
+ {isLoading ? ( +
+ +
+ ) : chartData.length === 0 ? ( +
No data available
+ ) : ( + + + + + + new Date(time * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + } + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + + ( + + )} + /> + + + + + + )} +
+ + {/* Footer: Summary */} +
+

Period Summary

+
+
+ Transactions + {dailyVolumes.reduce((sum, d) => sum + 1, 0)} days +
+
+ Avg Daily Volume + + ${formatReadable(dailyVolumes.length > 0 ? totalVolume / dailyVolumes.length : 0)} + +
+
+
+
+ ); +} diff --git a/src/features/admin-v2/index.ts b/src/features/admin-v2/index.ts new file mode 100644 index 00000000..0e496806 --- /dev/null +++ b/src/features/admin-v2/index.ts @@ -0,0 +1,14 @@ +/** + * Admin V2 Feature + * + * Experimental cross-chain stats dashboard that uses the monarch indexer API. + * + * NOTE: This feature is experimental and may be removed if the API costs + * prove too high. The original /admin/stats page should remain as a fallback. + */ + +export { PasswordGate } from './components/password-gate'; +export { StatsOverviewCards } from './components/stats-overview-cards'; +export { StatsVolumeChart } from './components/stats-volume-chart'; +export { ChainVolumeChart } from './components/chain-volume-chart'; +export { StatsTransactionsTable } from './components/stats-transactions-table'; diff --git a/src/hooks/useMonarchTransactions.ts b/src/hooks/useMonarchTransactions.ts new file mode 100644 index 00000000..5c2629cb --- /dev/null +++ b/src/hooks/useMonarchTransactions.ts @@ -0,0 +1,347 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { formatUnits } from 'viem'; +import { + fetchMonarchTransactions, + type MonarchSupplyTransaction, + type MonarchWithdrawTransaction, + type TimeRange, +} from '@/data-sources/monarch-indexer'; +import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; +import { useTokenPrices } from '@/hooks/useTokenPrices'; +import type { Market } from '@/utils/types'; + +export type TimeFrame = '1D' | '7D' | '30D' | '90D' | 'ALL'; + +const TIMEFRAME_TO_SECONDS: Record = { + '1D': 24 * 60 * 60, + '7D': 7 * 24 * 60 * 60, + '30D': 30 * 24 * 60 * 60, + '90D': 90 * 24 * 60 * 60, + ALL: 365 * 24 * 60 * 60, // 1 year as "ALL" +}; + +// Known ETH-pegged tokens (WETH variants) +const ETH_PEGGED_SYMBOLS = new Set(['WETH', 'ETH', 'wstETH', 'cbETH', 'rETH', 'stETH', 'STETH', 'weETH', 'ezETH']); + +// Known BTC-pegged tokens +const BTC_PEGGED_SYMBOLS = new Set(['WBTC', 'BTC', 'tBTC', 'cbBTC', 'sBTC', 'renBTC', 'BTCB']); + +export type EnrichedTransaction = { + txHash: string; + timestamp: number; + marketId: string; + assets: string; + assetsFormatted: number; + usdValue: number; + chainId: number; + type: 'supply' | 'withdraw'; + market?: Market; + loanSymbol?: string; +}; + +export type ChainStats = { + chainId: number; + supplyCount: number; + withdrawCount: number; + supplyVolumeUsd: number; + withdrawVolumeUsd: number; + totalVolumeUsd: number; +}; + +export type DailyVolume = { + date: string; + timestamp: number; + supplyVolumeUsd: number; + withdrawVolumeUsd: number; + totalVolumeUsd: number; + byChain: Record; +}; + +type UseMonarchTransactionsReturn = { + transactions: EnrichedTransaction[]; + supplies: EnrichedTransaction[]; + withdraws: EnrichedTransaction[]; + chainStats: ChainStats[]; + dailyVolumes: DailyVolume[]; + totalSupplyVolumeUsd: number; + totalWithdrawVolumeUsd: number; + totalVolumeUsd: number; + uniqueUsers: number; + isLoading: boolean; + error: Error | null; +}; + +export const useMonarchTransactions = (timeframe: TimeFrame): UseMonarchTransactionsReturn => { + const { allMarkets, loading: marketsLoading } = useProcessedMarkets(); + + // Calculate time range based on timeframe + const timeRange = useMemo((): TimeRange => { + const now = Math.floor(Date.now() / 1000); + return { + startTimestamp: now - TIMEFRAME_TO_SECONDS[timeframe], + endTimestamp: now, + }; + }, [timeframe]); + + // Fetch transactions (auth via httpOnly cookie) + const { + data: rawTransactions, + isLoading: txLoading, + error, + } = useQuery({ + queryKey: ['monarch-transactions', timeRange.startTimestamp, timeRange.endTimestamp], + queryFn: () => fetchMonarchTransactions(timeRange, 1000), + staleTime: 2 * 60 * 1000, // 2 minutes + refetchInterval: 5 * 60 * 1000, // 5 minutes + enabled: !marketsLoading, + }); + + // Create market lookup map + const marketMap = useMemo(() => { + const map = new Map(); + for (const market of allMarkets) { + if (market.uniqueKey) { + map.set(market.uniqueKey.toLowerCase(), market); + } + } + return map; + }, [allMarkets]); + + // Get unique loan tokens for price fetching + const uniqueTokens = useMemo(() => { + const tokens: Array<{ address: string; chainId: number }> = []; + const seen = new Set(); + + for (const market of allMarkets) { + const key = `${market.loanAsset.address.toLowerCase()}-${market.morphoBlue.chain.id}`; + if (!seen.has(key)) { + seen.add(key); + tokens.push({ + address: market.loanAsset.address, + chainId: market.morphoBlue.chain.id, + }); + } + } + + return tokens; + }, [allMarkets]); + + // Fetch token prices + const { prices: tokenPrices, isLoading: pricesLoading } = useTokenPrices(uniqueTokens); + + // Get ETH and BTC prices from fetched prices (find first match) + const ethPrice = useMemo(() => { + for (const [key, price] of tokenPrices) { + const address = key.split('-')[0]; + const market = allMarkets.find( + (m) => m.loanAsset.address.toLowerCase() === address && ETH_PEGGED_SYMBOLS.has(m.loanAsset.symbol), + ); + if (market) return price; + } + return 0; + }, [tokenPrices, allMarkets]); + + const btcPrice = useMemo(() => { + for (const [key, price] of tokenPrices) { + const address = key.split('-')[0]; + const market = allMarkets.find( + (m) => m.loanAsset.address.toLowerCase() === address && BTC_PEGGED_SYMBOLS.has(m.loanAsset.symbol), + ); + if (market) return price; + } + return 0; + }, [tokenPrices, allMarkets]); + + // Enrich transactions with market data and USD values + const enrichedData = useMemo(() => { + if (!rawTransactions) { + return { + supplies: [] as EnrichedTransaction[], + withdraws: [] as EnrichedTransaction[], + transactions: [] as EnrichedTransaction[], + }; + } + + const getUsdValue = (assets: string, market: Market | undefined): number => { + if (!market) return 0; + + const decimals = market.loanAsset.decimals; + const formatted = Number(formatUnits(BigInt(assets), decimals)); + const symbol = market.loanAsset.symbol; + + // Check if it's an ETH-pegged token + if (ETH_PEGGED_SYMBOLS.has(symbol)) { + return formatted * ethPrice; + } + + // Check if it's a BTC-pegged token + if (BTC_PEGGED_SYMBOLS.has(symbol)) { + return formatted * btcPrice; + } + + // Try to get price from token prices map + const priceKey = `${market.loanAsset.address.toLowerCase()}-${market.morphoBlue.chain.id}`; + const price = tokenPrices.get(priceKey); + if (price) { + return formatted * price; + } + + // Assume stablecoins are $1 + if (symbol.includes('USD') || symbol.includes('DAI') || symbol.includes('USDT') || symbol.includes('USDC')) { + return formatted; + } + + return 0; + }; + + const enrichTx = ( + tx: MonarchSupplyTransaction | MonarchWithdrawTransaction, + type: 'supply' | 'withdraw', + ): EnrichedTransaction => { + const market = marketMap.get(tx.market_id.toLowerCase()); + const decimals = market?.loanAsset.decimals ?? 18; + const formatted = Number(formatUnits(BigInt(tx.assets), decimals)); + + return { + txHash: tx.txHash, + timestamp: tx.timestamp, + marketId: tx.market_id, + assets: tx.assets, + assetsFormatted: formatted, + usdValue: getUsdValue(tx.assets, market), + chainId: tx.chainId, + type, + market, + loanSymbol: market?.loanAsset.symbol, + }; + }; + + const supplies = rawTransactions.supplies.map((tx) => enrichTx(tx, 'supply')); + const withdraws = rawTransactions.withdraws.map((tx) => enrichTx(tx, 'withdraw')); + const transactions = [...supplies, ...withdraws].sort((a, b) => b.timestamp - a.timestamp); + + return { supplies, withdraws, transactions }; + }, [rawTransactions, marketMap, tokenPrices, ethPrice, btcPrice]); + + // Calculate chain stats + const chainStats = useMemo(() => { + const statsMap = new Map(); + + for (const tx of enrichedData.supplies) { + const stats = statsMap.get(tx.chainId) ?? { + chainId: tx.chainId, + supplyCount: 0, + withdrawCount: 0, + supplyVolumeUsd: 0, + withdrawVolumeUsd: 0, + totalVolumeUsd: 0, + }; + stats.supplyCount++; + stats.supplyVolumeUsd += tx.usdValue; + stats.totalVolumeUsd += tx.usdValue; + statsMap.set(tx.chainId, stats); + } + + for (const tx of enrichedData.withdraws) { + const stats = statsMap.get(tx.chainId) ?? { + chainId: tx.chainId, + supplyCount: 0, + withdrawCount: 0, + supplyVolumeUsd: 0, + withdrawVolumeUsd: 0, + totalVolumeUsd: 0, + }; + stats.withdrawCount++; + stats.withdrawVolumeUsd += tx.usdValue; + stats.totalVolumeUsd += tx.usdValue; + statsMap.set(tx.chainId, stats); + } + + return Array.from(statsMap.values()).sort((a, b) => b.totalVolumeUsd - a.totalVolumeUsd); + }, [enrichedData]); + + // Calculate daily volumes + const dailyVolumes = useMemo(() => { + const volumeMap = new Map(); + + const addToVolume = (tx: EnrichedTransaction) => { + const date = new Date(tx.timestamp * 1000).toISOString().split('T')[0]; + const existing = volumeMap.get(date) ?? { + date, + timestamp: new Date(date).getTime() / 1000, + supplyVolumeUsd: 0, + withdrawVolumeUsd: 0, + totalVolumeUsd: 0, + byChain: {}, + }; + + if (tx.type === 'supply') { + existing.supplyVolumeUsd += tx.usdValue; + } else { + existing.withdrawVolumeUsd += tx.usdValue; + } + existing.totalVolumeUsd += tx.usdValue; + + // Track by chain + const chainData = existing.byChain[tx.chainId] ?? { supplyVolumeUsd: 0, withdrawVolumeUsd: 0 }; + if (tx.type === 'supply') { + chainData.supplyVolumeUsd += tx.usdValue; + } else { + chainData.withdrawVolumeUsd += tx.usdValue; + } + existing.byChain[tx.chainId] = chainData; + + volumeMap.set(date, existing); + }; + + for (const tx of enrichedData.transactions) { + addToVolume(tx); + } + + return Array.from(volumeMap.values()).sort((a, b) => a.timestamp - b.timestamp); + }, [enrichedData.transactions]); + + // Calculate totals + const totals = useMemo(() => { + let supplyVolumeUsd = 0; + let withdrawVolumeUsd = 0; + + for (const tx of enrichedData.supplies) { + supplyVolumeUsd += tx.usdValue; + } + + for (const tx of enrichedData.withdraws) { + withdrawVolumeUsd += tx.usdValue; + } + + return { + totalSupplyVolumeUsd: supplyVolumeUsd, + totalWithdrawVolumeUsd: withdrawVolumeUsd, + totalVolumeUsd: supplyVolumeUsd + withdrawVolumeUsd, + }; + }, [enrichedData]); + + // Calculate unique users (from tx hashes, approximation) + const uniqueUsers = useMemo(() => { + const users = new Set(); + for (const tx of enrichedData.transactions) { + users.add(tx.txHash); + } + return users.size; + }, [enrichedData.transactions]); + + const isLoading = marketsLoading || txLoading || pricesLoading; + + return { + transactions: enrichedData.transactions, + supplies: enrichedData.supplies, + withdraws: enrichedData.withdraws, + chainStats, + dailyVolumes, + ...totals, + uniqueUsers, + isLoading, + error: error as Error | null, + }; +}; diff --git a/src/stores/useAdminAuth.ts b/src/stores/useAdminAuth.ts new file mode 100644 index 00000000..87e80329 --- /dev/null +++ b/src/stores/useAdminAuth.ts @@ -0,0 +1,96 @@ +import { create } from 'zustand'; + +/** + * Admin authentication store for stats-v2 pages. + * + * Authentication flow: + * 1. User enters password on client + * 2. Password is sent to /api/admin/auth for server-side validation + * 3. Server validates against ADMIN_V2_PASSWORD_HASH env var + * 4. If valid, server sets httpOnly cookie (not accessible via JS) + * 5. Subsequent API requests include cookie automatically + * + * No sensitive data is stored client-side. + * Set ADMIN_V2_PASSWORD_HASH in your .env file. + * + * To generate a hash, run in Node: + * node -e "let h=0;for(const c of 'your-password'){h=(h<<5)-h+c.charCodeAt(0);h=h&h;}console.log(h.toString(16))" + */ + +type AdminAuthState = { + isAuthenticated: boolean; + isLoading: boolean; + isCheckingAuth: boolean; + error: string | null; +}; + +type AdminAuthActions = { + checkAuth: () => Promise; + authenticate: (password: string) => Promise; + logout: () => Promise; + clearError: () => void; +}; + +type AdminAuthStore = AdminAuthState & AdminAuthActions; + +export const useAdminAuth = create()((set) => ({ + isAuthenticated: false, + isLoading: false, + isCheckingAuth: true, + error: null, + + checkAuth: async () => { + set({ isCheckingAuth: true }); + try { + const response = await fetch('/api/admin/auth', { + method: 'GET', + credentials: 'same-origin', + }); + const data = await response.json(); + set({ isAuthenticated: data.authenticated, isCheckingAuth: false }); + } catch { + set({ isAuthenticated: false, isCheckingAuth: false }); + } + }, + + authenticate: async (password: string) => { + set({ isLoading: true, error: null }); + + try { + const response = await fetch('/api/admin/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ password }), + }); + + const data = await response.json(); + + if (!response.ok) { + set({ isLoading: false, error: data.error ?? 'Authentication failed' }); + return false; + } + + set({ isAuthenticated: true, isLoading: false, error: null }); + return true; + } catch { + set({ isLoading: false, error: 'Network error' }); + return false; + } + }, + + logout: async () => { + try { + await fetch('/api/admin/auth', { + method: 'DELETE', + credentials: 'same-origin', + }); + } finally { + set({ isAuthenticated: false, error: null }); + } + }, + + clearError: () => { + set({ error: null }); + }, +})); From 7ba73c3ee716e2fac55bb36e01beb74a6a4ed219 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 25 Jan 2026 18:17:13 +0800 Subject: [PATCH 2/8] chore: review --- app/api/admin/auth/route.ts | 9 +- .../monarch-indexer/transactions.ts | 18 ++- .../components/chain-volume-chart.tsx | 99 +++++++++------ .../components/stats-transactions-table.tsx | 120 ++++++++++++------ .../components/stats-volume-chart.tsx | 60 ++++++--- src/hooks/useMonarchTransactions.ts | 38 ++---- 6 files changed, 207 insertions(+), 137 deletions(-) diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 018ccdab..c4744993 100644 --- a/app/api/admin/auth/route.ts +++ b/app/api/admin/auth/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { type NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; /** @@ -29,10 +29,7 @@ function hashPassword(password: string): string { export async function POST(request: NextRequest) { if (!EXPECTED_HASH) { - return NextResponse.json( - { error: 'Authentication not configured' }, - { status: 500 }, - ); + return NextResponse.json({ error: 'Authentication not configured' }, { status: 500 }); } try { @@ -44,8 +41,6 @@ export async function POST(request: NextRequest) { const hash = hashPassword(password); - console.log('real hash', hash) - if (hash !== EXPECTED_HASH) { await new Promise((resolve) => setTimeout(resolve, 200)); return NextResponse.json({ error: 'Invalid password' }, { status: 401 }); diff --git a/src/data-sources/monarch-indexer/transactions.ts b/src/data-sources/monarch-indexer/transactions.ts index fac226a4..a1f5e637 100644 --- a/src/data-sources/monarch-indexer/transactions.ts +++ b/src/data-sources/monarch-indexer/transactions.ts @@ -14,6 +14,7 @@ export type MonarchSupplyTransaction = { market_id: string; assets: string; chainId: number; + onBehalf: string; }; export type MonarchWithdrawTransaction = { @@ -22,6 +23,7 @@ export type MonarchWithdrawTransaction = { market_id: string; assets: string; chainId: number; + onBehalf: string; }; type MonarchTransactionsResponse = { @@ -39,11 +41,7 @@ export type TimeRange = { /** * Fetches a single page of transactions */ -async function fetchTransactionsPage( - timeRange: TimeRange, - limit: number, - offset: number, -): Promise { +async function fetchTransactionsPage(timeRange: TimeRange, limit: number, offset: number): Promise { const hasEndTimestamp = timeRange.endTimestamp !== undefined; const query = hasEndTimestamp @@ -60,6 +58,7 @@ async function fetchTransactionsPage( market_id assets chainId + onBehalf } Morpho_Withdraw( where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp, _lte: $endTimestamp}}, @@ -72,6 +71,7 @@ async function fetchTransactionsPage( market_id assets chainId + onBehalf } } ` @@ -88,6 +88,7 @@ async function fetchTransactionsPage( market_id assets chainId + onBehalf } Morpho_Withdraw( where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp}}, @@ -100,6 +101,7 @@ async function fetchTransactionsPage( market_id assets chainId + onBehalf } } `; @@ -115,6 +117,8 @@ async function fetchTransactionsPage( * Fetches all supply and withdraw transactions with automatic pagination. * Will continue fetching until we get less than `limit` results. */ +const MAX_PAGES = 50; + export async function fetchMonarchTransactions( timeRange: TimeRange, limit = 1000, @@ -125,8 +129,10 @@ export async function fetchMonarchTransactions( const allSupplies: MonarchSupplyTransaction[] = []; const allWithdraws: MonarchWithdrawTransaction[] = []; let offset = 0; + let pageCount = 0; - while (true) { + while (pageCount < MAX_PAGES) { + pageCount++; const response = await fetchTransactionsPage(timeRange, limit, offset); const supplies = response.data.Morpho_Supply ?? []; const withdraws = response.data.Morpho_Withdraw ?? []; diff --git a/src/features/admin-v2/components/chain-volume-chart.tsx b/src/features/admin-v2/components/chain-volume-chart.tsx index efd29542..78290834 100644 --- a/src/features/admin-v2/components/chain-volume-chart.tsx +++ b/src/features/admin-v2/components/chain-volume-chart.tsx @@ -5,9 +5,9 @@ import Image from 'next/image'; import { Card } from '@/components/ui/card'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { Spinner } from '@/components/ui/spinner'; -import { useChartColors, CHART_PALETTES } from '@/constants/chartColors'; +import { CHART_PALETTES } from '@/constants/chartColors'; import { formatReadable } from '@/utils/balance'; -import { getNetworkImg, getNetworkName, type SupportedNetworks } from '@/utils/networks'; +import { getNetworkImg, getNetworkName } from '@/utils/networks'; import { ChartGradients, chartTooltipCursor, chartLegendStyle } from '@/features/market-detail/components/charts/chart-utils'; import type { DailyVolume, ChainStats } from '@/hooks/useMonarchTransactions'; @@ -33,31 +33,20 @@ function getChainColor(chainId: number): string { } export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainVolumeChartProps) { - const chartColors = useChartColors(); - // Get unique chain IDs from stats const chainIds = useMemo(() => chainStats.map((s) => s.chainId), [chainStats]); - const [visibleChains, setVisibleChains] = useState>(() => { - const initial: Record = {}; + // Track hidden chains instead of visible - all chains visible by default + const [hiddenChains, setHiddenChains] = useState>(new Set()); + + // Derive visible chains (pure computation, no side effects) + const visibleChains = useMemo(() => { + const visible: Record = {}; for (const chainId of chainIds) { - initial[chainId] = true; + visible[chainId] = !hiddenChains.has(chainId); } - return initial; - }); - - // Update visible chains when chainIds change - useMemo(() => { - setVisibleChains((prev) => { - const updated = { ...prev }; - for (const chainId of chainIds) { - if (updated[chainId] === undefined) { - updated[chainId] = true; - } - } - return updated; - }); - }, [chainIds]); + return visible; + }, [chainIds, hiddenChains]); const chartData = useMemo(() => { return dailyVolumes.map((v) => { @@ -100,10 +89,15 @@ export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainV const handleLegendClick = (e: { dataKey?: string }) => { if (!e.dataKey) return; const chainId = Number(e.dataKey.replace('chain_', '')); - setVisibleChains((prev) => ({ - ...prev, - [chainId]: !prev[chainId], - })); + setHiddenChains((prev) => { + const next = new Set(prev); + if (next.has(chainId)) { + next.delete(chainId); + } else { + next.add(chainId); + } + return next; + }); }; const legendFormatter = (value: string, entry: { dataKey?: string }) => { @@ -132,13 +126,25 @@ export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainV const networkImg = getNetworkImg(stat.chainId); const networkName = getNetworkName(stat.chainId) ?? `Chain ${stat.chainId}`; return ( -
+
{networkImg && ( - {networkName} + {networkName} )}

{networkName}

-

+

${formatReadable(stat.totalVolumeUsd)}

@@ -157,19 +163,30 @@ export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainV ) : filteredChartData.length === 0 ? (
No data available
) : ( - - - - + + + + - new Date(time * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) - } + tickFormatter={(time) => new Date(time * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} /> +
- + {networkName}
${formatReadable(entry.value ?? 0)} diff --git a/src/features/admin-v2/components/stats-transactions-table.tsx b/src/features/admin-v2/components/stats-transactions-table.tsx index e6f7f31d..4a7292af 100644 --- a/src/features/admin-v2/components/stats-transactions-table.tsx +++ b/src/features/admin-v2/components/stats-transactions-table.tsx @@ -6,18 +6,14 @@ import Link from 'next/link'; import moment from 'moment'; import { ChevronUpIcon, ChevronDownIcon } from '@radix-ui/react-icons'; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from '@/components/ui/table'; +import { AddressIdentity } from '@/components/shared/address-identity'; import { TablePagination } from '@/components/shared/table-pagination'; import { TokenIcon } from '@/components/shared/token-icon'; import { TransactionIdentity } from '@/components/shared/transaction-identity'; import { MarketIdBadge } from '@/features/markets/components/market-id-badge'; import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuCheckboxItem, -} from '@/components/ui/dropdown-menu'; +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem } from '@/components/ui/dropdown-menu'; import { formatReadable } from '@/utils/balance'; import { getNetworkImg, getNetworkName, type SupportedNetworks } from '@/utils/networks'; import { getTruncatedAssetName } from '@/utils/oracle'; @@ -39,6 +35,18 @@ type SortableHeaderProps = { onSort: (key: SortKey) => void; }; +function getChainFilterLabel(selectedChains: number[]): string { + if (selectedChains.length === 0) return 'All chains'; + if (selectedChains.length === 1) return getNetworkName(selectedChains[0]) ?? 'Chain'; + return `${selectedChains.length} chains`; +} + +function getTypeFilterLabel(selectedTypes: ('supply' | 'withdraw')[]): string { + if (selectedTypes.length === 0) return 'All types'; + if (selectedTypes.length === 1) return selectedTypes[0] === 'supply' ? 'Supply' : 'Withdraw'; + return 'Both types'; +} + function SortableHeader({ label, sortKeyValue, currentSortKey, sortDirection, onSort }: SortableHeaderProps) { return ( - @@ -184,14 +192,12 @@ export function StatsTransactionsTable({ transactions, isLoading }: StatsTransac {/* Type Filter */} - @@ -214,9 +220,7 @@ export function StatsTransactionsTable({ transactions, isLoading }: StatsTransac
{sortedData.length === 0 ? ( -
- {isLoading ? 'Loading transactions...' : 'No transaction data available'} -
+
{isLoading ? 'Loading transactions...' : 'No transaction data available'}
) : ( <> @@ -224,6 +228,7 @@ export function StatsTransactionsTable({ transactions, isLoading }: StatsTransac Chain Type + User Asset Market + {/* Chain */} - +
{networkImg && ( {/* Type */} - + + {/* User */} + + + + {/* Asset */} - +
{tx.market && ( )} - - {getTruncatedAssetName(tx.loanSymbol ?? 'Unknown')} - + {getTruncatedAssetName(tx.loanSymbol ?? 'Unknown')}
{/* Market */} - + {tx.market && marketPath ? ( - +
{/* Amount (USD) */} - + ${formatReadable(tx.usdValue)} @@ -332,15 +367,22 @@ export function StatsTransactionsTable({ transactions, isLoading }: StatsTransac {/* Tx Hash */} - - + + {/* Time */} - - - {moment.unix(tx.timestamp).fromNow()} - + + {moment.unix(tx.timestamp).fromNow()} ); diff --git a/src/features/admin-v2/components/stats-volume-chart.tsx b/src/features/admin-v2/components/stats-volume-chart.tsx index 971f7df9..7403dd18 100644 --- a/src/features/admin-v2/components/stats-volume-chart.tsx +++ b/src/features/admin-v2/components/stats-volume-chart.tsx @@ -29,12 +29,7 @@ function createStatsVolumeGradients(colors: ReturnType) { ]; } -export function StatsVolumeChart({ - dailyVolumes, - totalSupplyVolumeUsd, - totalWithdrawVolumeUsd, - isLoading, -}: StatsVolumeChartProps) { +export function StatsVolumeChart({ dailyVolumes, totalSupplyVolumeUsd, totalWithdrawVolumeUsd, isLoading }: StatsVolumeChartProps) { const chartColors = useChartColors(); const [visibleLines, setVisibleLines] = useState({ supply: true, @@ -67,13 +62,19 @@ export function StatsVolumeChart({

Supply Volume

- + ${formatReadable(totalSupplyVolumeUsd)}

Withdraw Volume

- + ${formatReadable(totalWithdrawVolumeUsd)}
@@ -89,19 +90,30 @@ export function StatsVolumeChart({ ) : chartData.length === 0 ? (
No data available
) : ( - - - - + + + + - new Date(time * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) - } + tickFormatter={(time) => new Date(time * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} /> ( - + )} /> - +
Transactions - {dailyVolumes.reduce((sum, d) => sum + 1, 0)} days + {dailyVolumes.length} days
Avg Daily Volume - - ${formatReadable(dailyVolumes.length > 0 ? totalVolume / dailyVolumes.length : 0)} - + ${formatReadable(dailyVolumes.length > 0 ? totalVolume / dailyVolumes.length : 0)}
diff --git a/src/hooks/useMonarchTransactions.ts b/src/hooks/useMonarchTransactions.ts index 5c2629cb..54869d58 100644 --- a/src/hooks/useMonarchTransactions.ts +++ b/src/hooks/useMonarchTransactions.ts @@ -38,6 +38,7 @@ export type EnrichedTransaction = { type: 'supply' | 'withdraw'; market?: Market; loanSymbol?: string; + onBehalf: string; }; export type ChainStats = { @@ -134,9 +135,7 @@ export const useMonarchTransactions = (timeframe: TimeFrame): UseMonarchTransact const ethPrice = useMemo(() => { for (const [key, price] of tokenPrices) { const address = key.split('-')[0]; - const market = allMarkets.find( - (m) => m.loanAsset.address.toLowerCase() === address && ETH_PEGGED_SYMBOLS.has(m.loanAsset.symbol), - ); + const market = allMarkets.find((m) => m.loanAsset.address.toLowerCase() === address && ETH_PEGGED_SYMBOLS.has(m.loanAsset.symbol)); if (market) return price; } return 0; @@ -145,9 +144,7 @@ export const useMonarchTransactions = (timeframe: TimeFrame): UseMonarchTransact const btcPrice = useMemo(() => { for (const [key, price] of tokenPrices) { const address = key.split('-')[0]; - const market = allMarkets.find( - (m) => m.loanAsset.address.toLowerCase() === address && BTC_PEGGED_SYMBOLS.has(m.loanAsset.symbol), - ); + const market = allMarkets.find((m) => m.loanAsset.address.toLowerCase() === address && BTC_PEGGED_SYMBOLS.has(m.loanAsset.symbol)); if (market) return price; } return 0; @@ -195,10 +192,7 @@ export const useMonarchTransactions = (timeframe: TimeFrame): UseMonarchTransact return 0; }; - const enrichTx = ( - tx: MonarchSupplyTransaction | MonarchWithdrawTransaction, - type: 'supply' | 'withdraw', - ): EnrichedTransaction => { + const enrichTx = (tx: MonarchSupplyTransaction | MonarchWithdrawTransaction, type: 'supply' | 'withdraw'): EnrichedTransaction => { const market = marketMap.get(tx.market_id.toLowerCase()); const decimals = market?.loanAsset.decimals ?? 18; const formatted = Number(formatUnits(BigInt(tx.assets), decimals)); @@ -214,6 +208,7 @@ export const useMonarchTransactions = (timeframe: TimeFrame): UseMonarchTransact type, market, loanSymbol: market?.loanAsset.symbol, + onBehalf: tx.onBehalf, }; }; @@ -304,29 +299,20 @@ export const useMonarchTransactions = (timeframe: TimeFrame): UseMonarchTransact // Calculate totals const totals = useMemo(() => { - let supplyVolumeUsd = 0; - let withdrawVolumeUsd = 0; - - for (const tx of enrichedData.supplies) { - supplyVolumeUsd += tx.usdValue; - } - - for (const tx of enrichedData.withdraws) { - withdrawVolumeUsd += tx.usdValue; - } - + const totalSupplyVolumeUsd = enrichedData.supplies.reduce((sum, tx) => sum + tx.usdValue, 0); + const totalWithdrawVolumeUsd = enrichedData.withdraws.reduce((sum, tx) => sum + tx.usdValue, 0); return { - totalSupplyVolumeUsd: supplyVolumeUsd, - totalWithdrawVolumeUsd: withdrawVolumeUsd, - totalVolumeUsd: supplyVolumeUsd + withdrawVolumeUsd, + totalSupplyVolumeUsd, + totalWithdrawVolumeUsd, + totalVolumeUsd: totalSupplyVolumeUsd + totalWithdrawVolumeUsd, }; }, [enrichedData]); - // Calculate unique users (from tx hashes, approximation) + // Calculate unique users from onBehalf addresses const uniqueUsers = useMemo(() => { const users = new Set(); for (const tx of enrichedData.transactions) { - users.add(tx.txHash); + users.add(tx.onBehalf.toLowerCase()); } return users.size; }, [enrichedData.transactions]); From 07efb3f0be90a1ade885552737545f0a747fc453 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 25 Jan 2026 18:23:24 +0800 Subject: [PATCH 3/8] chore: lint --- app/admin/stats-v2/page.tsx | 22 +++++++++++++++---- app/api/admin/monarch-indexer/route.ts | 7 ++---- .../admin-v2/components/password-gate.tsx | 8 ++----- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/app/admin/stats-v2/page.tsx b/app/admin/stats-v2/page.tsx index 46c07ca0..4f864aa4 100644 --- a/app/admin/stats-v2/page.tsx +++ b/app/admin/stats-v2/page.tsx @@ -59,7 +59,10 @@ function StatsV2Content() {

Error Loading Data

{error.message}

-
@@ -88,7 +91,11 @@ function StatsV2Content() { size="sm" variant="default" /> - @@ -121,11 +128,18 @@ function StatsV2Content() { /> {/* Chain Breakdown Chart */} - + {/* Transactions Table */} - + )} diff --git a/app/api/admin/monarch-indexer/route.ts b/app/api/admin/monarch-indexer/route.ts index 43e0d877..d2fbd66e 100644 --- a/app/api/admin/monarch-indexer/route.ts +++ b/app/api/admin/monarch-indexer/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { type NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; /** @@ -41,10 +41,7 @@ export async function POST(request: NextRequest) { }); if (!response.ok) { - return NextResponse.json( - { error: `Indexer request failed: ${response.status}` }, - { status: response.status }, - ); + return NextResponse.json({ error: `Indexer request failed: ${response.status}` }, { status: response.status }); } const data = await response.json(); diff --git a/src/features/admin-v2/components/password-gate.tsx b/src/features/admin-v2/components/password-gate.tsx index f0b1ee8e..beaff65c 100644 --- a/src/features/admin-v2/components/password-gate.tsx +++ b/src/features/admin-v2/components/password-gate.tsx @@ -47,9 +47,7 @@ export function PasswordGate({ children }: PasswordGateProps) {

Stats V2 (Experimental)

-

- This page uses an experimental API that may be reverted due to cost concerns. -

+

This page uses an experimental API that may be reverted due to cost concerns.

@@ -74,9 +72,7 @@ export function PasswordGate({ children }: PasswordGateProps) { -

- Contact the team if you need access credentials. -

+

Contact the team if you need access credentials.

From 1521e17b6f923e416e772984948abf62c9baf21d Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 25 Jan 2026 18:28:27 +0800 Subject: [PATCH 4/8] feat: build --- .../admin-v2/components/chain-volume-chart.tsx | 14 +++++++------- .../components/stats-transactions-table.tsx | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/features/admin-v2/components/chain-volume-chart.tsx b/src/features/admin-v2/components/chain-volume-chart.tsx index 78290834..d6a27041 100644 --- a/src/features/admin-v2/components/chain-volume-chart.tsx +++ b/src/features/admin-v2/components/chain-volume-chart.tsx @@ -86,8 +86,8 @@ export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainV const formatYAxis = (value: number) => `$${formatReadable(value)}`; - const handleLegendClick = (e: { dataKey?: string }) => { - if (!e.dataKey) return; + const handleLegendClick = (e: { dataKey?: unknown }) => { + if (!e.dataKey || typeof e.dataKey !== 'string') return; const chainId = Number(e.dataKey.replace('chain_', '')); setHiddenChains((prev) => { const next = new Set(prev); @@ -100,8 +100,8 @@ export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainV }); }; - const legendFormatter = (value: string, entry: { dataKey?: string }) => { - if (!entry.dataKey) return value; + const legendFormatter = (value: string, entry: { dataKey?: unknown }) => { + if (!entry.dataKey || typeof entry.dataKey !== 'string') return value; const chainId = Number(entry.dataKey.replace('chain_', '')); const isVisible = visibleChains[chainId] ?? true; return ( @@ -211,8 +211,8 @@ export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainV })}

- {payload.map((entry: { dataKey?: string; value?: number; color?: string }) => { - if (!entry.dataKey) return null; + {payload.map((entry) => { + if (!entry.dataKey || typeof entry.dataKey !== 'string') return null; const chainId = Number(entry.dataKey.replace('chain_', '')); const networkName = getNetworkName(chainId) ?? `Chain ${chainId}`; return ( @@ -227,7 +227,7 @@ export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainV /> {networkName}
- ${formatReadable(entry.value ?? 0)} + ${formatReadable(Number(entry.value) || 0)} ); })} diff --git a/src/features/admin-v2/components/stats-transactions-table.tsx b/src/features/admin-v2/components/stats-transactions-table.tsx index 4a7292af..d0474e25 100644 --- a/src/features/admin-v2/components/stats-transactions-table.tsx +++ b/src/features/admin-v2/components/stats-transactions-table.tsx @@ -154,7 +154,7 @@ export function StatsTransactionsTable({ transactions, isLoading }: StatsTransac +
+
+
+
+

Error Loading Data

+

{error.message}

+ +
); } return ( -
- {/* Header */} -
-
-
-

Stats V2

- Experimental +
+
+
+ {/* Header */} +
+
+
+

Stats V2

+ Experimental +
+

+ Cross-chain Monarch transaction analytics. This API may be reverted due to cost concerns. +

+
+
+ setTimeframe(value as TimeFrame)} + size="sm" + variant="default" + /> +
-

- Cross-chain Monarch transaction analytics. This API may be reverted due to cost concerns. -

-
-
- setTimeframe(value as TimeFrame)} - size="sm" - variant="default" - /> - -
-
- - {isLoading ? ( -
-
- ) : ( -
- {/* Overview Cards */} - - {/* Charts Grid */} -
- {/* Aggregated Volume Chart */} - + +
+ ) : ( +
+ {/* Overview Cards */} + - {/* Chain Breakdown Chart */} - + {/* Aggregated Volume Chart */} + + + {/* Chain Breakdown Chart */} + +
+ + {/* Transactions Table */} +
- - {/* Transactions Table */} - -
- )} + )} +
); } diff --git a/src/data-sources/monarch-indexer/transactions.ts b/src/data-sources/monarch-indexer/transactions.ts index a1f5e637..15902c59 100644 --- a/src/data-sources/monarch-indexer/transactions.ts +++ b/src/data-sources/monarch-indexer/transactions.ts @@ -3,7 +3,7 @@ * * Fetches Monarch supply/withdraw transactions across all chains. * Auth is handled via httpOnly cookie. - * Supports pagination to fetch all transactions beyond the 1000 limit. + * Uses separate pagination for supplies and withdraws to ensure complete data. */ import { monarchIndexerFetcher } from './fetchers'; @@ -26,9 +26,14 @@ export type MonarchWithdrawTransaction = { onBehalf: string; }; -type MonarchTransactionsResponse = { +type SuppliesResponse = { data: { Morpho_Supply?: MonarchSupplyTransaction[]; + }; +}; + +type WithdrawsResponse = { + data: { Morpho_Withdraw?: MonarchWithdrawTransaction[]; }; }; @@ -38,71 +43,55 @@ export type TimeRange = { endTimestamp?: number; }; -/** - * Fetches a single page of transactions - */ -async function fetchTransactionsPage(timeRange: TimeRange, limit: number, offset: number): Promise { +const MAX_PAGES = 50; + +async function fetchSuppliesPage(timeRange: TimeRange, limit: number, offset: number): Promise { const hasEndTimestamp = timeRange.endTimestamp !== undefined; const query = hasEndTimestamp ? ` - query MonarchTxs($startTimestamp: numeric!, $endTimestamp: numeric!, $limit: Int!, $offset: Int!) { + query MonarchSupplies($startTimestamp: numeric!, $endTimestamp: numeric!, $limit: Int!, $offset: Int!) { Morpho_Supply( where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp, _lte: $endTimestamp}}, - limit: $limit, - offset: $offset, - order_by: {timestamp: desc} - ) { - txHash - timestamp - market_id - assets - chainId - onBehalf - } - Morpho_Withdraw( - where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp, _lte: $endTimestamp}}, - limit: $limit, - offset: $offset, - order_by: {timestamp: desc} - ) { - txHash - timestamp - market_id - assets - chainId - onBehalf - } + limit: $limit, offset: $offset, order_by: {timestamp: desc} + ) { txHash timestamp market_id assets chainId onBehalf } } ` : ` - query MonarchTxs($startTimestamp: numeric!, $limit: Int!, $offset: Int!) { + query MonarchSupplies($startTimestamp: numeric!, $limit: Int!, $offset: Int!) { Morpho_Supply( where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp}}, - limit: $limit, - offset: $offset, - order_by: {timestamp: desc} - ) { - txHash - timestamp - market_id - assets - chainId - onBehalf - } + limit: $limit, offset: $offset, order_by: {timestamp: desc} + ) { txHash timestamp market_id assets chainId onBehalf } + } + `; + + const variables = hasEndTimestamp + ? { startTimestamp: timeRange.startTimestamp, endTimestamp: timeRange.endTimestamp, limit, offset } + : { startTimestamp: timeRange.startTimestamp, limit, offset }; + + const response = await monarchIndexerFetcher(query, variables); + return response.data.Morpho_Supply ?? []; +} + +async function fetchWithdrawsPage(timeRange: TimeRange, limit: number, offset: number): Promise { + const hasEndTimestamp = timeRange.endTimestamp !== undefined; + + const query = hasEndTimestamp + ? ` + query MonarchWithdraws($startTimestamp: numeric!, $endTimestamp: numeric!, $limit: Int!, $offset: Int!) { + Morpho_Withdraw( + where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp, _lte: $endTimestamp}}, + limit: $limit, offset: $offset, order_by: {timestamp: desc} + ) { txHash timestamp market_id assets chainId onBehalf } + } + ` + : ` + query MonarchWithdraws($startTimestamp: numeric!, $limit: Int!, $offset: Int!) { Morpho_Withdraw( where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp}}, - limit: $limit, - offset: $offset, - order_by: {timestamp: desc} - ) { - txHash - timestamp - market_id - assets - chainId - onBehalf - } + limit: $limit, offset: $offset, order_by: {timestamp: desc} + ) { txHash timestamp market_id assets chainId onBehalf } } `; @@ -110,15 +99,14 @@ async function fetchTransactionsPage(timeRange: TimeRange, limit: number, offset ? { startTimestamp: timeRange.startTimestamp, endTimestamp: timeRange.endTimestamp, limit, offset } : { startTimestamp: timeRange.startTimestamp, limit, offset }; - return monarchIndexerFetcher(query, variables); + const response = await monarchIndexerFetcher(query, variables); + return response.data.Morpho_Withdraw ?? []; } /** - * Fetches all supply and withdraw transactions with automatic pagination. - * Will continue fetching until we get less than `limit` results. + * Fetches all supply and withdraw transactions with independent pagination. + * Each collection is fetched completely before returning. */ -const MAX_PAGES = 50; - export async function fetchMonarchTransactions( timeRange: TimeRange, limit = 1000, @@ -126,26 +114,24 @@ export async function fetchMonarchTransactions( supplies: MonarchSupplyTransaction[]; withdraws: MonarchWithdrawTransaction[]; }> { + // Fetch supplies with independent pagination const allSupplies: MonarchSupplyTransaction[] = []; - const allWithdraws: MonarchWithdrawTransaction[] = []; - let offset = 0; - let pageCount = 0; - - while (pageCount < MAX_PAGES) { - pageCount++; - const response = await fetchTransactionsPage(timeRange, limit, offset); - const supplies = response.data.Morpho_Supply ?? []; - const withdraws = response.data.Morpho_Withdraw ?? []; - + let suppliesOffset = 0; + for (let page = 0; page < MAX_PAGES; page++) { + const supplies = await fetchSuppliesPage(timeRange, limit, suppliesOffset); allSupplies.push(...supplies); - allWithdraws.push(...withdraws); - - // If both returned less than limit, we've fetched everything - if (supplies.length < limit && withdraws.length < limit) { - break; - } + if (supplies.length < limit) break; + suppliesOffset += limit; + } - offset += limit; + // Fetch withdraws with independent pagination + const allWithdraws: MonarchWithdrawTransaction[] = []; + let withdrawsOffset = 0; + for (let page = 0; page < MAX_PAGES; page++) { + const withdraws = await fetchWithdrawsPage(timeRange, limit, withdrawsOffset); + allWithdraws.push(...withdraws); + if (withdraws.length < limit) break; + withdrawsOffset += limit; } return { supplies: allSupplies, withdraws: allWithdraws }; diff --git a/src/features/admin-v2/components/chain-volume-chart.tsx b/src/features/admin-v2/components/chain-volume-chart.tsx index d6a27041..10313968 100644 --- a/src/features/admin-v2/components/chain-volume-chart.tsx +++ b/src/features/admin-v2/components/chain-volume-chart.tsx @@ -59,24 +59,6 @@ export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainV }); }, [dailyVolumes, chainIds]); - // Filter chart data to only include visible chains (enables Y-axis auto-rescale) - const filteredChartData = useMemo(() => { - return chartData.map((dataPoint) => { - const filtered: Record = { x: dataPoint.x }; - for (const chainId of chainIds) { - if (visibleChains[chainId]) { - filtered[`chain_${chainId}`] = dataPoint[`chain_${chainId}`] ?? 0; - } - } - return filtered; - }); - }, [chartData, chainIds, visibleChains]); - - // Get only visible chain IDs for rendering - const visibleChainIds = useMemo(() => { - return chainIds.filter((chainId) => visibleChains[chainId]); - }, [chainIds, visibleChains]); - const gradients = useMemo(() => { return chainIds.map((chainId) => ({ id: `chain_${chainId}Gradient`, @@ -160,7 +142,7 @@ export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainV
- ) : filteredChartData.length === 0 ? ( + ) : chartData.length === 0 ? (
No data available
) : ( - {visibleChainIds.map((chainId) => ( + {chainIds.map((chainId) => ( ))} diff --git a/src/stores/useAdminAuth.ts b/src/stores/useAdminAuth.ts index 87e80329..8f70fad3 100644 --- a/src/stores/useAdminAuth.ts +++ b/src/stores/useAdminAuth.ts @@ -46,6 +46,10 @@ export const useAdminAuth = create()((set) => ({ method: 'GET', credentials: 'same-origin', }); + if (!response.ok) { + set({ isAuthenticated: false, isCheckingAuth: false }); + return; + } const data = await response.json(); set({ isAuthenticated: data.authenticated, isCheckingAuth: false }); } catch { From 876e87c62f2a13886f69967f9aac45ecf0dda09b Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 25 Jan 2026 18:42:20 +0800 Subject: [PATCH 6/8] chore: color palette --- .../components/chain-volume-chart.tsx | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/features/admin-v2/components/chain-volume-chart.tsx b/src/features/admin-v2/components/chain-volume-chart.tsx index 10313968..b575270b 100644 --- a/src/features/admin-v2/components/chain-volume-chart.tsx +++ b/src/features/admin-v2/components/chain-volume-chart.tsx @@ -5,7 +5,7 @@ import Image from 'next/image'; import { Card } from '@/components/ui/card'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { Spinner } from '@/components/ui/spinner'; -import { CHART_PALETTES } from '@/constants/chartColors'; +import { useChartColors } from '@/constants/chartColors'; import { formatReadable } from '@/utils/balance'; import { getNetworkImg, getNetworkName } from '@/utils/networks'; import { ChartGradients, chartTooltipCursor, chartLegendStyle } from '@/features/market-detail/components/charts/chart-utils'; @@ -17,22 +17,26 @@ type ChainVolumeChartProps = { isLoading: boolean; }; -// Chain color mapping using pie colors -const CHAIN_COLORS: Record = { - 1: CHART_PALETTES.classic.pie[0], // Mainnet - Blue - 8453: CHART_PALETTES.classic.pie[1], // Base - Green - 137: CHART_PALETTES.classic.pie[3], // Polygon - Purple - 130: CHART_PALETTES.classic.pie[4], // Unichain - Teal - 42161: CHART_PALETTES.classic.pie[6], // Arbitrum - Orange - 999: CHART_PALETTES.classic.pie[5], // HyperEVM - Pink - 143: CHART_PALETTES.classic.pie[2], // Monad - Yellow +// Chain to pie color index mapping (consistent ordering) +const CHAIN_COLOR_INDEX: Record = { + 1: 0, // Mainnet + 8453: 1, // Base + 137: 3, // Polygon + 130: 4, // Unichain + 42161: 6, // Arbitrum + 999: 5, // HyperEVM + 143: 2, // Monad }; -function getChainColor(chainId: number): string { - return CHAIN_COLORS[chainId] ?? CHART_PALETTES.classic.pie[8]; -} - export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainVolumeChartProps) { + const chartColors = useChartColors(); + + // Get color for a chain using the current palette + const getChainColor = (chainId: number): string => { + const index = CHAIN_COLOR_INDEX[chainId] ?? 8; + return chartColors.pie[index] ?? chartColors.pie[8]; + }; + // Get unique chain IDs from stats const chainIds = useMemo(() => chainStats.map((s) => s.chainId), [chainStats]); @@ -64,7 +68,7 @@ export function ChainVolumeChart({ dailyVolumes, chainStats, isLoading }: ChainV id: `chain_${chainId}Gradient`, color: getChainColor(chainId), })); - }, [chainIds]); + }, [chainIds, chartColors]); const formatYAxis = (value: number) => `$${formatReadable(value)}`; From c6aa1c8581731ed993a66bc44486117fec6f489e Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 25 Jan 2026 18:46:38 +0800 Subject: [PATCH 7/8] chore: styling --- app/admin/stats-v2/page.tsx | 12 ++++-------- .../admin-v2/components/stats-overview-cards.tsx | 4 ++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/app/admin/stats-v2/page.tsx b/app/admin/stats-v2/page.tsx index bc368313..3d0a553d 100644 --- a/app/admin/stats-v2/page.tsx +++ b/app/admin/stats-v2/page.tsx @@ -82,14 +82,10 @@ function StatsV2Content() {
-

Stats V2

- Experimental +

Monarch Stats

-

- Cross-chain Monarch transaction analytics. This API may be reverted due to cost concerns. -

-
+
diff --git a/src/features/admin-v2/components/stats-overview-cards.tsx b/src/features/admin-v2/components/stats-overview-cards.tsx index d75654d3..3276d147 100644 --- a/src/features/admin-v2/components/stats-overview-cards.tsx +++ b/src/features/admin-v2/components/stats-overview-cards.tsx @@ -23,7 +23,7 @@ function StatCard({ title, value, subtitle }: StatCardProps) { return ( -

{title}

+

{title}

{value}

{subtitle &&

{subtitle}

} @@ -45,7 +45,7 @@ export function StatsOverviewCards({ const activeChains = chainStats.length; return ( -
+
Date: Sun, 25 Jan 2026 18:51:23 +0800 Subject: [PATCH 8/8] chore: review fixes --- .../monarch-indexer/transactions.ts | 98 +++++++++---------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/src/data-sources/monarch-indexer/transactions.ts b/src/data-sources/monarch-indexer/transactions.ts index 15902c59..0d69fef0 100644 --- a/src/data-sources/monarch-indexer/transactions.ts +++ b/src/data-sources/monarch-indexer/transactions.ts @@ -4,6 +4,7 @@ * Fetches Monarch supply/withdraw transactions across all chains. * Auth is handled via httpOnly cookie. * Uses separate pagination for supplies and withdraws to ensure complete data. + * Freezes endTimestamp at fetch start to ensure consistent pagination. */ import { monarchIndexerFetcher } from './fetchers'; @@ -43,61 +44,50 @@ export type TimeRange = { endTimestamp?: number; }; +type FrozenTimeRange = { + startTimestamp: number; + endTimestamp: number; +}; + const MAX_PAGES = 50; -async function fetchSuppliesPage(timeRange: TimeRange, limit: number, offset: number): Promise { - const hasEndTimestamp = timeRange.endTimestamp !== undefined; - - const query = hasEndTimestamp - ? ` - query MonarchSupplies($startTimestamp: numeric!, $endTimestamp: numeric!, $limit: Int!, $offset: Int!) { - Morpho_Supply( - where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp, _lte: $endTimestamp}}, - limit: $limit, offset: $offset, order_by: {timestamp: desc} - ) { txHash timestamp market_id assets chainId onBehalf } - } - ` - : ` - query MonarchSupplies($startTimestamp: numeric!, $limit: Int!, $offset: Int!) { - Morpho_Supply( - where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp}}, - limit: $limit, offset: $offset, order_by: {timestamp: desc} - ) { txHash timestamp market_id assets chainId onBehalf } - } - `; - - const variables = hasEndTimestamp - ? { startTimestamp: timeRange.startTimestamp, endTimestamp: timeRange.endTimestamp, limit, offset } - : { startTimestamp: timeRange.startTimestamp, limit, offset }; +async function fetchSuppliesPage(timeRange: FrozenTimeRange, limit: number, offset: number): Promise { + const query = ` + query MonarchSupplies($startTimestamp: numeric!, $endTimestamp: numeric!, $limit: Int!, $offset: Int!) { + Morpho_Supply( + where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp, _lte: $endTimestamp}}, + limit: $limit, offset: $offset, order_by: {timestamp: desc} + ) { txHash timestamp market_id assets chainId onBehalf } + } + `; + + const variables = { + startTimestamp: timeRange.startTimestamp, + endTimestamp: timeRange.endTimestamp, + limit, + offset, + }; const response = await monarchIndexerFetcher(query, variables); return response.data.Morpho_Supply ?? []; } -async function fetchWithdrawsPage(timeRange: TimeRange, limit: number, offset: number): Promise { - const hasEndTimestamp = timeRange.endTimestamp !== undefined; - - const query = hasEndTimestamp - ? ` - query MonarchWithdraws($startTimestamp: numeric!, $endTimestamp: numeric!, $limit: Int!, $offset: Int!) { - Morpho_Withdraw( - where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp, _lte: $endTimestamp}}, - limit: $limit, offset: $offset, order_by: {timestamp: desc} - ) { txHash timestamp market_id assets chainId onBehalf } - } - ` - : ` - query MonarchWithdraws($startTimestamp: numeric!, $limit: Int!, $offset: Int!) { - Morpho_Withdraw( - where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp}}, - limit: $limit, offset: $offset, order_by: {timestamp: desc} - ) { txHash timestamp market_id assets chainId onBehalf } - } - `; - - const variables = hasEndTimestamp - ? { startTimestamp: timeRange.startTimestamp, endTimestamp: timeRange.endTimestamp, limit, offset } - : { startTimestamp: timeRange.startTimestamp, limit, offset }; +async function fetchWithdrawsPage(timeRange: FrozenTimeRange, limit: number, offset: number): Promise { + const query = ` + query MonarchWithdraws($startTimestamp: numeric!, $endTimestamp: numeric!, $limit: Int!, $offset: Int!) { + Morpho_Withdraw( + where: {isMonarch: {_eq: true}, timestamp: {_gte: $startTimestamp, _lte: $endTimestamp}}, + limit: $limit, offset: $offset, order_by: {timestamp: desc} + ) { txHash timestamp market_id assets chainId onBehalf } + } + `; + + const variables = { + startTimestamp: timeRange.startTimestamp, + endTimestamp: timeRange.endTimestamp, + limit, + offset, + }; const response = await monarchIndexerFetcher(query, variables); return response.data.Morpho_Withdraw ?? []; @@ -106,6 +96,7 @@ async function fetchWithdrawsPage(timeRange: TimeRange, limit: number, offset: n /** * Fetches all supply and withdraw transactions with independent pagination. * Each collection is fetched completely before returning. + * Freezes endTimestamp at start to ensure consistent results during pagination. */ export async function fetchMonarchTransactions( timeRange: TimeRange, @@ -114,11 +105,18 @@ export async function fetchMonarchTransactions( supplies: MonarchSupplyTransaction[]; withdraws: MonarchWithdrawTransaction[]; }> { + // Freeze the end timestamp at function start to ensure consistent pagination + // This prevents skipped/duplicate data if new transactions arrive during fetching + const frozenTimeRange: FrozenTimeRange = { + startTimestamp: timeRange.startTimestamp, + endTimestamp: timeRange.endTimestamp ?? Math.floor(Date.now() / 1000), + }; + // Fetch supplies with independent pagination const allSupplies: MonarchSupplyTransaction[] = []; let suppliesOffset = 0; for (let page = 0; page < MAX_PAGES; page++) { - const supplies = await fetchSuppliesPage(timeRange, limit, suppliesOffset); + const supplies = await fetchSuppliesPage(frozenTimeRange, limit, suppliesOffset); allSupplies.push(...supplies); if (supplies.length < limit) break; suppliesOffset += limit; @@ -128,7 +126,7 @@ export async function fetchMonarchTransactions( const allWithdraws: MonarchWithdrawTransaction[] = []; let withdrawsOffset = 0; for (let page = 0; page < MAX_PAGES; page++) { - const withdraws = await fetchWithdrawsPage(timeRange, limit, withdrawsOffset); + const withdraws = await fetchWithdrawsPage(frozenTimeRange, limit, withdrawsOffset); allWithdraws.push(...withdraws); if (withdraws.length < limit) break; withdrawsOffset += limit;