Skip to content
Merged
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
105 changes: 12 additions & 93 deletions apps/scan/app/[locale]/leaderboard/contract/calls/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm text-muted-foreground font-normal">
Contracts ranked by lifetime call count
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" style={{ opacity: 1 - i * 0.08 }} />
))}
</div>
) : (data?.length ?? 0) === 0 ? (
<div className="p-12 text-center">
<FileCode className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">No contract activity indexed yet</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Counts appear as the indexer ingests EVM tx with a contract receiver.
</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground bg-muted/30">
<th className="px-4 py-2.5 font-medium w-14">Rank</th>
<th className="px-4 py-2.5 font-medium">Contract</th>
<th className="px-4 py-2.5 font-medium text-right">Calls</th>
<th className="px-4 py-2.5 font-medium text-right hidden md:table-cell">
Gas used
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60 row-hover">
{paged.map((r) => (
<tr key={r.address}>
<td className="px-4 py-2.5">
<RankBadge rank={r.rank} />
</td>
<td className="px-4 py-2.5">
<Address address={r.address} className="text-xs" />
</td>
<td className="px-4 py-2.5 text-right font-mono">
{formatNumber(r.calls)}
</td>
<td className="px-4 py-2.5 text-right font-mono hidden md:table-cell text-muted-foreground">
{formatNumber(r.gas_used)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{(data?.length ?? 0) > PAGE_SIZE && (
<div className="border-t border-border">
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</div>
)}
</>
)}
</CardContent>
</Card>
);
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 });
}
104 changes: 11 additions & 93 deletions apps/scan/app/[locale]/leaderboard/contract/gas/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm text-muted-foreground font-normal">
Contracts ranked by total gas consumed
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" style={{ opacity: 1 - i * 0.08 }} />
))}
</div>
) : (data?.length ?? 0) === 0 ? (
<div className="p-12 text-center">
<Flame className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">No contract gas activity yet</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Numbers populate as the indexer ingests EVM contract calls.
</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground bg-muted/30">
<th className="px-4 py-2.5 font-medium w-14">Rank</th>
<th className="px-4 py-2.5 font-medium">Contract</th>
<th className="px-4 py-2.5 font-medium text-right">Gas used</th>
<th className="px-4 py-2.5 font-medium text-right hidden md:table-cell">
Calls
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60 row-hover">
{paged.map((r) => (
<tr key={r.address}>
<td className="px-4 py-2.5">
<RankBadge rank={r.rank} />
</td>
<td className="px-4 py-2.5">
<Address address={r.address} className="text-xs" />
</td>
<td className="px-4 py-2.5 text-right font-mono">
{formatNumber(r.gas_used)}
</td>
<td className="px-4 py-2.5 text-right font-mono hidden md:table-cell text-muted-foreground">
{formatNumber(r.calls)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{(data?.length ?? 0) > PAGE_SIZE && (
<div className="border-t border-border">
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</div>
)}
</>
)}
</CardContent>
</Card>
);
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 });
}
4 changes: 2 additions & 2 deletions apps/scan/app/[locale]/leaderboard/contract/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export default function ContractLeaderboardLayout({ children }: { children: Reac
<div className="space-y-4">
<SubTabs
tabs={[
{ href: "/leaderboard/contract/calls", label: "Top by Calls" },
{ href: "/leaderboard/contract/gas", label: "Top by Gas Used" },
{ href: "/leaderboard/contract/recent", label: "Recently Deployed" },
{ href: "/leaderboard/contract/pioneers", label: "Pioneers" },
]}
/>
{children}
Expand Down
2 changes: 1 addition & 1 deletion apps/scan/app/[locale]/leaderboard/contract/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ContractRanking } from "@/components/leaderboard/ContractRanking";

export default function PioneerContractsPage() {
return <ContractRanking order="pioneers" />;
}
5 changes: 5 additions & 0 deletions apps/scan/app/[locale]/leaderboard/contract/recent/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ContractRanking } from "@/components/leaderboard/ContractRanking";

export default function RecentContractsPage() {
return <ContractRanking order="recent" />;
}
2 changes: 1 addition & 1 deletion apps/scan/components/layout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
108 changes: 108 additions & 0 deletions apps/scan/components/leaderboard/ContractRanking.tsx
Original file line number Diff line number Diff line change
@@ -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],
Comment on lines +30 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset/clamp page when the contract list shrinks to avoid blank tables.

Line 30 keeps prior page state across dataset changes. If the user is on a later page and the new data has fewer pages, Line 34 slices to an empty array while (data?.length ?? 0) > 0, so the table renders with no rows and no empty-state message.

Suggested fix
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
@@
   const totalPages = Math.max(1, Math.ceil((data?.length ?? 0) / PAGE_SIZE));
+  useEffect(() => {
+    setPage((p) => Math.min(p, totalPages));
+  }, [totalPages]);
+
   const paged = useMemo(
     () => (data ?? []).slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
     [data, page],
   );

Also applies to: 52-77, 98-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/scan/components/leaderboard/ContractRanking.tsx` around lines 30 - 35,
When the dataset changes the existing page state can point past the new
totalPages causing paged to be empty; add an effect that recalculates totalPages
(using data?.length and PAGE_SIZE) and clamps/resets page via setPage to
Math.min(Math.max(1, page), totalPages) (or to 1 when totalPages is 0) so the
current page is valid; implement this behavior wherever you manage pagination
state (references: page, setPage, totalPages, paged, useMemo, PAGE_SIZE, and the
data prop).

);

return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm text-muted-foreground font-normal">
{COPY[order].subtitle}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" style={{ opacity: 1 - i * 0.08 }} />
))}
</div>
) : (data?.length ?? 0) === 0 ? (
<div className="p-12 text-center">
<FileCode className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">{COPY[order].emptyTitle}</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Contracts appear as the indexer classifies addresses via eth_getCode.
</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground bg-muted/30">
<th className="px-4 py-2.5 font-medium w-14">Rank</th>
<th className="px-4 py-2.5 font-medium">Contract</th>
<th className="px-4 py-2.5 font-medium text-right">First seen</th>
<th className="px-4 py-2.5 font-medium text-right hidden md:table-cell">
Last seen
</th>
<th className="px-4 py-2.5 font-medium hidden lg:table-cell">Code hash</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60 row-hover">
{paged.map((r) => (
<tr key={r.address}>
<td className="px-4 py-2.5">
<RankBadge rank={r.rank} />
</td>
<td className="px-4 py-2.5">
<Address address={r.address} className="text-xs" />
</td>
<td className="px-4 py-2.5 text-right font-mono text-muted-foreground">
#{r.first_seen_block.toLocaleString()}
</td>
<td className="px-4 py-2.5 text-right font-mono hidden md:table-cell text-muted-foreground">
#{r.last_seen_block.toLocaleString()}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground hidden lg:table-cell break-all max-w-[16rem]">
{r.code_hash ? `${r.code_hash.slice(0, 18)}…` : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{(data?.length ?? 0) > PAGE_SIZE && (
<div className="border-t border-border">
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
Loading
Loading