diff --git a/src/app/chains/[slug]/page.tsx b/src/app/chains/[slug]/page.tsx
index 75fba5a0..88e1e7c5 100644
--- a/src/app/chains/[slug]/page.tsx
+++ b/src/app/chains/[slug]/page.tsx
@@ -14,6 +14,12 @@ import {
hasAnyKpi,
} from "@/lib/chain-kpis";
import { ChainKpiStrip } from "@/components/chain-kpi-strip";
+import { ChainRpcSection } from "@/components/chain-rpc-section";
+import {
+ VenueKpiToggle,
+ type VenueToggleSection,
+} from "@/components/venue-kpi-toggle";
+import { getRpcChainRow } from "@/lib/rpc-hub-stats";
import { Breadcrumb } from "@/components/breadcrumb";
import { Pill } from "@/components/pill";
import { ProviderLogo } from "@/components/provider-logo";
@@ -96,14 +102,46 @@ export default async function ChainPage({
// TVL full history is fetched directly from DefiLlama (single ~50 KB
// call, revalidated every 15 min). Client renders the active range
// (7D/30D/90D/1Y/All) without extra network round-trips.
- const [kpis, tvlHistory] = await Promise.all([
+ const [kpis, tvlHistory, rpcRow] = await Promise.all([
fetchChainKpis(slug),
fetchChainTvlFullHistory(slug),
+ getRpcChainRow(slug),
]);
const byCategory = groupByCategory(benches);
const url = `${SITE.url}/chains/${slug}`;
+ // KPI domain pill bar (same pattern as /products/): the existing
+ // Live KPIs strip (including the TVL chart) becomes the "Chain KPIs"
+ // section, and chains covered by a -rpc bench gain an "RPC"
+ // section. Chains without a hub row keep a single "Chain KPIs" pill;
+ // the strip's own gating (kpis && hasAnyKpi) is unchanged.
+ const kpiSections: VenueToggleSection[] = [];
+ if (kpis && hasAnyKpi(kpis)) {
+ kpiSections.push({
+ id: "chain-kpis",
+ label: "Chain KPIs",
+ content: (
+
+
+
+ ),
+ });
+ }
+ if (rpcRow) {
+ kpiSections.push({
+ id: "rpc",
+ label: "RPC",
+ content: ,
+ });
+ }
+
// ItemList of Datasets, one entry per benchmark that touches this chain.
// Previously CollectionPage — Google's rich result validator doesn't
// recognise CollectionPage as a supported type and flagged every chain
@@ -183,17 +221,7 @@ export default async function ChainPage({
the chain scoped bench page when one exists.
- {kpis && hasAnyKpi(kpis) && (
-
-
-
- )}
+
{Array.from(byCategory.entries()).map(([category, list]) => (
diff --git a/src/components/chain-rpc-section.tsx b/src/components/chain-rpc-section.tsx
new file mode 100644
index 00000000..2c3e155b
--- /dev/null
+++ b/src/components/chain-rpc-section.tsx
@@ -0,0 +1,106 @@
+import Link from "next/link";
+import { ArrowUpRight } from "lucide-react";
+import {
+ fetchRpcHub,
+ getRpcChainRow,
+ RPC_REGION_KEYS,
+ type RpcRegionKey,
+} from "@/lib/rpc-hub-stats";
+
+/**
+ * Compact RPC summary panel for /chains/: the chain centric cut of
+ * the rpc-hub cohort snapshot (the provider centric cut lives in
+ * RpcProviderChainsSection on product pages). Shows the 24h p50 leader,
+ * the best provider per probe region, and links out to the full
+ * -rpc bench page for the complete leaderboard.
+ *
+ * Server component, snapshot only (fetchRpcHub reads the worker written
+ * cohort blob via unstable_cache; zero Prometheus traffic). Returns null
+ * when the chain has no rpc-hub row, so the page can render it
+ * unconditionally, though the chain page gates the pill upfront via
+ * getRpcChainRow (same cached read, no extra roundtrip).
+ */
+
+const REGION_LABELS: Record = {
+ "us-east": "US East",
+ "eu-west": "EU West",
+ sgp: "Singapore",
+};
+
+function fmtMs(v: number): string {
+ if (v < 1000) return `${Math.round(v)} ms`;
+ return `${(v / 1000).toFixed(2)} s`;
+}
+
+function fmtUtc(iso: string): string | null {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return null;
+ return `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`;
+}
+
+export async function ChainRpcSection({ chainSlug }: { chainSlug: string }) {
+ const [snapshot, row] = await Promise.all([
+ fetchRpcHub(),
+ getRpcChainRow(chainSlug),
+ ]);
+ if (!snapshot || !row || !row.best) return null;
+
+ const asOf = snapshot.generatedAt ? fmtUtc(snapshot.generatedAt) : null;
+
+ return (
+
+
+ Fastest public RPC on {row.name}
+
+
+ The fastest public RPC endpoint on {row.name} right now is{" "}
+ {row.best.providerName}{" "}
+ at{" "}
+
+ {fmtMs(row.best.p50Ms)}
+ {" "}
+ p50 over the last 24 hours, counting successful calls only, across{" "}
+ {row.providerCount} live provider
+ {row.providerCount === 1 ? "" : "s"} measured from 3 probe regions.
+
+
+ Full {row.name} RPC benchmark
+
+
+ {asOf && (
+ Data as of {asOf}
+ )}
+
+
+ );
+}
diff --git a/src/lib/rpc-hub-stats.ts b/src/lib/rpc-hub-stats.ts
index 82a5f423..5912361e 100644
--- a/src/lib/rpc-hub-stats.ts
+++ b/src/lib/rpc-hub-stats.ts
@@ -31,6 +31,7 @@ import {
import { REMOVED_BENCH_SLUGS } from "@/lib/removed-benches";
import { readMaterialized, storeConfigured } from "@/lib/materialize/store";
import { chainLabelForSlug } from "@/lib/chains";
+import { matchesChainSlug } from "@/lib/chain-aliases";
import type { Benchmark, ProviderResult } from "@/types/benchmark";
import type { Spec } from "@/lib/spec-schema";
@@ -452,6 +453,25 @@ export async function fetchRpcHub(): Promise {
* a page that also renders the section costs no extra roundtrip. Used
* to decide upfront whether the RPC domain gets a KPI pill.
*/
+/**
+ * Chain centric availability helper for /chains/: the hub row for
+ * a site chain slug, or null when the chain has no `-rpc` bench
+ * in the snapshot. Alias aware (matchesChainSlug), so a renamed site
+ * slug still lands on a hub row carrying the legacy slug. Reads the
+ * same cached snapshot as ChainRpcSection, so gating the RPC KPI pill
+ * costs no extra roundtrip.
+ */
+export async function getRpcChainRow(
+ siteChainSlug: string,
+): Promise {
+ const snapshot = await fetchRpcHub();
+ if (!snapshot) return null;
+ return (
+ snapshot.chains.find((c) => matchesChainSlug(c.chain, siteChainSlug)) ??
+ null
+ );
+}
+
export async function hasRpcProviderData(slug: string): Promise {
const snapshot = await fetchRpcHub();
if (!snapshot) return false;