From e0f04a7819db615706fe743ed422829aab44bb2d Mon Sep 17 00:00:00 2001 From: satyakwok <119509589+satyakwok@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:45:59 +0200 Subject: [PATCH] fix(scan): repoint contract leaderboard to real indexer data The contract leaderboard's "by calls" / "by gas" tabs crashed on open: they fetched /contracts/stats expecting calls + gas_used per contract, but the indexer never tracks those aggregates (the native block view carries no receipt, so contract_address is never populated). The missing fields hit formatNumber(undefined).toLocaleString() and took down the whole render via the error boundary. Repoint the two tabs to the orderings the indexer actually serves: - Recently Deployed -> /contracts/recent (newest first) - Pioneers -> /contracts/pioneers (earliest first) Both render rank, address, first/last seen block, and code hash through a shared ContractRanking component. The old /calls and /gas paths stay as redirects so existing links don't 404. Drops the now-unused ContractStat type, fetchContractStats, and useContractStats. --- .../leaderboard/contract/calls/page.tsx | 105 ++--------------- .../leaderboard/contract/gas/page.tsx | 104 ++--------------- .../[locale]/leaderboard/contract/layout.tsx | 4 +- .../[locale]/leaderboard/contract/page.tsx | 2 +- .../leaderboard/contract/pioneers/page.tsx | 5 + .../leaderboard/contract/recent/page.tsx | 5 + apps/scan/components/layout/header.tsx | 2 +- .../leaderboard/ContractRanking.tsx | 108 ++++++++++++++++++ apps/scan/lib/api.ts | 40 +++---- apps/scan/lib/hooks.ts | 22 ++-- 10 files changed, 173 insertions(+), 224 deletions(-) create mode 100644 apps/scan/app/[locale]/leaderboard/contract/pioneers/page.tsx create mode 100644 apps/scan/app/[locale]/leaderboard/contract/recent/page.tsx create mode 100644 apps/scan/components/leaderboard/ContractRanking.tsx diff --git a/apps/scan/app/[locale]/leaderboard/contract/calls/page.tsx b/apps/scan/app/[locale]/leaderboard/contract/calls/page.tsx index 295b2e8..9d6298f 100644 --- a/apps/scan/app/[locale]/leaderboard/contract/calls/page.tsx +++ b/apps/scan/app/[locale]/leaderboard/contract/calls/page.tsx @@ -1,94 +1,13 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { FileCode } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Address } from "@/components/common/Address"; -import { Pagination } from "@/components/common/Pagination"; -import { RankBadge } from "@/components/common/RankBadge"; -import { useNetwork, useNetworkFromQuery } from "@/lib/network-context"; -import { useContractStats } from "@/lib/hooks"; -import { formatNumber } from "@/lib/format"; - -const PAGE_SIZE = 25; - -export default function TopContractsByCallsPage() { - const { network } = useNetwork(); - useNetworkFromQuery(); - const { data, loading } = useContractStats(network, "calls", 100); - const [page, setPage] = useState(1); - - const totalPages = Math.max(1, Math.ceil((data?.length ?? 0) / PAGE_SIZE)); - const paged = useMemo( - () => (data ?? []).slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), - [data, page], - ); - - return ( - - - - Contracts ranked by lifetime call count - - - - {loading ? ( -
- {Array.from({ length: 10 }).map((_, i) => ( - - ))} -
- ) : (data?.length ?? 0) === 0 ? ( -
- -

No contract activity indexed yet

-

- Counts appear as the indexer ingests EVM tx with a contract receiver. -

-
- ) : ( - <> -
- - - - - - - - - - - {paged.map((r) => ( - - - - - - - ))} - -
RankContractCalls - Gas used -
- - -
-
- {formatNumber(r.calls)} - - {formatNumber(r.gas_used)} -
-
- {(data?.length ?? 0) > PAGE_SIZE && ( -
- -
- )} - - )} -
-
- ); +import { redirect } from "@/i18n/navigation"; + +// `calls`/`gas` were the old "rank by call count / gas" tabs, but the indexer +// never tracked those aggregates — the pages crashed on the missing fields. +// Kept as a redirect so old links/bookmarks land on the contracts list. +export default async function ContractCallsRedirect({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + redirect({ href: "/leaderboard/contract/recent", locale }); } diff --git a/apps/scan/app/[locale]/leaderboard/contract/gas/page.tsx b/apps/scan/app/[locale]/leaderboard/contract/gas/page.tsx index 1e0af11..32117c4 100644 --- a/apps/scan/app/[locale]/leaderboard/contract/gas/page.tsx +++ b/apps/scan/app/[locale]/leaderboard/contract/gas/page.tsx @@ -1,94 +1,12 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { Flame } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Address } from "@/components/common/Address"; -import { Pagination } from "@/components/common/Pagination"; -import { RankBadge } from "@/components/common/RankBadge"; -import { useNetwork, useNetworkFromQuery } from "@/lib/network-context"; -import { useContractStats } from "@/lib/hooks"; -import { formatNumber } from "@/lib/format"; - -const PAGE_SIZE = 25; - -export default function TopContractsByGasPage() { - const { network } = useNetwork(); - useNetworkFromQuery(); - const { data, loading } = useContractStats(network, "gas_used", 100); - const [page, setPage] = useState(1); - - const totalPages = Math.max(1, Math.ceil((data?.length ?? 0) / PAGE_SIZE)); - const paged = useMemo( - () => (data ?? []).slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), - [data, page], - ); - - return ( - - - - Contracts ranked by total gas consumed - - - - {loading ? ( -
- {Array.from({ length: 10 }).map((_, i) => ( - - ))} -
- ) : (data?.length ?? 0) === 0 ? ( -
- -

No contract gas activity yet

-

- Numbers populate as the indexer ingests EVM contract calls. -

-
- ) : ( - <> -
- - - - - - - - - - - {paged.map((r) => ( - - - - - - - ))} - -
RankContractGas used - Calls -
- - -
-
- {formatNumber(r.gas_used)} - - {formatNumber(r.calls)} -
-
- {(data?.length ?? 0) > PAGE_SIZE && ( -
- -
- )} - - )} -
-
- ); +import { redirect } from "@/i18n/navigation"; + +// See calls/page.tsx — the old gas-ranked tab had no backing data. Redirect +// to the pioneers ordering so old links/bookmarks still resolve. +export default async function ContractGasRedirect({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + redirect({ href: "/leaderboard/contract/pioneers", locale }); } diff --git a/apps/scan/app/[locale]/leaderboard/contract/layout.tsx b/apps/scan/app/[locale]/leaderboard/contract/layout.tsx index afbecf5..b174e3b 100644 --- a/apps/scan/app/[locale]/leaderboard/contract/layout.tsx +++ b/apps/scan/app/[locale]/leaderboard/contract/layout.tsx @@ -6,8 +6,8 @@ export default function ContractLeaderboardLayout({ children }: { children: Reac
{children} diff --git a/apps/scan/app/[locale]/leaderboard/contract/page.tsx b/apps/scan/app/[locale]/leaderboard/contract/page.tsx index 94b7257..82a8ea8 100644 --- a/apps/scan/app/[locale]/leaderboard/contract/page.tsx +++ b/apps/scan/app/[locale]/leaderboard/contract/page.tsx @@ -6,5 +6,5 @@ export default async function ContractIndex({ params: Promise<{ locale: string }>; }) { const { locale } = await params; - redirect({ href: "/leaderboard/contract/calls", locale }); + redirect({ href: "/leaderboard/contract/recent", locale }); } diff --git a/apps/scan/app/[locale]/leaderboard/contract/pioneers/page.tsx b/apps/scan/app/[locale]/leaderboard/contract/pioneers/page.tsx new file mode 100644 index 0000000..98f2f34 --- /dev/null +++ b/apps/scan/app/[locale]/leaderboard/contract/pioneers/page.tsx @@ -0,0 +1,5 @@ +import { ContractRanking } from "@/components/leaderboard/ContractRanking"; + +export default function PioneerContractsPage() { + return ; +} diff --git a/apps/scan/app/[locale]/leaderboard/contract/recent/page.tsx b/apps/scan/app/[locale]/leaderboard/contract/recent/page.tsx new file mode 100644 index 0000000..86a727f --- /dev/null +++ b/apps/scan/app/[locale]/leaderboard/contract/recent/page.tsx @@ -0,0 +1,5 @@ +import { ContractRanking } from "@/components/leaderboard/ContractRanking"; + +export default function RecentContractsPage() { + return ; +} diff --git a/apps/scan/components/layout/header.tsx b/apps/scan/components/layout/header.tsx index a1b0e0f..2b59b3f 100644 --- a/apps/scan/components/layout/header.tsx +++ b/apps/scan/components/layout/header.tsx @@ -224,7 +224,7 @@ export function Header() { { href: "/leaderboard/account/holders", label: "Account", icon: Users, color: "text-[var(--cyan)]" }, { href: "/leaderboard/token/holders", label: "Token", icon: Coins, color: "text-[var(--gold)]" }, { href: "/leaderboard/validator/stake", label: "Validator", icon: Shield, color: "text-[var(--purple)]" }, - { href: "/leaderboard/contract/calls", label: "Contract", icon: FileCode, color: "text-[var(--cyan)]" }, + { href: "/leaderboard/contract/recent", label: "Contract", icon: FileCode, color: "text-[var(--cyan)]" }, { href: "/leaderboard/whale/recent", label: "Whale", icon: Fish, color: "text-[var(--green)]" }, { href: "/leaderboard/compare", label: "Compare", icon: GitCompare, color: "text-[var(--pink)]" }, ] as const; diff --git a/apps/scan/components/leaderboard/ContractRanking.tsx b/apps/scan/components/leaderboard/ContractRanking.tsx new file mode 100644 index 0000000..df44fe8 --- /dev/null +++ b/apps/scan/components/leaderboard/ContractRanking.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { FileCode } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Address } from "@/components/common/Address"; +import { Pagination } from "@/components/common/Pagination"; +import { RankBadge } from "@/components/common/RankBadge"; +import { useNetwork, useNetworkFromQuery } from "@/lib/network-context"; +import { useContractList } from "@/lib/hooks"; + +const PAGE_SIZE = 25; + +const COPY = { + recent: { + subtitle: "Contracts ordered by most recent deployment", + emptyTitle: "No contracts indexed yet", + }, + pioneers: { + subtitle: "The earliest contracts deployed on chain", + emptyTitle: "No contracts indexed yet", + }, +} as const; + +export function ContractRanking({ order }: { order: "recent" | "pioneers" }) { + const { network } = useNetwork(); + useNetworkFromQuery(); + const { data, loading } = useContractList(network, order, 100); + const [page, setPage] = useState(1); + + const totalPages = Math.max(1, Math.ceil((data?.length ?? 0) / PAGE_SIZE)); + const paged = useMemo( + () => (data ?? []).slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), + [data, page], + ); + + return ( + + + + {COPY[order].subtitle} + + + + {loading ? ( +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+ ) : (data?.length ?? 0) === 0 ? ( +
+ +

{COPY[order].emptyTitle}

+

+ Contracts appear as the indexer classifies addresses via eth_getCode. +

+
+ ) : ( + <> +
+ + + + + + + + + + + + {paged.map((r) => ( + + + + + + + + ))} + +
RankContractFirst seen + Last seen + Code hash
+ + +
+
+ #{r.first_seen_block.toLocaleString()} + + #{r.last_seen_block.toLocaleString()} + + {r.code_hash ? `${r.code_hash.slice(0, 18)}…` : "—"} +
+
+ {(data?.length ?? 0) > PAGE_SIZE && ( +
+ +
+ )} + + )} +
+
+ ); +} diff --git a/apps/scan/lib/api.ts b/apps/scan/lib/api.ts index d316a61..6ebea7a 100644 --- a/apps/scan/lib/api.ts +++ b/apps/scan/lib/api.ts @@ -1347,34 +1347,13 @@ export async function fetchActiveAccounts(network: NetworkId, limit = 100): Prom return res?.accounts ?? []; } -// ── /contracts/stats ──────────────────────────────────────────────────────── -// Top contracts by tx-count (?sort=calls) or summed gas_used (?sort=gas_used). -// Indexer-served — joins transactions × addresses.is_contract = true. -export interface ContractStat { - rank: number; - address: string; - calls: number; - gas_used: number; -} - -export async function fetchContractStats( - network: NetworkId, - sort: "calls" | "gas_used" = "calls", - limit = 100, -): Promise { - const res = await apiFetch<{ contracts: ContractStat[] }>( - network, - `/contracts/stats?sort=${sort}&limit=${limit}`, - ); - return res?.contracts ?? []; -} - // ── /contracts/recent ─────────────────────────────────────────────────────── // User-deployed contracts ordered by deployment height. Indexer-served from // `addresses WHERE is_contract = true` — surfaces every contract the chain -// has indexed, regardless of whether it's been called yet (so freshly-deployed -// contracts show immediately, while /contracts/stats requires indexed call -// history that may lag behind by hours during initial backfill). +// has indexed. `/recent` is newest-first; `/pioneers` is earliest-first. +// The indexer doesn't aggregate per-contract call/gas counts (the native +// block view carries no receipt, so contract_address is never populated), +// so these two orderings are all the contract leaderboard can honestly rank by. export interface RecentContract { rank: number; address: string; @@ -1394,6 +1373,17 @@ export async function fetchRecentContracts( return res?.contracts ?? []; } +export async function fetchPioneerContracts( + network: NetworkId, + limit = 100, +): Promise { + const res = await apiFetch<{ contracts: RecentContract[] }>( + network, + `/contracts/pioneers?limit=${limit}`, + ); + return res?.contracts ?? []; +} + // ── /whale/tx ─────────────────────────────────────────────────────────────── // Largest transfers — backend-served (was previously computed client-side // from the rolling 100-block window in useBlocks). Indexer scans the full diff --git a/apps/scan/lib/hooks.ts b/apps/scan/lib/hooks.ts index eb5ab00..2bb3157 100644 --- a/apps/scan/lib/hooks.ts +++ b/apps/scan/lib/hooks.ts @@ -12,7 +12,7 @@ import { fetchAccountTokens, fetchValidatorRewards, fetchValidatorBlocksOverTime, fetchValidatorDelegators, fetchMempool, fetchCurrentEpoch, fetchChainStatus, fetchEventLogs, fetchDailyStats, fetchAccountsTop, - fetchActiveAccounts, fetchContractStats, fetchWhaleTransfers, + fetchActiveAccounts, fetchRecentContracts, fetchPioneerContracts, fetchWhaleTransfers, type ChainInfo, type BlockData, type TransactionData, type ValidatorData, type AccountBalance, type TokenData, type TopHolder, type TokenHolder, type TokenTransfer, @@ -20,7 +20,7 @@ import { type AccountTokenHolding, type ValidatorReward, type ValidatorBlocksPoint, type ValidatorDelegator, type MempoolSnapshot, type EpochInfo, type ChainStatus, type EventLog, type DailyStat, - type ActiveAccount, type ContractStat, type WhaleTransfer, + type ActiveAccount, type RecentContract, type WhaleTransfer, } from "./api"; interface UsePollingReturn { @@ -285,17 +285,21 @@ export function useActiveAccounts(network: NetworkId, limit = 100) { ); } -// Top contracts by call count or summed gas_used. Same 30s poll. Sort -// flips per page (calls vs gas_used) so the dep list pulls it in. -export function useContractStats( +// Indexed contracts, newest-first ("recent") or earliest-first ("pioneers"). +// 30s poll; `order` is in the dep list so the two leaderboard tabs each get +// their own fetch. The indexer has no per-contract call/gas aggregation, so +// these height-based orderings are what the contract leaderboard ranks by. +export function useContractList( network: NetworkId, - sort: "calls" | "gas_used" = "calls", + order: "recent" | "pioneers" = "recent", limit = 100, ) { - return usePolling( - () => fetchContractStats(network, sort, limit), + return usePolling( + () => (order === "pioneers" + ? fetchPioneerContracts(network, limit) + : fetchRecentContracts(network, limit)), 30_000, - [network, sort, limit], + [network, order, limit], ); }