diff --git a/apps/explorer/src/comps/ContractSource.tsx b/apps/explorer/src/comps/ContractSource.tsx index 401d325ae..b55449d08 100644 --- a/apps/explorer/src/comps/ContractSource.tsx +++ b/apps/explorer/src/comps/ContractSource.tsx @@ -10,12 +10,10 @@ import SolidityIcon from '~icons/vscode-icons/file-type-solidity' import VyperIcon from '~icons/vscode-icons/file-type-vyper' function getCompilerVersionUrl(compiler: string, version: string) { - const isVyper = compiler.toLowerCase() === "vyper" - const repo = isVyper ? "vyperlang/vyper" : "argotorg/solidity" + const isVyper = compiler.toLowerCase() === 'vyper' + const repo = isVyper ? 'vyperlang/vyper' : 'argotorg/solidity' - const tag = isVyper - ? version.trim() - : version.trim().split("+commit.", 1)[0] + const tag = isVyper ? version.trim() : version.trim().split('+commit.', 1)[0] return `https://github.com/${repo}/releases/tag/v${tag}` } diff --git a/apps/explorer/src/comps/Dashboard.tsx b/apps/explorer/src/comps/Dashboard.tsx new file mode 100644 index 000000000..f7b46bf18 --- /dev/null +++ b/apps/explorer/src/comps/Dashboard.tsx @@ -0,0 +1,469 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { waapi, stagger } from 'animejs' +import type * as React from 'react' +import { useEffect, useRef } from 'react' +import { Address } from '#comps/Address' +import { Midcut } from '#comps/Midcut' +import { RelativeTime } from '#comps/RelativeTime' +import { cx } from '#lib/css' +import { springSmooth } from '#lib/animation' +import { + dashboardQueryOptions, + networkStatsQueryOptions, + tokensListQueryOptions, + validatorsQueryOptions, + type DashboardBlock, + type DashboardTransaction, +} from '#lib/queries' +import type { Token } from '#lib/server/tokens.server' +import BoxIcon from '~icons/lucide/box' +import ArrowRightIcon from '~icons/lucide/arrow-right' +import ClockIcon from '~icons/lucide/clock' +import CoinsIcon from '~icons/lucide/coins' +import ShieldCheckIcon from '~icons/lucide/shield-check' +import ZapIcon from '~icons/lucide/zap' +import ActivityIcon from '~icons/lucide/activity' + +export function Dashboard(props: Dashboard.Props): React.JSX.Element | null { + const { visible } = props + const containerRef = useRef(null) + + const { data, isLoading } = useQuery({ + ...dashboardQueryOptions(), + enabled: visible, + }) + + const { data: stats, isLoading: statsLoading } = useQuery({ + ...networkStatsQueryOptions(), + enabled: visible, + }) + + const { data: tokensData, isLoading: tokensLoading } = useQuery({ + ...tokensListQueryOptions({ page: 1, limit: 5 }), + enabled: visible, + }) + + const { data: validators, isLoading: validatorsLoading } = useQuery({ + ...validatorsQueryOptions(), + enabled: visible, + }) + + const avgBlockTime = data?.statsBlocks + ? calculateAvgBlockTime(data.statsBlocks) + : null + const tps = data?.statsBlocks ? calculateTPS(data.statsBlocks) : null + + useEffect(() => { + if (!visible || !containerRef.current) return + const children = [...containerRef.current.children] + waapi.animate(children as HTMLElement[], { + opacity: [0, 1], + translateY: [12, 0], + ease: springSmooth, + delay: stagger(40, { start: 100 }), + }) + }, [visible]) + + if (!visible) return null + + return ( +
+
+ } + loading={statsLoading} + tooltip="Total transactions on the network" + /> + + + v.active).length} + icon={} + loading={validatorsLoading} + href="/validators" + tooltip="Active network validators" + /> +
+ } + loading={isLoading} + > + {data?.transactions.map((tx) => ( + + ))} + +
+ } + viewAllLink="/blocks" + loading={isLoading} + > + {data?.blocks.map((block) => ( + + ))} + + } + viewAllLink="/tokens" + loading={tokensLoading} + > + {tokensData?.tokens.map((token) => ( + + ))} + +
+
+ ) +} + +export declare namespace Dashboard { + type Props = { + visible: boolean + } +} + +function Tooltip(props: { + text: string + children: React.ReactNode +}): React.JSX.Element { + return ( +
+ {props.children} +
+ {props.text} +
+
+ ) +} + +type StatCardProps = { + title: string + value: number | undefined + subtitle?: number | undefined + subtitleLabel?: string + icon: React.ReactNode + loading?: boolean + href?: string + tooltip?: string +} + +function formatNumber(num: number): string { + 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() +} + +function StatCard(props: StatCardProps): React.JSX.Element { + const { title, value, subtitle, icon, loading, href, tooltip } = props + + const content = ( +
+ {icon} + {title} + {loading ? ( +
+ ) : ( + + {value !== undefined ? formatNumber(value) : '—'} + {subtitle !== undefined && subtitle > 0 && ( + + +{formatNumber(subtitle)} + + )} + + )} + {href && } +
+ ) + + const pillClass = + 'bg-surface border border-base-border rounded-full px-4 py-2 hover:border-accent transition-colors' + + if (href) { + return ( + + {tooltip ? {content} : content} + + ) + } + + return ( +
+ {tooltip ? {content} : content} +
+ ) +} + +function Skeleton(): React.JSX.Element { + const items = [1, 2, 3, 4, 5] + return ( + <> + {items.map((n) => ( +
+
+
+
+
+
+
+ ))} + + ) +} + +type CardProps = { + title: string + icon: React.ReactNode + viewAllLink?: string + loading?: boolean + children: React.ReactNode +} + +function Card(props: CardProps): React.JSX.Element { + const { title, icon, viewAllLink, loading, children } = props + return ( +
+
+
+ {icon} + {title} +
+ {viewAllLink && ( + + View all + + + )} +
+
+ {loading ? : children} +
+
+ ) +} + +function BlockRow(props: { block: DashboardBlock }): React.JSX.Element { + const { block } = props + const blockNumber = block.number?.toString() ?? '0' + const txCount = Array.isArray(block.transactions) + ? block.transactions.length + : 0 + + return ( + +
+
+ +
+
+ + #{blockNumber} + + + {txCount} transaction{txCount !== 1 ? 's' : ''} + +
+
+
+ {block.hash && ( + + + + )} + {block.timestamp && ( + + + + )} +
+ + ) +} + +function TransactionRow(props: { + transaction: DashboardTransaction +}): React.JSX.Element { + const { transaction } = props + + return ( + +
+
+ +
+
+ + + +
+ From +
+ {transaction.to && ( + <> + +
+ + )} +
+
+
+
+ + Block #{transaction.blockNumber.toString()} + + {transaction.timestamp && ( + + + + )} +
+ + ) +} + +function calculateAvgBlockTime(blocks: DashboardBlock[]): number | null { + if (blocks.length < 2) return null + const timestamps = blocks.map((b) => Number(b.timestamp)) + let totalDiff = 0 + for (let i = 0; i < timestamps.length - 1; i++) { + totalDiff += timestamps[i] - timestamps[i + 1] + } + return totalDiff / (timestamps.length - 1) +} + +function calculateTPS(blocks: DashboardBlock[]): number | null { + if (blocks.length < 2) return null + const timestamps = blocks.map((b) => Number(b.timestamp)) + const timeSpan = timestamps[0] - timestamps[timestamps.length - 1] + if (timeSpan <= 0) return null + const totalTxs = blocks.reduce((sum, b) => { + const txCount = Array.isArray(b.transactions) ? b.transactions.length : 0 + return sum + txCount + }, 0) + return totalTxs / timeSpan +} + +function AvgBlockTimeCard(props: { + avgBlockTime: number | null + loading: boolean +}): React.JSX.Element { + const { avgBlockTime, loading } = props + + const content = ( +
+ + + + Block Time + {loading ? ( +
+ ) : ( + + {avgBlockTime !== null ? `${avgBlockTime.toFixed(1)}s` : '—'} + + )} +
+ ) + + return ( +
+ + {content} + +
+ ) +} + +function TPSCard(props: { + tps: number | null + loading: boolean +}): React.JSX.Element { + const { tps, loading } = props + + const content = ( +
+ + + + TPS + {loading ? ( +
+ ) : ( + + {tps !== null ? tps.toFixed(2) : '—'} + + )} +
+ ) + + return ( +
+ + {content} + +
+ ) +} + +function TokenRow(props: { token: Token }): React.JSX.Element { + const { token } = props + + return ( + +
+
+ +
+
+ {token.symbol} + + {token.name} + +
+
+ + + ) +} + + diff --git a/apps/explorer/src/lib/queries/.dashboard.ts.swp b/apps/explorer/src/lib/queries/.dashboard.ts.swp new file mode 100644 index 000000000..3c757ae42 Binary files /dev/null and b/apps/explorer/src/lib/queries/.dashboard.ts.swp differ diff --git a/apps/explorer/src/lib/queries/dashboard.ts b/apps/explorer/src/lib/queries/dashboard.ts new file mode 100644 index 000000000..12ec4e678 --- /dev/null +++ b/apps/explorer/src/lib/queries/dashboard.ts @@ -0,0 +1,129 @@ +import { queryOptions } from '@tanstack/react-query' +import type { Block } from 'viem' +import { getBlock } from 'wagmi/actions' +import { getWagmiConfig } from '#wagmi.config.ts' + +export const DASHBOARD_BLOCKS_DISPLAY = 5 +export const DASHBOARD_BLOCKS_STATS = 50 +export const DASHBOARD_TRANSACTIONS_COUNT = 5 + +export type NetworkStats = { + totalTransactions: number + transactions24h: number + totalAccounts: number + accounts24h: number +} + +export function networkStatsQueryOptions() { + return queryOptions({ + queryKey: ['network-stats'], + queryFn: async (): Promise => { + const response = await fetch(`${__BASE_URL__}/api/stats`) + const json = (await response.json()) as { + data: NetworkStats | null + error: string | null + } + if (json.error || !json.data) { + return { + totalTransactions: 0, + transactions24h: 0, + totalAccounts: 0, + accounts24h: 0, + } + } + return json.data + }, + staleTime: 30_000, + refetchInterval: 60_000, + }) +} + +export type DashboardBlock = Pick< + Block, + 'number' | 'hash' | 'timestamp' | 'transactions' +> + +export type DashboardTransaction = { + hash: `0x${string}` + from: `0x${string}` + to: `0x${string}` | null + blockNumber: bigint + timestamp: bigint +} + +export function dashboardQueryOptions() { + return queryOptions({ + queryKey: ['dashboard'], + queryFn: async () => { + const config = getWagmiConfig() + const latestBlock = await getBlock(config, { includeTransactions: true }) + const latestBlockNumber = latestBlock.number + + const blockNumbers: bigint[] = [] + for (let i = 0n; i < BigInt(DASHBOARD_BLOCKS_STATS); i++) { + const blockNum = latestBlockNumber - i + if (blockNum >= 0n) blockNumbers.push(blockNum) + } + + const blocks = await Promise.all( + blockNumbers.map((blockNumber) => + getBlock(config, { blockNumber, includeTransactions: true }).catch( + () => null, + ), + ), + ) + + const validBlocks = blocks.filter(Boolean) as Block[] + + const displayBlocks = validBlocks.slice(0, DASHBOARD_BLOCKS_DISPLAY) + const recentBlocks: DashboardBlock[] = displayBlocks.map((block) => ({ + number: block.number, + hash: block.hash, + timestamp: block.timestamp, + transactions: block.transactions.map((tx) => + typeof tx === 'string' ? tx : tx.hash, + ), + })) + + const allTransactions: DashboardTransaction[] = [] + for (const block of displayBlocks) { + if (block.number === null) continue + for (const tx of block.transactions) { + if (typeof tx === 'string') continue + allTransactions.push({ + hash: tx.hash, + from: tx.from, + to: tx.to, + blockNumber: block.number, + timestamp: block.timestamp, + }) + if (allTransactions.length >= DASHBOARD_TRANSACTIONS_COUNT) break + } + if (allTransactions.length >= DASHBOARD_TRANSACTIONS_COUNT) break + } + + return { + latestBlockNumber, + blocks: recentBlocks, + transactions: allTransactions, + statsBlocks: validBlocks.map((block) => ({ + number: block.number, + hash: block.hash, + timestamp: block.timestamp, + transactions: block.transactions.map((tx) => + typeof tx === 'string' ? tx : tx.hash, + ), + })), + } + }, + staleTime: 5_000, + refetchInterval: 10_000, + }) +} + +export type DashboardData = { + latestBlockNumber: bigint + blocks: DashboardBlock[] + transactions: DashboardTransaction[] + statsBlocks: DashboardBlock[] +} diff --git a/apps/explorer/src/lib/queries/index.ts b/apps/explorer/src/lib/queries/index.ts index 64813ad66..e94c8c643 100644 --- a/apps/explorer/src/lib/queries/index.ts +++ b/apps/explorer/src/lib/queries/index.ts @@ -2,6 +2,7 @@ export * from './abi' export * from './account' export * from './balance-changes' export * from './blocks' +export * from './dashboard' export * from './tokens' export * from './trace' export * from './tx' diff --git a/apps/explorer/src/routeTree.gen.ts b/apps/explorer/src/routeTree.gen.ts index 75c2a7453..0651131ed 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', @@ -183,6 +189,7 @@ export interface FileRoutesByFullPath { '/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 @@ -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 @@ -271,6 +280,7 @@ export interface FileRouteTypes { | '/api/code' | '/api/health' | '/api/search' + | '/api/stats' | '/' | '/address/$address' | '/block/$id' @@ -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 2476568d4..45093b366 100644 --- a/apps/explorer/src/routes/_layout/index.tsx +++ b/apps/explorer/src/routes/_layout/index.tsx @@ -1,76 +1,15 @@ import { createFileRoute, - Link, useNavigate, useRouter, useRouterState, } from '@tanstack/react-router' -import { waapi, stagger } from 'animejs' -import type { Address, Hex } from 'ox' +import { waapi } from 'animejs' import * as React from 'react' +import { Dashboard } from '#comps/Dashboard' import { ExploreInput } from '#comps/ExploreInput' -import { cx } from '#lib/css' -import { springInstant, springBouncy, springSmooth } from '#lib/animation' +import { springInstant, springBouncy } from '#lib/animation' import { Intro, type IntroPhase, useIntroSeen } from '#comps/Intro' -import BoxIcon from '~icons/lucide/box' -import ChevronDownIcon from '~icons/lucide/chevron-down' -import CoinsIcon from '~icons/lucide/coins' -import FileIcon from '~icons/lucide/file' -import ReceiptIcon from '~icons/lucide/receipt' -import ShieldCheckIcon from '~icons/lucide/shield-check' -import ShuffleIcon from '~icons/lucide/shuffle' -import UserIcon from '~icons/lucide/user' -import ZapIcon from '~icons/lucide/zap' - -const SPOTLIGHT_DATA: Record< - string, - { - accountAddress: Address.Address - contractAddress: Address.Address - receiptHash: Hex.Hex | null - paymentHash: Hex.Hex | null - swapHash: Hex.Hex | null - mintHash: Hex.Hex | null - } -> = { - testnet: { - accountAddress: '0x5bc1473610754a5ca10749552b119df90c1a1877', - contractAddress: '0x9b400b4c962463E840cCdbE2493Dc6Ab78768266', - receiptHash: - '0x6d6d8c102064e6dee44abad2024a8b1d37959230baab80e70efbf9b0c739c4fd', - paymentHash: - '0x33cdfc39dcda535aac88e7fe3a79954e0740ec26a2fe54eb5481a4cfc0cb8024', - swapHash: - '0x8b6cdb1f6193c17a3733aec315441ab92bca3078b462b27863509a279a5ea6e0', - mintHash: - '0xe5c909ef42674965a8b805118f08b58f215a98661838ae187737841531097b70', - }, - moderato: { - accountAddress: '0xa726a1CD723409074DF9108A2187cfA19899aCF8', - contractAddress: '0x52db6B29F1032b55F1C28354055539b1931CB26e', - receiptHash: - '0x429eb0d8a4565138aec97fe11c8f2f4e56f26725e3a428881bbeba6c4e8ecdc9', - paymentHash: - '0x429eb0d8a4565138aec97fe11c8f2f4e56f26725e3a428881bbeba6c4e8ecdc9', - swapHash: - '0xc61b40cfc6714a893e3d758f2db3e19cd54f175369e17c48591654b294332cf9', - mintHash: - '0x58fcdd78477f7ee402320984e990e7a1623d80b768afb03f9b27fd2eac395032', - }, - presto: { - accountAddress: '0x85269497F0b602a718b85DB5ce490A6c88d01c0E', - contractAddress: '0x4027a3f47d9a421c381bf5d88e22dad5afd4b1a2', - receiptHash: - '0x009293cd8195b5f088729e8839c3ccb7e9d10d98798a1cb11c06e62d77d1dbef', - paymentHash: - '0xc13dbcf74b99ddc7645b1752a031b4bd479e4514b77569e1a4e69ecbf88e7290', - swapHash: null, - mintHash: - '0x3dcdfda1c8689a0fab003174e7a0bc3c5df8c325e566df42cae8fe1a41ac48fb', - }, -} - -const spotlightData = SPOTLIGHT_DATA[import.meta.env.VITE_TEMPO_ENV] export const Route = createFileRoute('/_layout/')({ component: Component, @@ -84,6 +23,7 @@ function Component() { const [inputValue, setInputValue] = React.useState('') const [isMounted, setIsMounted] = React.useState(false) const [inputReady, setInputReady] = React.useState(false) + const [dashboardVisible, setDashboardVisible] = React.useState(false) const exploreInputRef = React.useRef(null) const exploreWrapperRef = React.useRef(null) const isNavigating = useRouterState({ @@ -105,6 +45,7 @@ function Component() { setTimeout( () => { setInputReady(true) + setDashboardVisible(true) if (exploreWrapperRef.current) { exploreWrapperRef.current.style.pointerEvents = 'auto' waapi.animate(exploreWrapperRef.current, { @@ -120,8 +61,8 @@ function Component() { }, []) return ( -
-
+
+
-
+
) } - -function SpotlightLinks() { - const introSeen = useIntroSeen() - const [actionOpen, setActionOpen] = React.useState(false) - const [menuMounted, setMenuMounted] = React.useState(false) - const dropdownRef = React.useRef(null) - const dropdownMenuRef = React.useRef(null) - const hoverTimeoutRef = React.useRef | null>( - null, - ) - const closingRef = React.useRef(false) - const pillsRef = React.useRef(null) - const introSeenOnMount = React.useRef(introSeen) - - const closeMenu = React.useCallback(() => { - setActionOpen(false) - if (dropdownMenuRef.current) { - closingRef.current = true - waapi - .animate(dropdownMenuRef.current, { - opacity: [1, 0], - scale: [1, 0.97], - translateY: [0, -4], - ease: springInstant, - }) - .then(() => { - if (!closingRef.current) return - setMenuMounted(false) - }) - } else { - setMenuMounted(false) - } - }, []) - - React.useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - closeMenu() - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [closeMenu]) - - React.useEffect(() => { - if (!pillsRef.current) return - const seen = introSeenOnMount.current - const children = [...pillsRef.current.children] - const delay = seen ? 0 : 320 - setTimeout(() => { - for (const child of children) { - ;(child as HTMLElement).style.pointerEvents = 'auto' - } - }, delay) - const anim = waapi.animate(children as HTMLElement[], { - opacity: [0, 1], - translateY: [seen ? 4 : 8, 0], - scale: [0.97, 1], - ease: seen ? springInstant : springSmooth, - delay: seen ? stagger(10) : stagger(20, { start: delay, from: 'first' }), - }) - anim.then(() => { - for (const child of children) { - ;(child as HTMLElement).style.transform = '' - } - }) - return () => { - try { - anim.cancel() - } catch {} - } - }, []) - - React.useEffect(() => { - if (actionOpen) setMenuMounted(true) - }, [actionOpen]) - - React.useLayoutEffect(() => { - if (!dropdownMenuRef.current) return - if (actionOpen && menuMounted) { - waapi.animate(dropdownMenuRef.current, { - opacity: [0, 1], - scale: [0.97, 1], - translateY: [-4, 0], - ease: springInstant, - }) - } - }, [actionOpen, menuMounted]) - - const handleMouseEnter = () => { - if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current) - if (closingRef.current && dropdownMenuRef.current) { - closingRef.current = false - waapi.animate(dropdownMenuRef.current, { - opacity: 1, - scale: 1, - ease: springInstant, - }) - } - setActionOpen(true) - } - - const handleMouseLeave = () => { - hoverTimeoutRef.current = setTimeout(() => closeMenu(), 150) - } - - const actionTypes = spotlightData - ? [ - { label: 'Payment', hash: spotlightData.paymentHash }, - { label: 'Swap', hash: spotlightData.swapHash }, - { label: 'Mint', hash: spotlightData.mintHash }, - ].filter((a): a is { label: string; hash: Hex.Hex } => a.hash !== null) - : [] - - return ( -
-
- {spotlightData && ( - <> - } - badge={} - > - Account - - } - badge={} - > - Contract - - {spotlightData.receiptHash && ( - } - > - Receipt - - )} - {/** biome-ignore lint/a11y/noStaticElementInteractions: _ */} -
- - {menuMounted && ( -
-
- {actionTypes.map((action, i) => ( - - {action.label} - - ))} -
-
- )} -
- - )} - } - > - Blocks - - } - > - Tokens - - } - > - Validators - -
-
- ) -} - -function SpotlightPill(props: { - className?: string - to: string - params?: Record - search?: Record - icon: React.ReactNode - badge?: React.ReactNode - children: React.ReactNode -}) { - const { className, to, params, search, icon, badge, children } = props - return ( - - {icon} - {children} - {badge && ( - - {badge} - - )} - - ) -} diff --git a/apps/explorer/src/routes/api/stats.ts b/apps/explorer/src/routes/api/stats.ts new file mode 100644 index 000000000..a78a4ceb8 --- /dev/null +++ b/apps/explorer/src/routes/api/stats.ts @@ -0,0 +1,106 @@ +import { createFileRoute } from '@tanstack/react-router' +import * as IDX from 'idxs' +import { getBlock } from 'wagmi/actions' +import { getChainId } from 'wagmi/actions' +import { hasIndexSupply } from '#lib/env' +import { getWagmiConfig } from '#wagmi.config' + +const IS = IDX.IndexSupply.create({ + apiKey: process.env.INDEXER_API_KEY, +}) + +const QB = IDX.QueryBuilder.from(IS) + +const SECONDS_IN_24H = 24 * 60 * 60 +const BLOCKS_TO_CHECK_24H = 500 + +export const Route = createFileRoute('/api/stats')({ + server: { + handlers: { + GET: async () => { + const config = getWagmiConfig() + const chainId = getChainId(config) + + let totalTransactions = 0 + let transactions24h = 0 + let totalAccounts = 0 + let accounts24h = 0 + + if (hasIndexSupply()) { + try { + const totalTxResult = await QB.selectFrom('txs') + .select((eb) => eb.fn.count('hash').as('cnt')) + .where('chain', '=', chainId) + .executeTakeFirst() + + totalTransactions = Number(totalTxResult?.cnt ?? 0) + } catch (error) { + console.error('Failed to fetch total transactions:', error) + } + + try { + const uniqueFromResult = await QB.selectFrom('txs') + .select('from') + .distinct() + .where('chain', '=', chainId) + .limit(100_000) + .execute() + + totalAccounts = uniqueFromResult.length + } catch (error) { + console.error('Failed to fetch total accounts:', error) + } + } + + try { + const latestBlock = await getBlock(config) + const now = Math.floor(Date.now() / 1000) + const timestamp24hAgo = now - SECONDS_IN_24H + + const blockNumbers: bigint[] = [] + for (let i = 0n; i < BigInt(BLOCKS_TO_CHECK_24H); i++) { + const blockNum = latestBlock.number - i + if (blockNum >= 0n) blockNumbers.push(blockNum) + } + + const blocks = await Promise.all( + blockNumbers.map((blockNumber) => + getBlock(config, { + blockNumber, + includeTransactions: true, + }).catch(() => null), + ), + ) + + const uniqueAccountsIn24h = new Set() + let txCount24h = 0 + + for (const block of blocks) { + if (!block || Number(block.timestamp) < timestamp24hAgo) continue + txCount24h += block.transactions.length + for (const tx of block.transactions) { + if (typeof tx !== 'string') { + uniqueAccountsIn24h.add(tx.from.toLowerCase()) + } + } + } + + transactions24h = txCount24h + accounts24h = uniqueAccountsIn24h.size + } catch (error) { + console.error('Failed to fetch 24h stats:', error) + } + + return Response.json({ + data: { + totalTransactions, + transactions24h, + totalAccounts, + accounts24h, + }, + error: null, + }) + }, + }, + }, +}) diff --git a/apps/og/src/index.tsx b/apps/og/src/index.tsx index 958811144..88188e329 100644 --- a/apps/og/src/index.tsx +++ b/apps/og/src/index.tsx @@ -170,9 +170,7 @@ app.get( const [fonts, images, tokenIcon] = await Promise.all([ loadFonts(), loadImages(context.env), - tokenParams.chainId - ? fetchTokenIcon(address, tokenParams.chainId) - : null, + tokenParams.chainId ? fetchTokenIcon(address, tokenParams.chainId) : null, ]) const imageResponse = new ImageResponse( diff --git a/apps/tokenlist/env.d.ts b/apps/tokenlist/env.d.ts index 700bd10e5..70d1a1587 100644 --- a/apps/tokenlist/env.d.ts +++ b/apps/tokenlist/env.d.ts @@ -30,5 +30,4 @@ interface ImportMeta { readonly env: ImportMetaEnv } - declare const __BUILD_VERSION__: string diff --git a/apps/tokenlist/src/docs.tsx b/apps/tokenlist/src/docs.tsx index 8571d7ae8..8047144fd 100644 --- a/apps/tokenlist/src/docs.tsx +++ b/apps/tokenlist/src/docs.tsx @@ -4,9 +4,7 @@ import { html, raw } from 'hono/html' const scalarConfig = { slug: 'tokenlist', hideModels: true, - sources: [ - { url: '/schema/openapi.json', default: true }, - ], + sources: [{ url: '/schema/openapi.json', default: true }], hideClientButton: true, url: '/schema/openapi.json', showDeveloperTools: 'never',