From 60734881c86e63e89dc41a003791fb5b1bcb2c39 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Fri, 10 Jul 2026 16:10:40 +0200
Subject: [PATCH 1/3] chain-kpis: cover 11 new chains, fix ton to gram slug,
unfiltered stables endpoint (#1067)
Co-authored-by: Florent Tapponnier
---
harnesses/chain-kpis/cmd/script/config.go | 12 +++++---
harnesses/chain-kpis/cmd/script/defillama.go | 23 +++++++-------
harnesses/chain-kpis/cmd/script/mobula.go | 4 +--
harnesses/chain-kpis/cmd/script/registry.go | 32 +++++++++++++++++++-
4 files changed, 52 insertions(+), 19 deletions(-)
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"},
}
From 12e81a340087420062c3e1f7ceb696e2bbcb9dd0 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Fri, 10 Jul 2026 16:10:44 +0200
Subject: [PATCH 2/3] chains: RPC summary panel behind KPI pill toggle (#1068)
Co-authored-by: Florent Tapponnier
---
src/app/chains/[slug]/page.tsx | 52 ++++++++++---
src/components/chain-rpc-section.tsx | 106 +++++++++++++++++++++++++++
src/lib/rpc-hub-stats.ts | 20 +++++
3 files changed, 166 insertions(+), 12 deletions(-)
create mode 100644 src/components/chain-rpc-section.tsx
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.
+