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
214 changes: 214 additions & 0 deletions benchmarks/network-fees.yml

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions benchmarks/wallet-labels-coverage.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,7 +67,7 @@ faq:
- q: "What does 'wallet labeling' mean in a crypto API?"
a: "A wallet labeling API takes an address and returns an entity name. The CEX it belongs to (`Binance hot wallet 14`), the protocol (`Uniswap V3 router`), the multisig owner (`Safe: foundation treasury`), the sanctioned status (`OFAC SDN`), or a public-figure tag (`Vitalik Buterin`). Generic categorical labels like `EOA`, `Contract`, `Wallet` are not considered a hit on this benchmark because they carry no entity signal."
- q: "Is Mobula's labels API better than Moralis or Helius?"
a: "It depends on the chain. {{name:mobula}} is a universal provider audited on every chain it advertises and currently returns {{p50:mobula}} on the active tab. {{name:helius}} is Solana-only and dominates that chain because its label graph is curated against native Solana programs. {{name:moralis}} is EVM-centric and trails on TON, Stellar, XRP and Bitcoin. The per-chain tabs are the honest comparison; the All chains tab is the universal-coverage story."
a: "It depends on the chain. {{name:mobula}} is a universal provider audited on every chain it advertises and currently returns {{p50:mobula}} on the active tab. {{name:helius}} is Solana-only and dominates that chain because its label graph is curated against native Solana programs. {{name:moralis}} is EVM-centric and trails on TON, Stellar, XRP and Bitcoin. The per-chain tabs are the honest comparison."
- q: "What is the alternative to Arkham or Nansen for builders?"
a: "Arkham and Nansen own the consumer visualization layer (browse-the-web-of-onchain-money). The builder side of the question, the API integrated under the hood by wallets, portfolio trackers and AML flows, lives elsewhere. Mobula, Helius, Moralis, Blockscout, OLI, TonAPI, StellarExpert, XRPScan and WalletExplorer are the labeling APIs benchmarked here. Pick the one whose coverage matches the chains your product touches and whose response shape fits your integration latency budget."
- q: "How does OpenChainBench measure wallet label coverage?"
Expand All@@ -86,7 +86,6 @@ prometheus:
# value `all` skips the filter, aggregate over every chain at once.
dimensions:
chain:
- { value: all, label: All chains }
- { value: ethereum, label: Ethereum }
- { value: solana, label: Solana }
- { value: bnb, label: BNB Chain }
Expand Down
12 changes: 11 additions & 1 deletion src/components/distribution-chart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import { fmtUnit } from "@/lib/format";
import { buildProviderColors } from "@/lib/series-colors";
import { useChartExclusion } from "@/hooks/use-chart-exclusion";
import { useAnimatedDomain } from "@/hooks/use-animated-domain";
import { useTopN } from "@/hooks/use-top-n";
import { TopNSelector } from "@/components/top-n-selector";

