diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml
index 763d3310..ba08b7b0 100644
--- a/.github/workflows/prod-deploy.yml
+++ b/.github/workflows/prod-deploy.yml
@@ -101,7 +101,7 @@ jobs:
if: steps.smoke.outcome == 'failure'
run: |
echo "::error::Sitemap smoke failed. Rolling back prod alias to the previous deploy."
- vercel rollback --token=${{ secrets.VERCEL_TOKEN }} --yes
+ vercel rollback --token=${{ secrets.VERCEL_TOKEN }} --scope=mobula-labs --yes
# Re-fail the job so the rollback is visible in the workflow
# status (a red X on the commit + an email).
exit 1
diff --git a/harnesses/chain-kpis/cmd/script/config.go b/harnesses/chain-kpis/cmd/script/config.go
index dc2733ce..06409f9a 100644
--- a/harnesses/chain-kpis/cmd/script/config.go
+++ b/harnesses/chain-kpis/cmd/script/config.go
@@ -11,21 +11,23 @@ import (
type Config struct {
// Mobula REST key. Required for /market/data and /market/blockchain/stats
// (both endpoints rate-limit free traffic to ~10 req/min, the key lifts
- // that to a level that comfortably absorbs 21 chains × 2 endpoints
+ // that to a level that comfortably absorbs 32 chains × 2 endpoints
// every refresh tick.) Without a key, Mobula returns 429 immediately.
MobulaAPIKey string
// How often DefiLlama is polled per chain. 15 min is the conservative
// default: their public API is generous but the TVL value barely moves
// inside a 15-min window (intra-day deltas are noise vs the day-over-
- // day signal we surface on the chain page). 3 calls/h × 21 chains = 63
- // req/h, well under any sane rate-limit ceiling.
+ // day signal we surface on the chain page). 4 ticks/h × 31 mapped
+ // chains × 3 endpoints = 372 req/h, well under any sane rate-limit
+ // ceiling.
DefillamaRefreshInterval time.Duration
// Mobula tick. Faster because we own the source — and native-token
// prices move on shorter cycles than DEX-TVL aggregates. 5 min keeps
- // the price card fresh without hammering. 12 calls/h × 21 chains × 2
- // endpoints = 504 req/h, fine on a paid key.
+ // the price card fresh without hammering. Native calls dedup by
+ // symbol, so one tick is ~19 native plus ~27 stats requests, under
+ // 600 req/h, fine on a paid key.
MobulaRefreshInterval time.Duration
}
diff --git a/harnesses/chain-kpis/cmd/script/defillama.go b/harnesses/chain-kpis/cmd/script/defillama.go
index 1bda3e7d..c3ee658a 100644
--- a/harnesses/chain-kpis/cmd/script/defillama.go
+++ b/harnesses/chain-kpis/cmd/script/defillama.go
@@ -12,8 +12,7 @@ import (
// per chain that has a DefiLlama mapping:
// 1. /v2/historicalChainTvl/ — last sample = current TVL
// 2. /overview/dexs/ — total24h = aggregate DEX volume
-// 3. /stablecoincharts/?stablecoin=1
-// — last point's USD-pegged total
+// 3. /stablecoincharts/ — last point's USD-pegged total
//
// Each endpoint is its own goroutine inside fetchDefillamaChain so a
// stuck TVL request doesn't starve the stables fetch for the same chain.
@@ -113,20 +112,22 @@ func defillamaDexVolume24h(chainName string) (float64, error) {
return resp.Total24h, nil
}
-// defillamaStablesMcap reads /stablecoincharts/?stablecoin=1 and
-// returns the last sample's USD-pegged total. The `stablecoin=1` filter
-// is required by the API though we don't filter by a specific stablecoin;
-// the response is the chain-wide aggregate when no specific id is given.
+// defillamaStablesMcap reads /stablecoincharts/ and returns the
+// last sample's USD-pegged total. No `stablecoin=` query param: that
+// param filters to ONE pegged asset (id 1 = USDT) and the API answers
+// 200 + empty body on chains where that specific asset has no recorded
+// issuance (Base, Blast, Stellar), which is what nulled their stables
+// card. The unfiltered call returns the chain-wide aggregate.
+// Chains DefiLlama tracks for TVL but not for stablecoins (Litecoin)
+// answer 404 and land in the not_found error bucket, gauge unpublished.
func defillamaStablesMcap(chainName string) (float64, error) {
- url := fmt.Sprintf("%s/stablecoincharts/%s?stablecoin=1", stablesLlamaBase, encodePath(chainName))
+ url := fmt.Sprintf("%s/stablecoincharts/%s", stablesLlamaBase, encodePath(chainName))
body, err := getJSON(httpClientDefillama, url)
if err != nil {
return 0, err
}
- // DefiLlama returns 200 + empty body for chains it tracks but where no
- // stablecoin issuance has been recorded (Stellar, Blast, Base at time
- // of writing). Surface as a typed sentinel so the harness logs it as
- // "not_tracked" rather than spamming parse_error.
+ // Typed sentinel so an empty 200 logs as "not_tracked" rather than
+ // spamming parse_error.
if len(body) == 0 {
return 0, fmt.Errorf("not_tracked")
}
diff --git a/harnesses/chain-kpis/cmd/script/mobula.go b/harnesses/chain-kpis/cmd/script/mobula.go
index 816689ff..bd47ee5b 100644
--- a/harnesses/chain-kpis/cmd/script/mobula.go
+++ b/harnesses/chain-kpis/cmd/script/mobula.go
@@ -33,8 +33,8 @@ func fetchAllMobula(cfg *Config) {
return
}
- // Dedup native symbols. The 21 chains today flatten to 11 unique
- // natives (every L2 = ETH).
+ // Dedup native symbols. The 32 chains today flatten to ~19 unique
+ // natives (every ETH gas rollup = ETH).
natives := map[string][]Chain{}
for _, c := range Registry {
if c.NativeSymbol == "" {
diff --git a/harnesses/chain-kpis/cmd/script/registry.go b/harnesses/chain-kpis/cmd/script/registry.go
index b3ca6768..eb084453 100644
--- a/harnesses/chain-kpis/cmd/script/registry.go
+++ b/harnesses/chain-kpis/cmd/script/registry.go
@@ -35,7 +35,13 @@ var Registry = []Chain{
{Slug: "bnb", DefiLlama: "BSC", Mobula: "BNB Smart Chain (BEP20)", NativeSymbol: "BNB"},
{Slug: "avalanche", DefiLlama: "Avalanche", Mobula: "Avalanche C-Chain", NativeSymbol: "AVAX"},
{Slug: "sui", DefiLlama: "Sui", Mobula: "Sui", NativeSymbol: "SUI"},
- {Slug: "ton", DefiLlama: "TON", Mobula: "TON", NativeSymbol: "TON"},
+ // Site canonical slug is "gram" since the Toncoin to Gram rename; the
+ // old "ton" label made the Prom selector miss and the KV blob stay null.
+ // Mobula still serves the asset under symbol TON: symbol GRAM resolves
+ // to an unrelated "GRAM Token" (~$24M mcap), verified live 2026-07-08.
+ // The stats endpoint still accepts blockchain=TON even though it is
+ // absent from /api/1/blockchains.
+ {Slug: "gram", DefiLlama: "TON", Mobula: "TON", NativeSymbol: "TON"},
{Slug: "stellar", DefiLlama: "Stellar", Mobula: "", NativeSymbol: "XLM"},
{Slug: "tron", DefiLlama: "Tron", Mobula: "TRON", NativeSymbol: "TRX"},
{Slug: "cardano", DefiLlama: "Cardano", Mobula: "", NativeSymbol: "ADA"},
@@ -52,4 +58,28 @@ var Registry = []Chain{
{Slug: "blast", DefiLlama: "Blast", Mobula: "Blast", NativeSymbol: "ETH"},
{Slug: "mantle", DefiLlama: "Mantle", Mobula: "Mantle", NativeSymbol: "MNT"},
{Slug: "taiko", DefiLlama: "Taiko", Mobula: "Taiko", NativeSymbol: "ETH"},
+ // Chains added to the site registry after the original harness config
+ // was written; they published all-null KV blobs until this batch.
+ // Every DefiLlama name below verified live against /v2/chains,
+ // /overview/dexs/ and /stablecoincharts/ on 2026-07-08.
+ // Mobula blockchain names verified against /api/1/blockchains; empty
+ // means Mobula does not index the chain yet (only the tokens-indexed
+ // gauge is lost, the KPI strip does not read it).
+ {Slug: "monad", DefiLlama: "Monad", Mobula: "", NativeSymbol: "MON"},
+ {Slug: "megaeth", DefiLlama: "MegaETH", Mobula: "MegaETH", NativeSymbol: "ETH"},
+ {Slug: "sonic", DefiLlama: "Sonic", Mobula: "Sonic", NativeSymbol: "S"},
+ // Gnosis gas is xDAI but the site strip labels the native token GNO,
+ // so we publish GNO to match what the page displays. Mobula indexes
+ // the chain under its legacy XDAI name.
+ {Slug: "gnosis", DefiLlama: "Gnosis", Mobula: "XDAI", NativeSymbol: "GNO"},
+ {Slug: "celo", DefiLlama: "Celo", Mobula: "Celo", NativeSymbol: "CELO"},
+ {Slug: "moonbeam", DefiLlama: "Moonbeam", Mobula: "Moonbeam", NativeSymbol: "GLMR"},
+ {Slug: "unichain", DefiLlama: "Unichain", Mobula: "", NativeSymbol: "ETH"},
+ {Slug: "berachain", DefiLlama: "Berachain", Mobula: "Berachain", NativeSymbol: "BERA"},
+ {Slug: "cronos", DefiLlama: "Cronos", Mobula: "Cronos", NativeSymbol: "CRO"},
+ // Fraxtal gas is frxETH, so we follow the same gas-token convention as
+ // the ETH rollups. Mobula symbol FRAX resolves to the legacy Frax
+ // stablecoin (~$1), the wrong asset for a native-token card.
+ {Slug: "fraxtal", DefiLlama: "Fraxtal", Mobula: "", NativeSymbol: "FRXETH"},
+ {Slug: "soneium", DefiLlama: "Soneium", Mobula: "", NativeSymbol: "ETH"},
}
diff --git a/scripts/sitemap-smoke.mjs b/scripts/sitemap-smoke.mjs
index 4de6753f..983758dd 100644
--- a/scripts/sitemap-smoke.mjs
+++ b/scripts/sitemap-smoke.mjs
@@ -51,7 +51,26 @@ async function getText(url, timeoutMs = TIMEOUT_MS) {
}
}
-const sitemapRes = await getText(sitemapUrl);
+// The first sitemap render after a fresh deploy is cold (full catalog
+// load) and can exceed a single request timeout. Retry with backoff so
+// a cold cache never fails the smoke and triggers a rollback of a
+// perfectly healthy deploy (observed 2026-07-10: AbortError at 20s,
+// sitemap healthy 30s later).
+async function getTextWithRetry(url, attempts = 3, timeoutMs = 60000) {
+ let lastErr;
+ for (let i = 1; i <= attempts; i++) {
+ try {
+ return await getText(url, timeoutMs);
+ } catch (err) {
+ lastErr = err;
+ console.warn(`[smoke] attempt ${i}/${attempts} failed: ${err}`);
+ if (i < attempts) await new Promise((r) => setTimeout(r, 10000 * i));
+ }
+ }
+ throw lastErr;
+}
+
+const sitemapRes = await getTextWithRetry(sitemapUrl);
if (sitemapRes.res.status !== 200) {
console.error(
`[smoke] sitemap fetch failed: status=${sitemapRes.res.status}`,
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;