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.
-
-
- ) : (
- <>
-
-
-
-
- | Rank |
- Contract |
- Calls |
-
- Gas used
- |
-
-
-
- {paged.map((r) => (
-
- |
-
- |
-
-
- |
-
- {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.
-
-
- ) : (
- <>
-
-
-
-
- | Rank |
- Contract |
- Gas used |
-
- Calls
- |
-
-
-
- {paged.map((r) => (
-
- |
-
- |
-
-
- |
-
- {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.
+
+
+ ) : (
+ <>
+
+
+
+
+ | Rank |
+ Contract |
+ First seen |
+
+ Last seen
+ |
+ Code hash |
+
+
+
+ {paged.map((r) => (
+
+ |
+
+ |
+
+
+ |
+
+ #{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],
);
}