From d14eed5497f80d9bff725f9d0cd439dd74a16d25 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Wed, 3 Jun 2026 11:33:44 +0200 Subject: [PATCH 1/3] sync(topN): lift Top-N to parent so chart and ledger share one value --- src/components/distribution-chart.tsx | 6 +++- src/components/donut-chart.tsx | 4 ++- src/components/ledger-table.tsx | 6 ++-- src/components/ranked-bar-chart.tsx | 4 ++- src/components/time-series-chart.tsx | 4 ++- src/hooks/use-top-n.ts | 45 +++++++++++++++------------ 6 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/components/distribution-chart.tsx b/src/components/distribution-chart.tsx index 77e85fb4..9092d744 100644 --- a/src/components/distribution-chart.tsx +++ b/src/components/distribution-chart.tsx @@ -50,6 +50,7 @@ export function DistributionChart({ excluded: controlledExcluded, onToggleExclude, onResetExcluded, + topNControl, headerActions, }: { benchmark: Benchmark; @@ -61,6 +62,7 @@ export function DistributionChart({ * on the same baseline as the chart title instead of floating in * the card corner or eating a footer row of its own. */ headerActions?: ReactNode; + topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; }) { const { results, unit, higherIsBetter } = benchmark; const { excluded, toggle, reset } = useChartExclusion( @@ -68,6 +70,8 @@ export function DistributionChart({ onToggleExclude, onResetExcluded, ); + // topNControl is destructured from the function signature below — accept it via Props. + const colors = useMemo(() => buildProviderColors(results), [results]); @@ -84,7 +88,7 @@ export function DistributionChart({ ); // 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 { topN, setTopN, topNOptions } = useTopN(sortedAll.length, { external: topNControl }); const sorted = useMemo( () => (topN == null ? sortedAll : sortedAll.slice(0, topN)), [sortedAll, topN], diff --git a/src/components/donut-chart.tsx b/src/components/donut-chart.tsx index 7487b042..fd1e0b75 100644 --- a/src/components/donut-chart.tsx +++ b/src/components/donut-chart.tsx @@ -38,12 +38,14 @@ export function DonutChart({ benchmark, excluded: controlledExcluded, onToggleExclude, + topNControl, headerActions, }: { benchmark: Benchmark; excluded?: Set; onToggleExclude?: (slug: string) => void; headerActions?: ReactNode; + topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; }) { const { results } = benchmark; const { excluded, toggle } = useChartExclusion( @@ -58,7 +60,7 @@ export function DonutChart({ // 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 { topN, setTopN, topNOptions } = useTopN(liveAll.length, { external: topNControl }); const liveClipped = useMemo( () => topN == null diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index f5350a8e..ae6b081f 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -20,6 +20,7 @@ type Props = { * providers in the same order. When null/undefined the ledger uses * the headline p50 metric. */ activePanel?: MetricPanel | null; + topN?: number | null; }; /** @@ -29,7 +30,7 @@ type Props = { * to recognition; sort order remains mechanical (ascending p50) and no * row is highlighted as the "winner". */ -export function LedgerTable({ benchmark, activePanel }: Props) { +export function LedgerTable({ benchmark, activePanel, topN }: Props) { const { results, extras } = benchmark; const unit = activePanel?.unit ?? benchmark.unit; const higherIsBetter = activePanel?.higherIsBetter ?? benchmark.higherIsBetter; @@ -66,7 +67,7 @@ export function LedgerTable({ benchmark, activePanel }: Props) { // The chart's panel tabs still surface those providers via // seriesByProvider when the reader switches metric, so coverage isn't // lost — only the noisy ledger rows are pruned. - const sorted = [...results] + const sortedAll = [...results] .filter((r) => { if (activePanel) { const v = activePanel.values[r.slug]; @@ -79,6 +80,7 @@ export function LedgerTable({ benchmark, activePanel }: Props) { const bv = pickValue(b); return higherIsBetter ? bv - av : av - bv; }); + const sorted = topN == null ? sortedAll : sortedAll.slice(0, topN); const colors = useMemo(() => buildProviderColors(results), [results]); const allSeries = Object.values(extras.series24h).flat(); diff --git a/src/components/ranked-bar-chart.tsx b/src/components/ranked-bar-chart.tsx index 544637e4..98dfee13 100644 --- a/src/components/ranked-bar-chart.tsx +++ b/src/components/ranked-bar-chart.tsx @@ -22,6 +22,7 @@ type Props = { /** Optional slot rendered in the chart's header row, right-aligned. * BenchmarkBody passes the here. */ headerActions?: import("react").ReactNode; + topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; }; export function RankedBarChart({ @@ -29,6 +30,7 @@ export function RankedBarChart({ excluded: controlledExcluded, onToggleExclude, onResetExcluded, + topNControl, headerActions, }: Props) { const { excluded, toggle, reset } = useChartExclusion( @@ -75,7 +77,7 @@ export function RankedBarChart({ // 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 { topN, setTopN, topNOptions } = useTopN(allRows.length, { external: topNControl }); const rows = useMemo(() => { if (topN == null) return allRows; return allRows.slice(0, topN); diff --git a/src/components/time-series-chart.tsx b/src/components/time-series-chart.tsx index 3ae652fc..0626a608 100644 --- a/src/components/time-series-chart.tsx +++ b/src/components/time-series-chart.tsx @@ -34,6 +34,7 @@ type Props = { seriesOverride?: Record; metricLabelOverride?: string; unitOverride?: Benchmark["unit"]; + topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; }; type Range = "1h" | "6h" | "24h" | "7d" | "30d"; @@ -72,6 +73,7 @@ export function TimeSeriesChart({ metricLabelOverride, unitOverride, onResetExcluded, + topNControl, headerActions, }: Props) { const [range, setRange] = useState("24h"); @@ -157,7 +159,7 @@ export function TimeSeriesChart({ // 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 { topN, setTopN, topNOptions } = useTopN(allLines.length, { external: topNControl }); const lines = useMemo(() => { if (topN == null) return allLines; return allLines.slice(0, topN); diff --git a/src/hooks/use-top-n.ts b/src/hooks/use-top-n.ts index 15072169..cb614545 100644 --- a/src/hooks/use-top-n.ts +++ b/src/hooks/use-top-n.ts @@ -4,39 +4,44 @@ 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. + * of providers that actually have data on the active metric. * - * Returned `topN`: - * - `null` means "show every provider that has data" - * - `number` means "slice to the first N (already sorted upstream)" + * Two control modes: + * - Uncontrolled (default): the hook owns the value via useState. + * - Controlled via `options.external`: the parent owns the value so + * every chart view + the ledger can share one Top-N selection. * - * 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). + * `options.disabled` short-circuits the option set (returns []) which + * hides the selector entirely. */ -export function useTopN(scoredCount: number): { +export function useTopN( + scoredCount: number, + options?: { + disabled?: boolean; + external?: { topN: number | null; setTopN: (n: number | null) => void }; + }, +): { topN: number | null; setTopN: (n: number | null) => void; topNOptions: (number | null)[]; } { + const disabled = options?.disabled === true; + const external = options?.external; const topNOptions = useMemo<(number | null)[]>(() => { + if (disabled) return []; 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]); + }, [scoredCount, disabled]); const initial = topNOptions[0] ?? null; - const [topN, setTopN] = useState(initial); + const [topNLocal, setTopNLocal] = useState(initial); + const topN = external ? external.topN : topNLocal; + const setTopN = external ? external.setTopN : setTopNLocal; useEffect(() => { - if (topN == null) return; - if (!topNOptions.includes(topN)) setTopN(null); - }, [topNOptions, topN]); + if (external) return; + if (topNLocal == null) return; + if (!topNOptions.includes(topNLocal)) setTopNLocal(null); + }, [topNOptions, topNLocal, external]); return { topN, setTopN, topNOptions }; } From a8c9dc50f972924e092062881834d4d394ba20e6 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Wed, 3 Jun 2026 13:03:39 +0200 Subject: [PATCH 2/3] docs(network-fees): drop the chain count from subtitle + seo description User reads cleaner without the number; the list of chains right after it already says which 20 are tracked. --- benchmarks/network-fees.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmarks/network-fees.yml b/benchmarks/network-fees.yml index 6a679cd3..c1de25c4 100644 --- a/benchmarks/network-fees.yml +++ b/benchmarks/network-fees.yml @@ -4,8 +4,8 @@ slug: network-fees number: "031" title: Current native transfer fee across L1 and L2 blockchains seo_title: "Cheapest blockchain transaction fee 2026: L1 and L2 USD live (Ethereum, Arbitrum, Optimism, Base, zkSync, Solana, BNB, Avalanche)" -seo_description: "Live USD cost of a native-token transfer across 20 L1 and L2 chains. L1: Ethereum, Solana, BNB, Avalanche, TRON, Cardano, SUI, TON, Stellar, Litecoin, Monero. L2: Arbitrum, Optimism, Base, zkSync, Linea, Scroll, Blast, Mantle, Taiko. Slow/standard/fast tiers refreshed every 30s." -subtitle: "Current USD cost of moving the chain's native asset across 20 L1 and L2 chains, refreshed every 30 seconds." +seo_description: "Live USD cost of a native-token transfer across L1 and L2 chains. L1: Ethereum, Solana, BNB, Avalanche, TRON, Cardano, SUI, TON, Stellar, Litecoin, Monero. L2: Arbitrum, Optimism, Base, zkSync, Linea, Scroll, Blast, Mantle, Taiko. Slow/standard/fast tiers refreshed every 30s." +subtitle: "Current USD cost of moving the chain's native asset across L1 and L2 chains, refreshed every 30 seconds." seo_intro: | This page shows the live USD cost of sending the native token on every major Layer-1 blockchain. We sample the chain-native fee market (eth_feeHistory for EVM chains, getRecentPrioritizationFees for Solana, koios protocol params for Cardano, fee_stats for Stellar, fee_estimate for Monero, mempool oracles for Litecoin, getChainParameters for TRON, sui_getReferenceGasPrice for SUI, hardcoded typical observed for TON), convert to the chain's smallest unit, then multiply by the native-token USD price pulled from Mobula every 30 seconds. The result is a head-to-head comparison of what it actually costs an end user to move one unit of value on each chain, rather than a gas-price comparison in gwei or lamports which is meaningless across protocols. From 7b933918ccb79caa698bd6915769a2cc732cafaf Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Wed, 3 Jun 2026 15:37:47 +0200 Subject: [PATCH 3/3] docs(network-fees): align prose with current L1+L2 coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several leftover phrases from the L1-only era contradicted the title/ seo description (which already say L1 and L2): - FAQ 'How are L2 fees handled?' said 'not on this page' — replaced with the actual sampling logic for L2. - Abstract said 'each L1 chain' and '11-chain list'. - Methodology said 'all 11 chains' and 'all 11 native tokens'. - Findings called Ethereum 'the most expensive L1'. All updated to either 'L1 and L2' or just 'tracked chains' so the copy doesn't need a manual edit every time we add a chain. --- benchmarks/network-fees.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/benchmarks/network-fees.yml b/benchmarks/network-fees.yml index c1de25c4..1797b1a1 100644 --- a/benchmarks/network-fees.yml +++ b/benchmarks/network-fees.yml @@ -12,7 +12,7 @@ seo_intro: | faq: - q: "What does this benchmark measure?" - a: "The current USD cost of a single native-token transfer on each of the 11 tracked L1 chains. A native transfer is the simplest possible action: send the chain's main asset from one address to another. ETH on Ethereum, SOL on Solana, ADA on Cardano, XLM on Stellar, and so on. We do not measure ERC-20 transfers, DEX swaps, or contract calls." + a: "The current USD cost of a single native-token transfer on each tracked L1 and L2 chain. A native transfer is the simplest possible action: send the chain's main asset from one address to another. ETH on Ethereum, SOL on Solana, ADA on Cardano, XLM on Stellar, and so on. We do not measure ERC-20 transfers, DEX swaps, or contract calls." - q: "Why USD instead of gas price?" a: "Gas price in gwei (Ethereum) cannot be compared to lamports per CU (Solana) or stroops per operation (Stellar). The only honest cross-chain unit is the dollar cost of a user-facing action, computed at scrape time using a live USD price for each native token. Mobula's market API provides the prices we multiply by." - q: "What do slow, standard, and fast tiers mean?" @@ -22,7 +22,7 @@ faq: - q: "Why is the Cardano fee always similar?" a: "Cardano fees are deterministic. The protocol parameters (`min_fee_a` per byte, `min_fee_b` base) are set by governance and updated rarely; a standard ADA transfer is roughly 250 bytes, so the lovelace cost is essentially fixed until the next parameter vote. The USD figure on the leaderboard only moves because ADA's USD price moves." - q: "How are L2 fees handled?" - a: "Not on this page. L2 fees are a separate measurement problem (rollup fee = L2 execution + L1 data posting, the latter being a non-trivial blob market). The L2 bench is in progress as a companion to this L1 page; once shipped, you'll be able to compare L1-fee vs L2-fee for any wallet decision in one click." + a: "L2 native-transfer fees are sampled the same way as L1: query the chain's fee oracle (`eth_feeHistory` on rollups with EVM RPC) and multiply by the L2's native-token USD price. The number you see is the user-facing cost only; the L2 sequencer absorbs the L1 data-posting component into the gas price under the hood, so this leaderboard reflects what an end user pays, not the rollup's internal economics." - q: "How often does the page refresh?" a: "Every 30 seconds. The harness re-queries each chain's fee oracle and Mobula's price API every 30 seconds, so headline values are at most 30 seconds stale plus chain RPC latency (typically under a second)." - q: "Why are some chains using single tier?" @@ -35,18 +35,18 @@ unit: usd higher_is_better: false abstract: | - Every 30 seconds we ask each L1 chain its current native-transfer fee - in the chain's smallest unit (wei, lamport, lovelace, stroop, sun, - MIST, nanoton, litoshi, atomic), multiply by the live USD price of - the chain's native token from Mobula's market API, and publish the - result. Chains with a priority market expose three tiers (slow / std - / fast) mapped to roughly the 25th, 50th and 90th percentile of the - recent fee distribution. Deterministic-fee chains emit a single tier. - The 11-chain list mirrors the L1 finality bench so users can compare - cost and speed head-to-head. + Every 30 seconds we ask each tracked L1 and L2 chain its current + native-transfer fee in the chain's smallest unit (wei, lamport, + lovelace, stroop, sun, MIST, nanoton, litoshi, atomic), multiply by + the live USD price of the chain's native token from Mobula's market + API, and publish the result. Chains with a priority market expose + three tiers (slow / std / fast) mapped to roughly the 25th, 50th and + 90th percentile of the recent fee distribution. Deterministic-fee + chains emit a single tier. The L1 list mirrors the L1 finality bench + so users can compare cost and speed head-to-head. methodology: - - "Refresh cadence: 30 s. One process samples all 11 chains in parallel goroutines." + - "Refresh cadence: 30 s. One process samples all tracked chains in parallel goroutines." - "Ethereum / BNB / Avalanche (EVM): `eth_feeHistory` over the last 4 blocks at percentiles [25, 50, 90]. Cost = (base_fee + reward_percentile) × 21_000 gas, mapped to slow/std/fast." - "Solana: `getRecentPrioritizationFees` percentiles 25/50/90 of micro-lamports per CU × 200 CU + 5_000 lamport base. Empty-fees response collapses to single std tier at the 5_000 base." - "TRON: `getChainParameters.getTransactionFee` (currently 1_000 SUN/byte) × 268 bytes typical native transfer." @@ -56,12 +56,12 @@ methodology: - "TON: hardcoded 0.005 TON, the typical observed wallet-v4 transfer. TON's fee model is BoC-emulation only; a clean fee-estimate RPC does not exist." - "Litecoin: litecoinspace.org `/api/v1/fees/recommended` (hourFee / halfHourFee / fastestFee in litoshi/vByte) × 225 vBytes typical 1-in-1-out P2WPKH transfer." - "Monero: monero-rpc `get_fee_estimate.fees[0..2]` × 1500 bytes typical 1-in-2-out RingCT transaction." - - "USD prices: `api.mobula.io/api/1/market/multi-data` polled every 30 s for all 11 native tokens in one call." + - "USD prices: `api.mobula.io/api/1/market/multi-data` polled every 30 s for every tracked native token in one call." - "Failures: any upstream error leaves the previous gauge in place, increments `tx_fee_fetch_errors_total{chain, error_type}`, and sets `tx_fee_health{chain}=0`." findings: - - "{{best_name}} is the cheapest L1 native transfer at {{best_p50}} (std, 24h)." - - "{{name:ethereum}} sits at {{p50:ethereum}} (std, 24h), the most expensive L1 transfer on this leaderboard during normal congestion." + - "{{best_name}} is the cheapest native transfer at {{best_p50}} (std, 24h)." + - "{{name:ethereum}} sits at {{p50:ethereum}} (std, 24h), the most expensive transfer on this leaderboard during normal congestion." - "{{name:bnb}}, {{name:avalanche}}, {{name:stellar}}, {{name:solana}}, {{name:litecoin}} cluster near or below one cent for a standard transfer." - "{{name:cardano}}, {{name:tron}}, {{name:monero}} use deterministic or near-deterministic fee models; USD movement on the leaderboard reflects native-token price movement, not network congestion." - "USD costs are computed at scrape time. A 10% intraday move in the native token's USD price moves the headline by 10% even if the chain-native fee is flat. We surface that intentionally — the dollar cost is what a user actually pays."