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
12 changes: 6 additions & 6 deletions benchmarks/token-quote-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ per_chain_explainer:
- slug: robinhood
h2: "DEX aggregator coverage on Robinhood Chain"
body: |
Robinhood Chain (chainId 4663) is Robinhood's EVM-compatible Layer 2, launched in 2025. Tokens on Robinhood launch and graduate via the Pons launchpad to Uniswap V3-based pools, with USDG as the primary stablecoin. The bench probes all EVM aggregators (KyberSwap, Mobula) against Pons-graduated tokens. As of launch, no major third-party aggregator has integrated Robinhood chain routing, so coverage is expected to be near 0% across all providers,this gap is the data point. The bench will capture the first aggregator to integrate Robinhood and reflect coverage improvements in real time.
Robinhood Chain (chainId 4663) is Robinhood's EVM-compatible Layer 2, launched in 2025. Tokens launch and graduate via the Pons launchpad to Uniswap V3-based pools, with USDG as the primary stablecoin. Virtuals Protocol has also expanded to Robinhood, so the bench now probes both Pons-graduated tokens and Virtuals agents deployed on Robinhood. Three aggregators cover this chain: KyberSwap, Mobula, and Relay. {{best_name:chain:robinhood}} currently leads on Robinhood at {{best_p50:chain:robinhood}} coverage (24h). The leaderboard captures improvements in real time as aggregators deepen their Robinhood integration.

