diff --git a/benchmarks/token-quote-coverage.yml b/benchmarks/token-quote-coverage.yml
index 972049af..7b0bbd7a 100644
--- a/benchmarks/token-quote-coverage.yml
+++ b/benchmarks/token-quote-coverage.yml
@@ -15,7 +15,7 @@ per_chain_explainer:
- slug: base
h2: "Best DEX aggregator for Base launchpad token coverage"
body: |
- {{best_name:chain:base}} currently leads on Base at {{best_p50:chain:base}} coverage (24h) across Virtuals Protocol. Base launchpad graduates from Virtuals converge on Uniswap V3 pools, so EVM aggregators with a live pool-creation event index have the narrowest discovery lag. The practical differentiator is indexing latency: an aggregator scraping the Uniswap V3 Factory log in real time picks up a new token within one block; one relying on a database sync job may lag by hours, recording misses on tokens the market is already actively trading. Virtuals agent tokens show wider spread across providers due to metadata-gating in some routing engines.
+ {{best_name:chain:base}} currently leads on Base at {{best_p50:chain:base}} coverage (24h). Virtuals Protocol has expanded its presence across chains: newer Virtuals agents now graduate to Robinhood Chain (chainId 4663) rather than Base. EVM aggregators with a live Uniswap V3 pool-creation event index have the narrowest discovery lag on Base-native tokens.
- slug: bsc
h2: "Best DEX aggregator for BNB Chain new token coverage"
body: |
@@ -68,15 +68,15 @@ abstract: |
structural (it does not index Meteora DBC pool types) or temporary
(it lags new Raydium pairs by a few minutes). The chain dimension
narrows to a single chain for providers that are chain-specific
- (Jupiter is Solana-only; ParaSwap covers Base and BNB only; KyberSwap, Mobula,
+ (Jupiter is Solana-only; ParaSwap covers Base, BNB and Robinhood; KyberSwap, Mobula,
and Relay cover all four chains).
methodology:
- - "Token discovery: five sources in parallel. (1) Dexscreener token-boosts: pump-fun (Solana) and pons (Robinhood). (2) Virtuals Protocol API: Base graduated agents (virtuals). (3) GeckoTerminal BSC new_pools: four-meme. (4) GeckoTerminal Solana moonshot/pools: moonshot. (5) GeckoTerminal Solana meteora-dbc/pools: meteora-dbc. Boosted tokens from (1) are enriched via Dexscreener batch lookup to resolve dexId to venue labels."
+ - "Token discovery: five sources in parallel. (1) Dexscreener token-boosts: pump-fun (Solana) and pons (Robinhood). (2) Virtuals Protocol API: Virtuals agents on Base and Robinhood chain (virtuals). (3) GeckoTerminal BSC new_pools: four-meme. (4) GeckoTerminal Solana moonshot/pools: moonshot. (5) GeckoTerminal Solana meteora-dbc/pools: meteora-dbc. Boosted tokens from (1) are enriched via Dexscreener batch lookup to resolve dexId to venue labels."
- "Quote probe: 1 USDC → token, fired in parallel to all in-scope providers with a 10s timeout. Input token is always USDC (native USDC on Solana and Base, bridged USDC.e on BNB). A probe is a coverage hit when the HTTP response is 2xx and the parsed outAmount field is a non-zero positive integer or decimal. All other outcomes (4xx, 5xx, no-route JSON, empty outAmount, timeout) record as misses."
- "Aggregator endpoints: Jupiter lite-api.jup.ag/swap/v1/quote (Solana), KyberSwap aggregator-api.kyberswap.com/{chain}/api/v1/routes (EVM), ParaSwap apiv5.paraswap.io/prices (EVM), Mobula /api/2/swap/quoting (EVM + Solana, API key), Relay api.relay.link/quote (EVM + Solana, no key). Each adapter normalises chain identifiers, input/output decimals, and error response shapes before recording outcome."
- "Jupiter strictMode=false: queried without the curated-token filter so Jupiter will attempt routing for any token with a discoverable on-chain pool, matching how most wallets call the API. strictMode=true would trivially exclude new tokens and defeat the purpose of the benchmark."
- - "Chain-scope enforcement: Jupiter probes Solana-only. ParaSwap probes Base and BNB only. KyberSwap probes Base, BNB, and Robinhood. Mobula and Relay probe all four chains (Solana, Base, BNB, Robinhood). Providers are never penalised for chains they do not support: null cells are excluded from cross-chain aggregates."
+ - "Chain-scope enforcement: Jupiter probes Solana-only. ParaSwap probes Base, BNB and Robinhood. KyberSwap probes Base, BNB, and Robinhood. Mobula and Relay probe all four chains (Solana, Base, BNB, Robinhood). Providers are never penalised for chains they do not support: null cells are excluded from cross-chain aggregates."
- "Coverage metric: Prometheus counters token_quote_coverage_success_total and token_quote_coverage_attempts_total carry labels {provider, venue, chain}. The bench queries compute increase(success[24h]) / increase(attempts[24h]) * 100 live at read time. No pre-aggregation; the ratio reflects exactly the tokens probed in the rolling window."
- "Probe cadence and deduplication: each batch cycles through the current Dexscreener token-boosts feed. Tokens are not deduplicated across batches; the same token may appear in multiple 30-minute windows if it remains boosted. The score therefore reflects freshness of the aggregator's current routing graph, not historical coverage depth."
- "Single origin: all probes originate from the eu-west OCB VPS. Regional latency is not a factor in this bench (the outcome is binary hit/miss, not latency). No region dimension in v1."
@@ -103,7 +103,7 @@ faq:
- q: "Why is Jupiter queried with strictMode=false?"
a: "Jupiter's strictMode=true restricts quotes to verified tokens on its curated list, which defeats the purpose of a coverage bench targeting newly-launched tokens. strictMode=false instructs Jupiter to attempt routing for any token with an existing pool, matching how wallets typically call the API when displaying all tradeable tokens."
- q: "What happens when a provider is chain-specific?"
- a: "Jupiter is Solana-only and appears as null on EVM rows. ParaSwap covers Base and BNB only. KyberSwap covers Base, BNB, and Robinhood. Mobula and Relay cover all four chains including Solana. Null cells are excluded from cross-chain aggregates so chain-restricted providers are not penalized."
+ a: "Jupiter is Solana-only and appears as null on EVM rows. ParaSwap covers Base, BNB, and Robinhood. KyberSwap covers Base, BNB, and Robinhood. Mobula and Relay cover all four chains including Solana. Null cells are excluded from cross-chain aggregates so chain-restricted providers are not penalized."
source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/token-quote-coverage
@@ -180,8 +180,8 @@ providers:
- slug: paraswap
name: ParaSwap
- tag: EVM multi-chain, Base + BNB
- formula: "Rolling 24h ratio for provider=paraswap. EVM launchpads only (Base, BNB). Solana and Robinhood cells null."
+ tag: EVM multi-chain, Base + BNB + Robinhood
+ formula: "Rolling 24h ratio for provider=paraswap. EVM launchpads only (Base, BNB, Robinhood). Solana cells null."
queries:
p50: sum(increase(token_quote_coverage_success_total{provider="paraswap"}[24h])) / sum(increase(token_quote_coverage_attempts_total{provider="paraswap"}[24h])) * 100
p90: sum(increase(token_quote_coverage_attempts_total{provider="paraswap"}[24h]))
diff --git a/harnesses/token-quote-coverage/cmd/monitor/paraswap.go b/harnesses/token-quote-coverage/cmd/monitor/paraswap.go
index 94e31ca7..0be207c7 100644
--- a/harnesses/token-quote-coverage/cmd/monitor/paraswap.go
+++ b/harnesses/token-quote-coverage/cmd/monitor/paraswap.go
@@ -10,8 +10,9 @@ import (
)
const (
- paraswapUSDCBase = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
- paraswapUSDCBSC = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d"
+ paraswapUSDCBase = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
+ paraswapUSDCBSC = "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d"
+ paraswapUSDGRobinhood = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168"
)
type ParaSwapProvider struct {
@@ -25,7 +26,7 @@ func NewParaSwapProvider() *ParaSwapProvider {
func (p *ParaSwapProvider) Slug() string { return "paraswap" }
func (p *ParaSwapProvider) SupportsChain(chain string) bool {
- return chain == "base" || chain == "bsc"
+ return chain == "base" || chain == "bsc" || chain == "robinhood"
}
func (p *ParaSwapProvider) Quote(ctx context.Context, token Token) (ok bool) {
@@ -37,6 +38,9 @@ func (p *ParaSwapProvider) Quote(ctx context.Context, token Token) (ok bool) {
case "bsc":
network = "56"
usdcAddr = paraswapUSDCBSC
+ case "robinhood":
+ network = "4663"
+ usdcAddr = paraswapUSDGRobinhood
default:
RecordProbe(p.Slug(), token.Venue, token.Chain, false)
return false
diff --git a/next.config.ts b/next.config.ts
index dfe2988a..ece0984d 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -121,6 +121,31 @@ const nextConfig: NextConfig = {
},
],
},
+ {
+ // Sitemap: force-dynamic in the route file (Data Cache 2MB cap
+ // blows past for the 500+ URL corpus), so nothing caches it by
+ // default and every crawler hit re-runs the full loader chain.
+ // Hold it edge-side for an hour with SWR so cold hits stay fast.
+ source: "/sitemap.xml",
+ headers: [
+ {
+ key: "Cache-Control",
+ value: "public, s-maxage=3600, stale-while-revalidate=86400",
+ },
+ ],
+ },
+ {
+ // Citable JSON is polled by LLM crawlers (Perplexity, ChatGPT,
+ // Claude Deep Research) — a bare `public` with no s-maxage sent
+ // every scrape to origin. Same freshness window as sitemap.
+ source: "/api/citable",
+ headers: [
+ {
+ key: "Cache-Control",
+ value: "public, s-maxage=3600, stale-while-revalidate=86400",
+ },
+ ],
+ },
];
},
async redirects() {
diff --git a/src/app/benchmarks/[slug]/opengraph-image.tsx b/src/app/benchmarks/[slug]/opengraph-image.tsx
index 361d9c93..4b50596e 100644
--- a/src/app/benchmarks/[slug]/opengraph-image.tsx
+++ b/src/app/benchmarks/[slug]/opengraph-image.tsx
@@ -19,38 +19,17 @@ export async function generateStaticParams() {
return [];
}
-// Emit one OG image per (slug, chain) combo so social shares of
-// `/benchmarks/{slug}?chain=X` render a chain-honest card instead of the
-// cross-chain aggregate leader (which is the misleading baseline-skew
-// case bench-001 ran into: GMGN looking like "fastest crypto data API"
-// on the unfiltered view because Solana's faster baseline drags the
-// average down). The `id` Next.js threads through to the default image
-// handler is the chain value; the unfiltered card uses `id="default"`.
-export async function generateImageMetadata({
- params,
-}: {
- params: Promise<{ slug: string }>;
-}) {
- const { slug } = await params;
- // Load editorial-only via getBenchmark (memoised; safe at build time).
- const b = await getBenchmark(slug);
- const chains = (b?.dimensions?.chain ?? []).filter(
- (c) => c.value !== "all",
- );
- return [
- {
- id: "default",
- alt,
- size,
- contentType,
- },
- ...chains.map((c) => ({
- id: c.value,
- alt: `${alt}. ${c.label}`,
- size,
- contentType,
- })),
- ];
+// Emit a SINGLE OG image for the aggregate bench page. Previously we
+// fanned out one entry per chain variant so `/benchmarks/{slug}?chain=X`
+// social shares could render a chain-honest card, but Next hoists every
+// generateImageMetadata entry into a separate
+// on the parent page — X, LinkedIn, Slack pick nondeterministically
+// among them, so a share of `?chain=solana` might render the `robinhood`
+// card. Chain-scoped pages live at `/benchmarks/[slug]/[chain]` and can
+// carry their own opengraph-image handler if per-chain cards become a
+// priority; today they inherit the site-wide default OG.
+export async function generateImageMetadata() {
+ return [{ id: "default", alt, size, contentType }];
}
export default async function OG({
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
index 8b5b5a2d..354c0776 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -4,7 +4,17 @@ import { ArrowLeft } from "lucide-react";
export const metadata: Metadata = {
title: "Page not found",
- robots: { index: false },
+ // Full robots shape so Next replaces (not merges) the layout's robots
+ // meta. Previous shape `{ index: false }` caused two `` tags on prod 404s (layout emitted one from its
+ // own defaults, this route emitted a second) — Bing had been observed
+ // to ignore the duplicated tag and fall through to the layout's
+ // index=true. Match the shape used by the layout's IS_STAGING branch.
+ robots: {
+ index: false,
+ follow: false,
+ googleBot: { index: false, follow: false },
+ },
};
export default function NotFound() {
diff --git a/src/middleware.ts b/src/middleware.ts
index 6c940da7..bf4116b1 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -69,6 +69,17 @@ const COMPARE_PATH = /^\/compare\/([a-z0-9][a-z0-9-]{0,79})\/?$/;
export function middleware(req: NextRequest) {
const { pathname, search } = req.nextUrl;
+ // Case normalisation. `/products/Alchemy` and `/Benchmarks/foo` used
+ // to serve 200 with the mixed-case URL while the HTML canonical
+ // pointed at the lowercase form — Google indexed the mixed-case URL
+ // as a valid variant and burned crawl budget on both. Force lowercase
+ // via 308 so only the canonical shape reaches origin.
+ if (pathname !== pathname.toLowerCase()) {
+ const url = req.nextUrl.clone();
+ url.pathname = pathname.toLowerCase();
+ return NextResponse.redirect(url, 308);
+ }
+
if (search && CANONICAL_NO_QUERY.has(pathname)) {
const canonical = req.nextUrl.clone();
canonical.search = "";
@@ -136,6 +147,11 @@ export const config = {
"/api/llm-context",
"/api/freshness",
"/api/openapi.json",
+ // Wildcard `/((?!_next|.*\..*).*)` covers every HTML route so the
+ // case-normalisation redirect fires on any URL, not just the
+ // enumerated bench/answer/compare/product paths. Excludes _next
+ // internals and any file with an extension (assets, RSS, sitemap).
+ "/((?!_next|api|.*\\..*).*)",
"/benchmarks/:slug*",
"/answers/:slug*",
"/compare/:slug*",