/**
* Latency-spread view, "percentile whisker" design.
Expand DownExpand Up@@ -73,13 +75,20 @@ export function DistributionChart({

// Sort once; sort order does NOT depend on the excluded set, so a
// row stays in its slot when toggled and the rank #N is stable.
const sorted = useMemo(
const sortedAll = useMemo(
() =>
[...live].sort((a, b) =>
higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50,
),
[live, higherIsBetter],
);
// Top-N selector — shared shape with the other chart views so the
// reader can focus on the top tail without losing the option to widen.
const { topN, setTopN, topNOptions } = useTopN(sortedAll.length);
const sorted = useMemo(
() => (topN == null ? sortedAll : sortedAll.slice(0, topN)),
[sortedAll, topN],
);

// The animated domain is driven by the *visible* set's min/max snapped
// to log-decade landmarks. Toggling a provider only moves the axis
Expand DownExpand Up@@ -134,6 +143,7 @@ export function DistributionChart({
Reset · {excluded.size} excluded
</button>
)}
<TopNSelector value={topN} options={topNOptions} onChange={setTopN} />
<WhiskerLegend />
{headerActions}
</div>
Expand Down
18 changes: 17 additions & 1 deletion src/components/donut-chart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,9 @@ import { ProviderLogo } from "@/components/provider-logo";
import { fmtUnit } from "@/lib/format";
import { buildProviderColors } from "@/lib/series-colors";
import { useChartExclusion } from "@/hooks/use-chart-exclusion";
import { useTopN } from "@/hooks/use-top-n";
import { rankResults } from "@/lib/ranking";
import { TopNSelector } from "@/components/top-n-selector";

/**
* Share-of-field donut. Each provider is a slice sized by p50 vs the
Expand DownExpand Up@@ -53,7 +55,20 @@ export function DonutChart({
() => liveResults(results),
[results],
);
const live = liveAll.filter((r) => !excluded.has(r.slug));
// Top-N selector — clip the cohort BEFORE applying exclusion so the
// "top N" semantic matches what every other view shows. Sized off
// the live provider count via the shared `useTopN` hook.
const { topN, setTopN, topNOptions } = useTopN(liveAll.length);
const liveClipped = useMemo(
() =>
topN == null
? liveAll
: [...liveAll]
.sort((a, b) => b.ms.p50 - a.ms.p50)
.slice(0, topN),
[liveAll, topN],
);
const live = liveClipped.filter((r) => !excluded.has(r.slug));
const total = live.reduce((s, r) => s + r.ms.p50, 0);
const colors = useMemo(() => buildProviderColors(results), [results]);
const sorted = useMemo(
Expand DownExpand Up@@ -117,6 +132,7 @@ export function DonutChart({
Share by p50
</p>
<div className="flex items-center gap-3">
<TopNSelector value={topN} options={topNOptions} onChange={setTopN} />
<p className="text-[11px] font-mono tabular text-ink-faint">
{live.length} of {liveAll.length} live
</p>
Expand Down
54 changes: 10 additions & 44 deletions src/components/ranked-bar-chart.tsx
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import type { Benchmark } from "@/types/benchmark";
import { fmtUnit } from "@/lib/format";
import { buildProviderColors } from "@/lib/series-colors";
import { useChartExclusion } from "@/hooks/use-chart-exclusion";
import { useTopN } from "@/hooks/use-top-n";
import { LiveDot } from "@/components/live-dot";
import { ProviderLogo } from "@/components/provider-logo";
import { TopNSelector } from "@/components/top-n-selector";

type Props = {
benchmark: Benchmark;
Expand DownExpand Up@@ -70,23 +72,10 @@ export function RankedBarChart({
}, [benchmark, colors]);

// Top-N selector — sized off the providers that actually scored on
// the headline metric (`allRows`), not the registered cohort. An N
// button only renders when at least N providers have data, and "All"
// only appears when there's enough to make filtering meaningful
// (>10). Below 10 the toolbar disappears entirely.
const scoredCount = allRows.length;
const topNOptions = useMemo<(number | null)[]>(() => {
const opts: (number | null)[] = [];
for (const n of [5, 10, 20]) if (n < scoredCount) opts.push(n);
if (scoredCount > 10) opts.push(null);
return opts;
}, [scoredCount]);
const TOP_N_DEFAULT = scoredCount > 20 ? 20 : null;
const [topN, setTopN] = useState<number | null>(TOP_N_DEFAULT);
useEffect(() => {
if (topN == null) return;
if (!topNOptions.includes(topN)) setTopN(null);
}, [topNOptions, topN]);
// the headline metric (`allRows`), via the shared `useTopN` hook so
// every chart view (ranked bar, time series, distribution, donut)
// agrees on the option set and the empty-toolbar rule.
const { topN, setTopN, topNOptions } = useTopN(allRows.length);
const rows = useMemo(() => {
if (topN == null) return allRows;
return allRows.slice(0, topN);
Expand DownExpand Up@@ -142,32 +131,9 @@ export function RankedBarChart({
{headerActions}
</div>
</div>
{topNOptions.length > 1 && (
<div className="mb-4 flex flex-wrap items-center justify-end gap-1">
<span className="mr-2 text-[10px] uppercase tracking-[0.16em] text-ink-faint">
Show
</span>
{topNOptions.map((n) => {
const active = topN === n;
const label = n == null ? "All" : `Top ${n}`;
return (
<button
key={String(n)}
type="button"
onClick={() => setTopN(n)}
className={`rounded-md border px-2 py-1 text-[11px] font-sans font-medium uppercase tracking-[0.1em] transition-all ${
active
? "border-ink bg-ink text-paper"
: "border-ink/15 bg-paper text-ink hover:border-ink/40"
}`}
aria-pressed={active}
>
{label}
</button>
);
})}
</div>
)}
<div className="mb-4 flex flex-wrap items-center justify-end gap-1">
<TopNSelector value={topN} options={topNOptions} onChange={setTopN} />
</div>
<ul className="space-y-2">
{rows.map((r) => {
const isOff = excluded.has(r.slug);
Expand Down
59 changes: 7 additions & 52 deletions src/components/time-series-chart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,9 @@ import { fmtUnit } from "@/lib/format";
import { buildProviderColors } from "@/lib/series-colors";
import { useChartExclusion } from "@/hooks/use-chart-exclusion";
import { useAnimatedDomain } from "@/hooks/use-animated-domain";
import { useTopN } from "@/hooks/use-top-n";
import { LiveDot } from "@/components/live-dot";
import { TopNSelector } from "@/components/top-n-selector";

type Props = {
benchmark: Benchmark;
Expand DownExpand Up@@ -152,32 +154,10 @@ export function TimeSeriesChart({
return built;
}, [benchmark, range, region, colors, excluded, seriesOverride]);

// Top-N selector. Sized off the providers that actually have data
// for the active range / panel — `allLines.length`, which is the
// post-filter count after empty series are dropped. Earlier this
// sized off the whole registered cohort so the toolbar would always
// offer Top 5 / 10 / 20 / All, but on panels where only e.g. 7
// builders emit data the Top 10 / Top 20 buttons were useless
// placeholders. Now the option set is curated per render: an N
// button only appears when at least N providers have data; "All"
// shows up whenever the cohort is wider than 10. Below-10 cohorts
// skip the toolbar entirely.
const scoredCount = allLines.length;
const topNOptions = useMemo<(number | null)[]>(() => {
const opts: (number | null)[] = [];
for (const n of [5, 10, 20]) if (n < scoredCount) opts.push(n);
if (scoredCount > 10) opts.push(null);
return opts;
}, [scoredCount]);
const TOP_N_DEFAULT = scoredCount > 20 ? 20 : null;
const [topN, setTopN] = useState<number | null>(TOP_N_DEFAULT);
// If the active option disappeared (e.g. reader swapped to a sparser
// panel where Top 20 is no longer offered), gracefully reset to the
// widest still-available option.
useEffect(() => {
if (topN == null) return;
if (!topNOptions.includes(topN)) setTopN(null);
}, [topNOptions, topN]);
// Top-N selector — sized off the post-filter line count via the
// shared `useTopN` hook so the option set agrees across every
// chart view on the bench page.
const { topN, setTopN, topNOptions } = useTopN(allLines.length);
const lines = useMemo(() => {
if (topN == null) return allLines;
return allLines.slice(0, topN);
Expand DownExpand Up@@ -297,32 +277,7 @@ export function TimeSeriesChart({
</div>
)}

{topNOptions.length > 1 && (
<div className="flex items-center gap-1">
<span className="mr-2 text-[10px] uppercase tracking-[0.16em] text-ink-faint">
Show
</span>
{topNOptions.map((n) => {
const active = topN === n;
const label = n == null ? "All" : `Top ${n}`;
return (
<button
key={String(n)}
type="button"
onClick={() => setTopN(n)}
className={`rounded-md border px-2 py-1 text-[11px] font-sans font-medium uppercase tracking-[0.1em] transition-all ${
active
? "border-ink bg-ink text-paper"
: "border-ink/15 bg-paper text-ink hover:border-ink/40"
}`}
aria-pressed={active}
>
{label}
</button>
);
})}
</div>
)}
<TopNSelector value={topN} options={topNOptions} onChange={setTopN} />
</div>

{lines.length === 0 ? (
Expand Down
47 changes: 47 additions & 0 deletions src/components/top-n-selector.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
"use client";

/**
* Shared button group for chart Top-N selection. Renders nothing when
* the option set is empty so the caller can include it unconditionally.
* The active button styling mirrors the range / region tabs across
* the chart toolbars so the whole control row reads as one unit.
*/
export function TopNSelector({
value,
options,
onChange,
className = "",
}: {
value: number | null;
options: (number | null)[];
onChange: (n: number | null) => void;
className?: string;
}) {
if (options.length === 0) return null;
return (
<div className={`flex items-center gap-1 ${className}`}>
<span className="mr-2 text-[10px] uppercase tracking-[0.16em] text-ink-faint">
Show
</span>
{options.map((n) => {
const active = value === n;
const label = n == null ? "All" : `Top ${n}`;
return (
<button
key={String(n)}
type="button"
onClick={() => onChange(n)}
className={`rounded-md border px-2 py-1 text-[11px] font-sans font-medium uppercase tracking-[0.1em] transition-all ${
active
? "border-ink bg-ink text-paper"
: "border-ink/15 bg-paper text-ink hover:border-ink/40"
}`}
aria-pressed={active}
>
{label}
</button>
);
})}
</div>
);
}
42 changes: 42 additions & 0 deletions src/hooks/use-top-n.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
"use client";

