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
2 changes: 1 addition & 1 deletion .github/workflows/prod-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions harnesses/chain-kpis/cmd/script/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
23 changes: 12 additions & 11 deletions harnesses/chain-kpis/cmd/script/defillama.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import (
// per chain that has a DefiLlama mapping:
// 1. /v2/historicalChainTvl/<name> — last sample = current TVL
// 2. /overview/dexs/<name> — total24h = aggregate DEX volume
// 3. /stablecoincharts/<name>?stablecoin=1
// — last point's USD-pegged total
// 3. /stablecoincharts/<name> — 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.
Expand Down Expand Up @@ -113,20 +112,22 @@ func defillamaDexVolume24h(chainName string) (float64, error) {
return resp.Total24h, nil
}

// defillamaStablesMcap reads /stablecoincharts/<chain>?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/<chain> and returns the
// last sample's USD-pegged total. No `stablecoin=<id>` 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")
}
Expand Down
4 changes: 2 additions & 2 deletions harnesses/chain-kpis/cmd/script/mobula.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
32 changes: 31 additions & 1 deletion harnesses/chain-kpis/cmd/script/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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/<name> and /stablecoincharts/<name> 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"},
}
21 changes: 20 additions & 1 deletion scripts/sitemap-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
52 changes: 40 additions & 12 deletions src/app/chains/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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/<slug>): the existing
// Live KPIs strip (including the TVL chart) becomes the "Chain KPIs"
// section, and chains covered by a <chain>-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: (
<div className="mt-6">
<ChainKpiStrip
slug={slug}
kpis={kpis}
nativeSymbol={chain.nativeSymbol}
tvlHistory={tvlHistory}
chainLabel={chain.label}
/>
</div>
),
});
}
if (rpcRow) {
kpiSections.push({
id: "rpc",
label: "RPC",
content: <ChainRpcSection chainSlug={slug} />,
});
}

// 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
Expand Down Expand Up @@ -183,17 +221,7 @@ export default async function ChainPage({
the chain scoped bench page when one exists.
</p>

{kpis && hasAnyKpi(kpis) && (
<div className="mt-10">
<ChainKpiStrip
slug={slug}
kpis={kpis}
nativeSymbol={chain.nativeSymbol}
tvlHistory={tvlHistory}
chainLabel={chain.label}
/>
</div>
)}
<VenueKpiToggle sections={kpiSections} />

<div className="mt-12 space-y-12">
{Array.from(byCategory.entries()).map(([category, list]) => (
Expand Down
106 changes: 106 additions & 0 deletions src/components/chain-rpc-section.tsx
Original file line number Diff line number Diff line change
@@ -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/<slug>: 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
* <chain>-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<RpcRegionKey, string> = {
"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 (
<section className="mt-6">
<h2 className="text-[11px] font-medium uppercase tracking-[0.18em] text-ink-muted">
Fastest public RPC on {row.name}
</h2>
<p className="mt-2 text-sm text-ink-soft leading-snug max-w-2xl">
The fastest public RPC endpoint on {row.name} right now is{" "}
<span className="font-medium text-ink">{row.best.providerName}</span>{" "}
at{" "}
<span className="font-medium text-ink tabular-nums">
{fmtMs(row.best.p50Ms)}
</span>{" "}
p50 over the last 24 hours, counting successful calls only, across{" "}
{row.providerCount} live provider
{row.providerCount === 1 ? "" : "s"} measured from 3 probe regions.
</p>
<dl className="mt-4 grid grid-cols-1 sm:grid-cols-3 border-y border-rule divide-y sm:divide-y-0 sm:divide-x divide-rule">
{RPC_REGION_KEYS.map((region) => {
const best = row.regions[region];
return (
<div key={region} className="py-3 sm:px-4 sm:first:pl-0">
<dt className="text-[10px] font-medium uppercase tracking-[0.16em] text-ink-muted">
{REGION_LABELS[region]}
</dt>
<dd className="mt-1 text-[12.5px]">
{best ? (
<>
<span className="font-medium text-ink">
{best.providerName}
</span>{" "}
<span className="text-ink-soft tabular-nums">
{fmtMs(best.p50Ms)}
</span>
</>
) : (
<span className="text-ink-faint">no data</span>
)}
</dd>
</div>
);
})}
</dl>
<div className="mt-3 flex flex-wrap items-baseline gap-x-4 gap-y-1">
<Link
href={`/benchmarks/${row.slug}`}
className="inline-flex items-center gap-1.5 text-sm font-medium text-ink hover:underline underline-offset-2"
>
Full {row.name} RPC benchmark
<ArrowUpRight size={14} strokeWidth={2} />
</Link>
{asOf && (
<span className="text-[10.5px] text-ink-faint">Data as of {asOf}</span>
)}
</div>
</section>
);
}
20 changes: 20 additions & 0 deletions src/lib/rpc-hub-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -452,6 +453,25 @@ export async function fetchRpcHub(): Promise<RpcHubSnapshot | null> {
* 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/<slug>: the hub row for
* a site chain slug, or null when the chain has no `<chain>-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<RpcHubChain | null> {
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<boolean> {
const snapshot = await fetchRpcHub();
if (!snapshot) return false;
Expand Down
Loading