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. +

+
+ {RPC_REGION_KEYS.map((region) => { + const best = row.regions[region]; + return ( +
+
+ {REGION_LABELS[region]} +
+
+ {best ? ( + <> + + {best.providerName} + {" "} + + {fmtMs(best.p50Ms)} + + + ) : ( + no data + )} +
+
+ ); + })} +
+
+ + 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; From 7c65574803212a3b38d7dd9073ef27a38a67fd11 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 10 Jul 2026 16:14:18 +0200 Subject: [PATCH 3/3] prod-deploy: smoke retries cold sitemap renders (60s x3), rollback gets the mobula-labs scope (403'd without it) --- .github/workflows/prod-deploy.yml | 2 +- scripts/sitemap-smoke.mjs | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) 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/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}`,