Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions apps/explorer/src/comps/NetworkStats.tsx
Original file line number Diff line number Diff line change
@@ -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<StatsApiResponse['data']> {
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 (
<section className="text-center px-4 pt-4">
<div className="flex items-center justify-center gap-5 text-[13px]">
{stats.map((stat, i) => (
<React.Fragment key={stat.label}>
{i > 0 && <div className="h-8 w-px bg-base-border opacity-50" />}
<StatItem value={stat.value} label={stat.label} />
</React.Fragment>
))}
</div>
</section>
)
}

function StatItem(props: { value: string; label: string }): React.JSX.Element {
return (
<div className="flex flex-col items-center gap-0.5">
<span className="text-[15px] text-primary font-medium tabular-nums">
{props.value}
</span>
<span className="text-tertiary">{props.label}</span>
</div>
)
}
2 changes: 1 addition & 1 deletion apps/explorer/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 21 additions & 0 deletions apps/explorer/src/routeTree.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/explorer/src/routes/_layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -158,6 +159,7 @@ function Component() {
/>
</div>
<SpotlightLinks />
<NetworkStats />
</div>
</div>
)
Expand Down
153 changes: 153 additions & 0 deletions apps/explorer/src/routes/api/stats.ts
Original file line number Diff line number Diff line change
@@ -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<StatsApiResponse['data']> | null = null

export type StatsApiResponse = {
data: {
transactions24h: number
tokens: number
accounts24h: number
} | null
error: string | null
}

async function fetchStatsFromIndexer(): Promise<StatsApiResponse['data']> {
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 },
)
}
},
},
},
})
Loading