category: Aggregators
status: live
Expand Down Expand Up @@ -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; KyberSwap, Mobula, and ParaSwap are EVM-only on the
launchpads they cover).
(Jupiter is Solana-only; ParaSwap covers Base and BNB only; 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."
- "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: Mobula /api/2/swap/quoting (Bearer auth, EVM), 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). Each adapter normalises chain identifiers, input/output decimals, and error response shapes before recording outcome."
- "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. KyberSwap, Mobula, and ParaSwap probe EVM chains. KyberSwap and Mobula also cover Robinhood chain; ParaSwap covers Base and BNB only. Robinhood chain is currently at 0% coverage across all providers,an accurate reflection that no major aggregator has integrated Robinhood yet. Providers are never penalised for chains they structurally do not support: Solana cells for EVM-only providers are null, not zero."
- "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."
- "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."
Expand All @@ -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 with null values on Base and BNB launchpad rows. KyberSwap, Mobula, and ParaSwap are EVM-only and appear as null on Solana launchpad rows. Null-chain cells are excluded from cross-launchpad aggregates so chain-specific providers are not penalized."
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."

source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/token-quote-coverage

Expand Down
16 changes: 16 additions & 0 deletions harnesses/token-quote-coverage/cmd/monitor/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ func classifyNetErr(err error) string {
return "network"
}

// chainSlug maps Virtuals API chain names (e.g. "ROBINHOOD", "BASE") to our chain slugs.
func chainSlug(apiChain string) string {
switch strings.ToUpper(apiChain) {
case "ROBINHOOD":
return "robinhood"
case "BASE":
return "base"
case "BSC", "BNB":
return "bsc"
case "SOLANA":
return "solana"
default:
return "base" // fallback: Virtuals was originally Base-only
}
}

// snippet returns at most 200 bytes of a response body for logging.
func snippet(b []byte) string {
if len(b) > 200 {
Expand Down
4 changes: 3 additions & 1 deletion harnesses/token-quote-coverage/cmd/monitor/virtuals.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func FetchVirtualsTokens(ctx context.Context) ([]boostEntry, error) {

type item struct {
TokenAddress string `json:"tokenAddress"`
Chain string `json:"chain"`
}

var items []item
Expand All @@ -67,7 +68,8 @@ func FetchVirtualsTokens(ctx context.Context) ([]boostEntry, error) {
continue
}
seen[k] = true
out = append(out, boostEntry{ChainId: "base", TokenAddress: it.TokenAddress, Venue: "virtuals"})
chain := chainSlug(it.Chain)
out = append(out, boostEntry{ChainId: chain, TokenAddress: it.TokenAddress, Venue: "virtuals"})
}
return out, nil
}
48 changes: 33 additions & 15 deletions src/components/chart-export-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

import { useCallback, useState } from "react";
import { Copy, Download, Check, Loader2 } from "lucide-react";
import { toPng } from "html-to-image";
import { toBlob } from "html-to-image";

type Props = {
/** Ref to the element to capture. Should include chart + watermark. */
Expand All @@ -47,24 +47,35 @@ export function ChartExportButton({
if (!el) return null;
// Force a solid background — html-to-image renders transparency by
// default which produces unreadable screenshots on dark UIs when the
// user pastes into a light chat / doc.
// user pastes into a light chat / doc. The computed value from the
// page CSS is normally an oklch(...) triple which the canvas ctx
// accepts fine, but if unset we hard-fall-back to a dark hex so
// the export never leaks a transparent PNG.
const bg =
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement)
.getPropertyValue("--color-paper")
.trim() || "#0b0b0d";
const dataUrl = await toPng(el, {
.trim() ||
"#0b0b0d";
// toBlob → direct Blob output. Avoids the dataURL → fetch → blob()
// intermediate step which OOMs for large canvases in Safari and
// sometimes silently produces empty PNGs. Also skips the "download
// blocked because the click gesture context expired" trap since we
// hand a real Blob to the caller synchronously right after await.
const blob = await toBlob(el, {
pixelRatio: PIXEL_RATIO,
backgroundColor: bg,
cacheBust: true,
// Skip external images with tainted crossOrigin (provider logos are
// same-origin from /logos/* so they render fine; anything else that
// fails to load is silently dropped rather than aborting the whole
// export).
skipFonts: false,
// Drop the export button itself from the capture — no point
// baking the "Copy" pill into every screenshot.
filter: (node) => {
if (!(node instanceof HTMLElement)) return true;
return !node.dataset.chartExportButton;
},
style: { boxShadow: "none" },
});
const res = await fetch(dataUrl);
return await res.blob();
return blob;
}, [targetRef]);

const onCopy = useCallback(async () => {
Expand All @@ -89,7 +100,7 @@ export function ChartExportButton({
setState("copied");
setTimeout(() => setState("idle"), 1600);
} catch (err) {
console.warn("[chart-export] copy failed", err);
console.error("[chart-export] copy failed", err);
setState("error");
setTimeout(() => setState("idle"), 2200);
}
Expand All @@ -99,19 +110,22 @@ export function ChartExportButton({
setState("working");
try {
const blob = await capture();
if (!blob) throw new Error("no target");
if (!blob) throw new Error("capture returned no blob");
triggerDownload(blob, filename);
setState("downloaded");
setTimeout(() => setState("idle"), 1600);
} catch (err) {
console.warn("[chart-export] download failed", err);
console.error("[chart-export] download failed", err);
setState("error");
setTimeout(() => setState("idle"), 2200);
}
}, [capture, filename]);

return (
<span className={`inline-flex items-center rounded-md border border-ink/15 bg-paper shadow-sm ${className}`}>
<span
data-chart-export-button="true"
className={`inline-flex items-center rounded-md border border-ink/15 bg-paper shadow-sm ${className}`}
>
<button
type="button"
onClick={onCopy}
Expand Down Expand Up @@ -159,8 +173,12 @@ function triggerDownload(blob: Blob, filename: string) {
const a = document.createElement("a");
a.href = url;
a.download = `${filename}.png`;
// Safari occasionally aborts programmatic downloads if the object URL
// is revoked before the browser processes the click. Give it a full
// second before releasing the URL.
a.rel = "noopener";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
10 changes: 5 additions & 5 deletions src/components/chart-watermark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
export function ChartWatermarkSvg({
cx,
cy,
fontSize = 48,
fontSize = 34,
}: {
/** Center X of the plot area (padL + innerW/2). */
cx: number;
Expand All @@ -43,10 +43,10 @@ export function ChartWatermarkSvg({
style={{
fontFamily: "var(--font-jetbrains-mono, ui-monospace, monospace)",
fontSize: `${fontSize}px`,
fontWeight: 600,
fontWeight: 500,
letterSpacing: "0.06em",
fill: "var(--color-ink)",
fillOpacity: 0.09,
fillOpacity: 0.04,
}}
>
openchainbench.com
Expand All @@ -58,8 +58,8 @@ export function ChartWatermarkHtml() {
return (
<span
aria-hidden
className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center select-none font-mono font-semibold uppercase text-[clamp(1.4rem,4.2vw,2.6rem)] tracking-[0.08em] text-ink"
style={{ opacity: 0.09, transform: "rotate(-22deg)" }}
className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center select-none font-mono font-medium uppercase text-[clamp(1rem,3vw,1.9rem)] tracking-[0.08em] text-ink"
style={{ opacity: 0.045, transform: "rotate(-22deg)" }}
>
openchainbench.com
</span>
Expand Down
5 changes: 3 additions & 2 deletions src/components/export-video-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,10 @@ function ExportVideoModal({ slug, title, benchmark }: Props) {
onClick={() => setOpen(true)}
aria-label="Export video"
title="Export video"
className="inline-flex items-center justify-center rounded-md border border-ink bg-ink p-2.5 text-paper hover:bg-paper hover:text-ink transition-colors shadow-sm"
className="inline-flex items-center gap-1.5 rounded-md border border-ink/15 bg-paper px-2.5 py-1 text-[11px] font-sans font-medium uppercase tracking-[0.1em] text-ink shadow-sm transition-colors hover:bg-paper-soft"
>
<Video size={14} strokeWidth={2} />
<Video size={11} strokeWidth={2} />
<span className="hidden sm:inline">Video</span>
</button>

{open && (
Expand Down
5 changes: 3 additions & 2 deletions src/components/share-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,10 @@ export function ShareSection({ slug, title, benchmark, chain }: Props) {
onClick={() => setOpen(true)}
aria-label="Export image"
title="Export image"
className="inline-flex items-center justify-center rounded-md border border-ink bg-ink p-2.5 text-paper hover:bg-paper hover:text-ink transition-colors shadow-sm"
className="inline-flex items-center gap-1.5 rounded-md border border-ink/15 bg-paper px-2.5 py-1 text-[11px] font-sans font-medium uppercase tracking-[0.1em] text-ink shadow-sm transition-colors hover:bg-paper-soft"
>
<ImageIcon size={14} strokeWidth={2} />
<ImageIcon size={11} strokeWidth={2} />
<span className="hidden sm:inline">Image</span>
</button>

{open && (
Expand Down
7 changes: 4 additions & 3 deletions src/components/time-series-chart/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@
// line draws a break but the dot keeps rendering at 0 / NaN.
return { ...l, color, pts, linePath, fillPath, lastX, lastY, last, isGap };
});
}, [slicedLines, padL, padT, innerW, innerH, lo, yRange, expectedPoints]);

Check warning on line 296 in src/components/time-series-chart/chart.tsx

View workflow job for this annotation

GitHub Actions / check

React Hook useMemo has a missing dependency: 'zoom'. Either include it or remove the dependency array

// Hover line position: mirror the right-anchored placement used by drawn().
// hover.idx is in [0, numPoints-1]; we map it to the visible chart via the
Expand Down Expand Up @@ -373,12 +373,13 @@

{/* Diagonal attribution watermark. Rendered BEFORE the series
paths so the data lines stack on top of it — reads as a
background stamp, not overlay. Sized to the plot area's
shorter axis so it scales gracefully with the chart. */}
background stamp, not overlay. Kept intentionally small +
near-transparent so it's discernible in screenshots but
never competes with the data during normal reading. */}
<ChartWatermarkSvg
cx={padL + innerW / 2}
cy={padT + innerH / 2}
fontSize={Math.round(Math.min(innerW, innerH) * 0.16)}
fontSize={Math.round(Math.min(innerW, innerH) * 0.1)}
/>

{/* Y gridlines + tick labels */}
Expand Down
Loading