diff --git a/apps/explorer/src/comps/NetworkStats.tsx b/apps/explorer/src/comps/NetworkStats.tsx new file mode 100644 index 00000000..210b30c4 --- /dev/null +++ b/apps/explorer/src/comps/NetworkStats.tsx @@ -0,0 +1,77 @@ +import { useQuery } from '@tanstack/react-query' +import * as React from 'react' +import { getApiUrl } from '#lib/env' +import { TOKEN_COUNT_MAX } from '#lib/constants' +import type { StatsApiResponse } from '#routes/api/stats' + +async function fetchStats(): Promise { + const response = await fetch(getApiUrl('/api/stats')) + if (!response.ok) throw new Error('Failed to fetch stats') + const json: StatsApiResponse = await response.json() + if (json.error) throw new Error(json.error) + return json.data +} + +function formatNumber(num: number, max?: number): string { + if (max && num >= max) return `${(max / 1000).toFixed(0)}K+` + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M` + if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K` + return num.toLocaleString() +} + +export function NetworkStats(): React.JSX.Element | null { + const { data, isLoading, isError } = useQuery({ + queryKey: ['network-stats'], + queryFn: fetchStats, + staleTime: 60_000, + gcTime: 5 * 60_000, + refetchInterval: 60_000, + retry: 2, + }) + + if (isLoading || isError || !data) return null + + const hasAnyData = + data.transactions24h > 0 || data.tokens > 0 || data.accounts24h > 0 + + if (!hasAnyData) return null + + const stats = [ + data.transactions24h > 0 && { + value: formatNumber(data.transactions24h), + label: 'txns / 24h', + }, + data.tokens > 0 && { + value: formatNumber(data.tokens, TOKEN_COUNT_MAX), + label: 'tokens', + }, + data.accounts24h > 0 && { + value: `+${formatNumber(data.accounts24h)}`, + label: 'accounts / 24h', + }, + ].filter(Boolean) as Array<{ value: string; label: string }> + + return ( +
+
+ {stats.map((stat, i) => ( + + {i > 0 &&
} + + + ))} +
+
+ ) +} + +function StatItem(props: { value: string; label: string }): React.JSX.Element { + return ( +
+ + {props.value} + + {props.label} +
+ ) +} diff --git a/apps/explorer/src/lib/constants.ts b/apps/explorer/src/lib/constants.ts index 00c59b8f..eaf41903 100644 --- a/apps/explorer/src/lib/constants.ts +++ b/apps/explorer/src/lib/constants.ts @@ -2,4 +2,4 @@ * Maximum number of rows to count in expensive count queries. * This prevents performance issues when counting very large datasets. */ -export const TOKEN_COUNT_MAX = 100_000 +export const TOKEN_COUNT_MAX = 1_000_000 diff --git a/apps/explorer/src/routeTree.gen.ts b/apps/explorer/src/routeTree.gen.ts index b11e86b7..95a566c4 100644 --- a/apps/explorer/src/routeTree.gen.ts +++ b/apps/explorer/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' +import { Route as ApiStatsRouteImport } from './routes/api/stats' import { Route as ApiSearchRouteImport } from './routes/api/search' import { Route as ApiHealthRouteImport } from './routes/api/health' import { Route as ApiCodeRouteImport } from './routes/api/code' @@ -46,6 +47,11 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({ path: '/', getParentRoute: () => LayoutRoute, } as any) +const ApiStatsRoute = ApiStatsRouteImport.update({ + id: '/api/stats', + path: '/api/stats', + getParentRoute: () => rootRouteImport, +} as any) const ApiSearchRoute = ApiSearchRouteImport.update({ id: '/api/search', path: '/api/search', @@ -184,6 +190,7 @@ export interface FileRoutesByFullPath { '/api/code': typeof ApiCodeRoute '/api/health': typeof ApiHealthRoute '/api/search': typeof ApiSearchRoute + '/api/stats': typeof ApiStatsRoute '/address/$address': typeof LayoutAddressAddressRoute '/block/$id': typeof LayoutBlockIdRoute '/demo/address': typeof LayoutDemoAddressRoute @@ -211,6 +218,7 @@ export interface FileRoutesByTo { '/api/code': typeof ApiCodeRoute '/api/health': typeof ApiHealthRoute '/api/search': typeof ApiSearchRoute + '/api/stats': typeof ApiStatsRoute '/': typeof LayoutIndexRoute '/address/$address': typeof LayoutAddressAddressRoute '/block/$id': typeof LayoutBlockIdRoute @@ -241,6 +249,7 @@ export interface FileRoutesById { '/api/code': typeof ApiCodeRoute '/api/health': typeof ApiHealthRoute '/api/search': typeof ApiSearchRoute + '/api/stats': typeof ApiStatsRoute '/_layout/': typeof LayoutIndexRoute '/_layout/address/$address': typeof LayoutAddressAddressRoute '/_layout/block/$id': typeof LayoutBlockIdRoute @@ -272,6 +281,7 @@ export interface FileRouteTypes { | '/api/code' | '/api/health' | '/api/search' + | '/api/stats' | '/address/$address' | '/block/$id' | '/demo/address' @@ -299,6 +309,7 @@ export interface FileRouteTypes { | '/api/code' | '/api/health' | '/api/search' + | '/api/stats' | '/' | '/address/$address' | '/block/$id' @@ -328,6 +339,7 @@ export interface FileRouteTypes { | '/api/code' | '/api/health' | '/api/search' + | '/api/stats' | '/_layout/' | '/_layout/address/$address' | '/_layout/block/$id' @@ -355,6 +367,7 @@ export interface RootRouteChildren { ApiCodeRoute: typeof ApiCodeRoute ApiHealthRoute: typeof ApiHealthRoute ApiSearchRoute: typeof ApiSearchRoute + ApiStatsRoute: typeof ApiStatsRoute ApiAbiBatchRoute: typeof ApiAbiBatchRoute ApiAddressAddressRoute: typeof ApiAddressAddressRoute ApiTokensCountRoute: typeof ApiTokensCountRoute @@ -381,6 +394,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutIndexRouteImport parentRoute: typeof LayoutRoute } + '/api/stats': { + id: '/api/stats' + path: '/api/stats' + fullPath: '/api/stats' + preLoaderRoute: typeof ApiStatsRouteImport + parentRoute: typeof rootRouteImport + } '/api/search': { id: '/api/search' path: '/api/search' @@ -603,6 +623,7 @@ const rootRouteChildren: RootRouteChildren = { ApiCodeRoute: ApiCodeRoute, ApiHealthRoute: ApiHealthRoute, ApiSearchRoute: ApiSearchRoute, + ApiStatsRoute: ApiStatsRoute, ApiAbiBatchRoute: ApiAbiBatchRoute, ApiAddressAddressRoute: ApiAddressAddressRoute, ApiTokensCountRoute: ApiTokensCountRoute, diff --git a/apps/explorer/src/routes/_layout/index.tsx b/apps/explorer/src/routes/_layout/index.tsx index 3726ad21..78ed8b8d 100644 --- a/apps/explorer/src/routes/_layout/index.tsx +++ b/apps/explorer/src/routes/_layout/index.tsx @@ -9,6 +9,7 @@ import { waapi, stagger } from 'animejs' import type { Address, Hex } from 'ox' import * as React from 'react' import { ExploreInput } from '#comps/ExploreInput' +import { NetworkStats } from '#comps/NetworkStats' import { cx } from '#lib/css' import { springInstant, springBouncy, springSmooth } from '#lib/animation' import { Intro, type IntroPhase, useIntroSeen } from '#comps/Intro' @@ -158,6 +159,7 @@ function Component() { /> + ) diff --git a/apps/explorer/src/routes/api/stats.ts b/apps/explorer/src/routes/api/stats.ts new file mode 100644 index 00000000..7ad20543 --- /dev/null +++ b/apps/explorer/src/routes/api/stats.ts @@ -0,0 +1,153 @@ +import { createFileRoute } from '@tanstack/react-router' +import * as IDX from 'idxs' +import { getChainId } from 'wagmi/actions' +import * as ABIS from '#lib/abis' +import { TOKEN_COUNT_MAX } from '#lib/constants' +import { getWagmiConfig } from '#wagmi.config.ts' + +const IS = IDX.IndexSupply.create({ + apiKey: process.env.INDEXER_API_KEY, +}) + +const QB = IDX.QueryBuilder.from(IS) + +// Average block time on Tempo is 0.5 seconds +const AVERAGE_BLOCK_TIME_SECONDS = 0.5 +const BLOCKS_PER_DAY = Math.floor((24 * 60 * 60) / AVERAGE_BLOCK_TIME_SECONDS) + +// Cache stats for 60 seconds to avoid rate limiting +const CACHE_TTL_MS = 60_000 +let cachedStats: StatsApiResponse['data'] | null = null +let cacheTimestamp = 0 +let fetchInProgress: Promise | null = null + +export type StatsApiResponse = { + data: { + transactions24h: number + tokens: number + accounts24h: number + } | null + error: string | null +} + +async function fetchStatsFromIndexer(): Promise { + const config = getWagmiConfig() + const chainId = getChainId(config) + const tokenCreatedSignature = ABIS.getTokenCreatedEvent(chainId) + + // Get latest block number to calculate 24h window + const latestBlockResult = await QB.selectFrom('blocks') + .select('num') + .where('chain', '=', chainId) + .orderBy('num', 'desc') + .limit(1) + .executeTakeFirst() + + const latestBlock = BigInt(latestBlockResult?.num ?? 0) + const block24hAgo = latestBlock - BigInt(BLOCKS_PER_DAY) + const block24hAgoSafe = block24hAgo < 0n ? 0n : block24hAgo + + // Get token count + const tokensCountResult = await QB.selectFrom( + QB.withSignatures([tokenCreatedSignature]) + .selectFrom('tokencreated') + .select((eb) => eb.lit(1).as('x')) + .where('chain', '=', chainId as never) + .limit(TOKEN_COUNT_MAX) + .as('subquery'), + ) + .select((eb) => eb.fn.count('x').as('count')) + .executeTakeFirst() + + // Get transaction count (24h) + const txCount24hResult = await QB.selectFrom('txs') + .select((eb) => eb.fn.count('hash').as('count')) + .where('chain', '=', chainId) + .where('block_num', '>=', block24hAgoSafe) + .executeTakeFirst() + + // Get active accounts (sample recent txs and count unique senders) + const accounts24hResult = await QB.selectFrom('txs') + .select(['from']) + .where('chain', '=', chainId) + .where('block_num', '>=', block24hAgoSafe) + .limit(50_000) + .execute() + + const uniqueAccounts = new Set(accounts24hResult?.map((tx) => tx.from) ?? []) + + return { + transactions24h: Number(txCount24hResult?.count ?? 0), + tokens: Number(tokensCountResult?.count ?? 0), + accounts24h: uniqueAccounts.size, + } +} + +export const Route = createFileRoute('/api/stats')({ + server: { + handlers: { + GET: async () => { + const now = Date.now() + const cacheAge = now - cacheTimestamp + const isCacheValid = cachedStats && cacheAge < CACHE_TTL_MS + + // Return cached data if still valid + if (isCacheValid) { + const remainingTtl = Math.max( + 1, + Math.floor((CACHE_TTL_MS - cacheAge) / 1000), + ) + return Response.json( + { data: cachedStats, error: null } satisfies StatsApiResponse, + { headers: { 'Cache-Control': `public, max-age=${remainingTtl}` } }, + ) + } + + // If a fetch is already in progress, wait for it (prevents thundering herd) + if (fetchInProgress) { + try { + const data = await fetchInProgress + return Response.json( + { data, error: null } satisfies StatsApiResponse, + { headers: { 'Cache-Control': 'public, max-age=60' } }, + ) + } catch { + // Fall through to try fresh fetch + } + } + + try { + fetchInProgress = fetchStatsFromIndexer() + const data = await fetchInProgress + + cachedStats = data + cacheTimestamp = now + fetchInProgress = null + + return Response.json( + { data, error: null } satisfies StatsApiResponse, + { headers: { 'Cache-Control': 'public, max-age=60' } }, + ) + } catch (error) { + fetchInProgress = null + console.error('[stats] Failed to fetch stats:', error) + + // Return stale cache if available (stale-while-error pattern) + if (cachedStats) { + return Response.json( + { data: cachedStats, error: null } satisfies StatsApiResponse, + { headers: { 'Cache-Control': 'public, max-age=10' } }, + ) + } + + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + return Response.json( + { data: null, error: errorMessage } satisfies StatsApiResponse, + { status: 500 }, + ) + } + }, + }, + }, +})