import { useEffect, useMemo, useState } from "react";

/**
* Shared Top-N selector state for chart views. Sized off the count
* of providers that actually have data on the active metric — passing
* the raw cohort makes the toolbar offer useless options (Top 10 when
* only 7 providers scored). The hook curates the option set so an N
* button only appears when at least N+1 providers exist, plus an
* "All" anchor whenever any filtering option is offered.
*
* Returned `topN`:
* - `null` means "show every provider that has data"
* - `number` means "slice to the first N (already sorted upstream)"
*
* Returned `topNOptions` is the exact button list the chart should
* render, in order. Hide the toolbar entirely when the array is empty
* (cohort too sparse for filtering to matter).
*
* `useEffect` gracefully resets to "All" when the active selection
* disappears from the option set (reader swapped to a sparser panel).
*/
export function useTopN(scoredCount: number): {
topN: number | null;
setTopN: (n: number | null) => void;
topNOptions: (number | null)[];
} {
const topNOptions = useMemo<(number | null)[]>(() => {
const opts: (number | null)[] = [];
for (const n of [5, 10, 20]) if (n < scoredCount) opts.push(n);
if (opts.length > 0) opts.push(null);
return opts;
}, [scoredCount]);
const initial = topNOptions[0] ?? null;
const [topN, setTopN] = useState<number | null>(initial);
useEffect(() => {
if (topN == null) return;
if (!topNOptions.includes(topN)) setTopN(null);
}, [topNOptions, topN]);
return { topN, setTopN, topNOptions };
}
Loading