From 17ca7b2bd09cc5355093d79a31f307590e47ef01 Mon Sep 17 00:00:00 2001
From: Florent Tapponnier
Date: Tue, 2 Jun 2026 18:05:59 +0200
Subject: [PATCH] feat(network-fees): L1/L2 toggle + 9 L2 providers
---
benchmarks/network-fees.yml | 128 +++++++++++++++++++++++++++++-
src/components/benchmark-body.tsx | 37 ++++++++-
src/components/layer-filter.tsx | 63 +++++++++++++++
src/lib/spec-schema.ts | 10 +++
src/lib/spec.ts | 3 +
src/types/benchmark.ts | 9 +++
6 files changed, 244 insertions(+), 6 deletions(-)
create mode 100644 src/components/layer-filter.tsx
diff --git a/benchmarks/network-fees.yml b/benchmarks/network-fees.yml
index 2a75701a..6a679cd3 100644
--- a/benchmarks/network-fees.yml
+++ b/benchmarks/network-fees.yml
@@ -2,10 +2,10 @@
slug: network-fees
number: "031"
-title: Current native transfer fee across L1 blockchains
-seo_title: "Cheapest blockchain transaction fee 2026: Ethereum, Solana, BNB, Avalanche, TRON, Cardano, SUI, TON, Stellar, Litecoin, Monero USD live"
-seo_description: "Live USD cost of a native-token transfer on 11 L1 chains: Ethereum, Solana, BNB, Avalanche, TRON, Cardano, SUI, TON, Stellar, Litecoin, Monero. Slow/standard/fast tiers refreshed every 30s, USD price from Mobula."
-subtitle: "Current USD cost of moving the chain's native asset, refreshed every 30 seconds for the same 11 L1s tracked on the finality bench."
+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_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.
@@ -83,6 +83,7 @@ prometheus:
providers:
- slug: ethereum
name: Ethereum
+ layer: l1
tag: EIP-1559 fee market, 21000 gas × (base + p50 priority)
formula: "Median USD cost of a native ETH transfer over 24h: (base_fee + p50 priority_fee) × 21000 gas × ETH USD price."
queries:
@@ -95,6 +96,7 @@ providers:
- slug: bnb
name: BNB Chain
+ layer: l1
tag: EIP-1559 fee market, 21000 gas
formula: "Median USD cost of a native BNB transfer over 24h."
queries:
@@ -107,6 +109,7 @@ providers:
- slug: avalanche
name: Avalanche
+ layer: l1
tag: C-Chain EIP-1559 fee market, 21000 gas
formula: "Median USD cost of a native AVAX transfer over 24h."
queries:
@@ -119,6 +122,7 @@ providers:
- slug: solana
name: Solana
+ layer: l1
tag: 5000 lamport base + getRecentPrioritizationFees × 200 CU
formula: "Median USD cost of a native SOL transfer over 24h: 5000 lamports + p50 priority × 200 CU × SOL USD price."
queries:
@@ -131,6 +135,7 @@ providers:
- slug: tron
name: TRON
+ layer: l1
tag: Bandwidth model, 268 bytes × getTransactionFee SUN/byte
formula: "Deterministic USD cost of a native TRX transfer using the bandwidth-rate parameter × 268 bytes typical transfer × TRX USD price."
queries:
@@ -143,6 +148,7 @@ providers:
- slug: cardano
name: Cardano
+ layer: l1
tag: min_fee_b + size × min_fee_a, deterministic
formula: "Protocol-deterministic ADA transfer cost: koios live min_fee_a/b × 250 bytes typical × ADA USD price."
queries:
@@ -155,6 +161,7 @@ providers:
- slug: sui
name: Sui
+ layer: l1
tag: suix_getReferenceGasPrice × 2_000_000 gas budget
formula: "USD cost of a native SUI transfer: live reference gas price × 2M gas budget × SUI USD price."
queries:
@@ -167,6 +174,7 @@ providers:
- slug: ton
name: TON
+ layer: l1
tag: Hardcoded 0.005 TON typical wallet-v4 transfer
formula: "Conservative typical-observed cost of a TON wallet transfer × TON USD price. TON has no fee-estimate RPC; this is the observed median."
queries:
@@ -179,6 +187,7 @@ providers:
- slug: stellar
name: Stellar
+ layer: l1
tag: 100 stroops base per operation, surge during congestion
formula: "Live horizon fee_stats.last_ledger_base_fee × 1 op × XLM USD price."
queries:
@@ -191,6 +200,7 @@ providers:
- slug: litecoin
name: Litecoin
+ layer: l1
tag: litecoinspace fee oracle × 225 vBytes
formula: "Mempool-based litoshi/vByte × 225 vBytes typical P2WPKH transfer × LTC USD price."
queries:
@@ -203,6 +213,7 @@ providers:
- slug: monero
name: Monero
+ layer: l1
tag: get_fee_estimate × 1500 bytes RingCT
formula: "Monero-rpc fee per byte × 1500 bytes typical 1-in-2-out RingCT × XMR USD price."
queries:
@@ -212,3 +223,112 @@ providers:
mean: avg_over_time(tx_fee_native_transfer_usd{chain="monero",tier="std"}[24h])
success: avg_over_time(tx_fee_health{chain="monero"}[24h])
series: tx_fee_native_transfer_usd{chain="monero",tier="std"}
+
+ - slug: arbitrum
+ name: Arbitrum One
+ layer: l2
+ tag: "Optimistic rollup (Nitro), 21000 gas"
+ formula: "Median USD cost of a native ETH transfer on Arbitrum One over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="arbitrum",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="arbitrum",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="arbitrum",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="arbitrum",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="arbitrum"}[24h])
+ series: tx_fee_native_transfer_usd{chain="arbitrum",tier="std"}
+ - slug: optimism
+ name: Optimism
+ layer: l2
+ tag: "OP Stack optimistic rollup, 21000 gas"
+ formula: "Median USD cost of a native ETH transfer on Optimism over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="optimism",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="optimism",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="optimism",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="optimism",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="optimism"}[24h])
+ series: tx_fee_native_transfer_usd{chain="optimism",tier="std"}
+ - slug: base
+ name: Base
+ layer: l2
+ tag: "OP Stack optimistic rollup (Coinbase), 21000 gas"
+ formula: "Median USD cost of a native ETH transfer on Base over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="base",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="base",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="base",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="base",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="base"}[24h])
+ series: tx_fee_native_transfer_usd{chain="base",tier="std"}
+ - slug: zksync
+ name: zkSync Era
+ layer: l2
+ tag: "ZK rollup, 21000 gas"
+ formula: "Median USD cost of a native ETH transfer on zkSync Era over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="zksync",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="zksync",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="zksync",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="zksync",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="zksync"}[24h])
+ series: tx_fee_native_transfer_usd{chain="zksync",tier="std"}
+ - slug: linea
+ name: Linea
+ layer: l2
+ tag: "ZK rollup (Stark), 21000 gas"
+ formula: "Median USD cost of a native ETH transfer on Linea over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="linea",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="linea",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="linea",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="linea",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="linea"}[24h])
+ series: tx_fee_native_transfer_usd{chain="linea",tier="std"}
+ - slug: scroll
+ name: Scroll
+ layer: l2
+ tag: "zkEVM rollup, 21000 gas"
+ formula: "Median USD cost of a native ETH transfer on Scroll over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="scroll",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="scroll",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="scroll",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="scroll",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="scroll"}[24h])
+ series: tx_fee_native_transfer_usd{chain="scroll",tier="std"}
+ - slug: blast
+ name: Blast
+ layer: l2
+ tag: "OP Stack optimistic rollup (Blast), 21000 gas"
+ formula: "Median USD cost of a native ETH transfer on Blast over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="blast",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="blast",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="blast",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="blast",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="blast"}[24h])
+ series: tx_fee_native_transfer_usd{chain="blast",tier="std"}
+ - slug: mantle
+ name: Mantle
+ layer: l2
+ tag: "OP Stack optimistic rollup (Mantle), 21000 gas"
+ formula: "Median USD cost of a native ETH transfer on Mantle over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="mantle",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="mantle",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="mantle",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="mantle",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="mantle"}[24h])
+ series: tx_fee_native_transfer_usd{chain="mantle",tier="std"}
+ - slug: taiko
+ name: Taiko
+ layer: l2
+ tag: "Based rollup, 21000 gas"
+ formula: "Median USD cost of a native ETH transfer on Taiko over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)."
+ queries:
+ p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="taiko",tier="std"}[24h])
+ p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="taiko",tier="fast"}[24h])
+ p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="taiko",tier="fast"}[24h])
+ mean: avg_over_time(tx_fee_native_transfer_usd{chain="taiko",tier="std"}[24h])
+ success: avg_over_time(tx_fee_health{chain="taiko"}[24h])
+ series: tx_fee_native_transfer_usd{chain="taiko",tier="std"}
diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx
index 36c8cf5c..1e4e5a3a 100644
--- a/src/components/benchmark-body.tsx
+++ b/src/components/benchmark-body.tsx
@@ -12,6 +12,7 @@ import { DistributionChart } from "@/components/distribution-chart";
import { DonutChart } from "@/components/donut-chart";
import { RegionGrid } from "@/components/region-grid";
import { MetricViewTabs } from "@/components/metric-view-tabs";
+import { LayerFilter, type LayerSelection } from "@/components/layer-filter";
import { CountLeaderboard } from "@/components/count-leaderboard";
import { SummaryStat } from "@/components/summary-stat";
import { ViewSwitcher } from "@/components/view-switcher";
@@ -143,23 +144,29 @@ export function BenchmarkBody({
const searchParams = useSearchParams();
const urlChain = searchParams.get("chain");
const urlRegion = searchParams.get("region");
+ const urlLayer = searchParams.get("layer");
const resolvedInitialChain =
(urlChain && chainOptions.find((c) => c.value === urlChain)?.value) ?? initialChain;
const resolvedInitialRegion =
(urlRegion && regionOptions.find((r) => r.value === urlRegion)?.value) ?? initialRegion;
+ const resolvedInitialLayer: LayerSelection =
+ urlLayer === "l1" || urlLayer === "l2" ? urlLayer : "all";
const [chain, setChain] = useState(resolvedInitialChain);
const [region, setRegion] = useState(resolvedInitialRegion);
+ const [layer, setLayer] = useState(resolvedInitialLayer);
useEffect(() => {
const url = new URL(window.location.href);
syncParam(url, "chain", chain, chainOptions);
syncParam(url, "region", region, regionOptions);
+ if (layer === "all") url.searchParams.delete("layer");
+ else url.searchParams.set("layer", layer);
const next = url.pathname + (url.search ? url.search : "");
if (next !== window.location.pathname + window.location.search) {
window.history.replaceState(null, "", next);
}
- }, [chain, region, chainOptions, regionOptions]);
+ }, [chain, region, layer, chainOptions, regionOptions]);
const fallbackChain = chainOptions[0]?.value ?? null;
const fallbackRegion = regionOptions[0]?.value ?? null;
@@ -171,6 +178,29 @@ export function BenchmarkBody({
Object.values(variants)[0];
if (!benchmark) return null;
+ // L1/L2 filter counts, derived from the full unfiltered results so the
+ // pill counts are stable as the user toggles the filter.
+ const layerCounts = useMemo(() => {
+ let l1 = 0;
+ let l2 = 0;
+ for (const r of benchmark.results) {
+ if (r.layer === "l1") l1++;
+ else if (r.layer === "l2") l2++;
+ }
+ return { all: benchmark.results.length, l1, l2 };
+ }, [benchmark.results]);
+
+ // Filtered benchmark for the ledger table only. Charts above keep the
+ // full provider set so the chart vs ledger don't disagree on which
+ // providers exist.
+ const ledgerBenchmark = useMemo(() => {
+ if (layer === "all") return benchmark;
+ return {
+ ...benchmark,
+ results: benchmark.results.filter((r) => r.layer === layer),
+ };
+ }, [benchmark, layer]);
+
const isDraft = benchmark.status === "draft";
const { fieldMin, fieldMedian, fieldMax, tailMin, tailMax, tailSpread } =
computeFieldStats(benchmark.results);
@@ -388,7 +418,10 @@ export function BenchmarkBody({
? `Product ledger · sorted by ${activePanel.label}`
: "Product ledger · sorted by p50"}
-
+ {layerCounts.all > 0 && (layerCounts.l1 > 0 || layerCounts.l2 > 0) && (
+
+ )}
+
{benchmark.unit !== "count" &&
diff --git a/src/components/layer-filter.tsx b/src/components/layer-filter.tsx
new file mode 100644
index 00000000..1bf6d338
--- /dev/null
+++ b/src/components/layer-filter.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+/**
+ * Three-pill filter (All / L1 / L2) shown above the ledger table on
+ * benches that mix L1 and L2 providers (currently only `network-fees`).
+ *
+ * Pure presentation: holds no state. The parent (benchmark-body) owns
+ * the active layer and passes it down with the swap callback. The
+ * parent also decides whether to render this at all — when no provider
+ * carries a `layer` field, the filter stays hidden.
+ */
+export type LayerSelection = "all" | "l1" | "l2";
+
+export function LayerFilter({
+ selected,
+ onSelect,
+ counts,
+}: {
+ selected: LayerSelection;
+ onSelect: (v: LayerSelection) => void;
+ counts: { all: number; l1: number; l2: number };
+}) {
+ return (
+
+
+ Layer
+
+
onSelect("all")} />
+ onSelect("l1")} />
+ onSelect("l2")} />
+
+ );
+}
+
+function Pill({
+ label,
+ count,
+ active,
+ onClick,
+}: {
+ label: string;
+ count: number;
+ active: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/src/lib/spec-schema.ts b/src/lib/spec-schema.ts
index 743cc9bb..63c8cb72 100644
--- a/src/lib/spec-schema.ts
+++ b/src/lib/spec-schema.ts
@@ -114,6 +114,12 @@ export const ProviderType = z.enum([
"relay", // relayer / settlement network (Relay, deBridge solver layer)
]);
+/** Network-layer classification used by benches that mix L1 and L2 chains
+ * in the same provider set (e.g. network-fees). Powers the L1/L2/All
+ * toggle pill on the bench page so readers can split the leaderboard
+ * by execution layer without leaving the page. */
+export const ProviderLayer = z.enum(["l1", "l2"]);
+
const provider = z.object({
/** Stable identifier. also used as the metric label. */
slug,
@@ -128,6 +134,10 @@ const provider = z.object({
/** Optional architectural category. When set, a badge appears next to
* the name so readers understand the comparison. */
type: ProviderType.optional(),
+ /** Optional network-layer classification. When set, a bench that mixes
+ * L1 and L2 providers (network-fees) renders an L1/L2/All toggle pill
+ * that filters the ledger by this field. */
+ layer: ProviderLayer.optional(),
/** Optional secondary metric (e.g. "Chains covered") shown in the table. */
secondary: z
.object({ label: z.string(), value: z.string() })
diff --git a/src/lib/spec.ts b/src/lib/spec.ts
index 33ca2784..bc57f4a8 100644
--- a/src/lib/spec.ts
+++ b/src/lib/spec.ts
@@ -354,6 +354,7 @@ async function specToBenchmark(
slug: p.slug,
tag: p.tag,
type: p.type,
+ layer: p.layer,
ms: { p50: 0, p90: 0, p99: 0, mean: 0 },
successRate: 0,
secondary: p.secondary,
@@ -611,6 +612,7 @@ async function tryLoadLive(
slug: p.slug,
tag: p.tag,
type: p.type,
+ layer: p.layer,
ms: { p50, p90, p99, mean: mean ?? p50 },
slots:
slotP50 != null && slotP99 != null
@@ -754,6 +756,7 @@ function draftBenchmark(
slug: p.slug,
tag: p.tag,
type: p.type,
+ layer: p.layer,
ms: { p50: 0, p90: 0, p99: 0, mean: 0 },
successRate: 0,
secondary: p.secondary,
diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts
index b1c82fc7..9843454f 100644
--- a/src/types/benchmark.ts
+++ b/src/types/benchmark.ts
@@ -6,6 +6,11 @@
export type ProviderType = "protocol" | "aggregator" | "intent" | "relay";
+/** Network-layer classification used by benches that mix L1 and L2 chains
+ * in the same provider set (network-fees). Drives the L1/L2/All toggle
+ * pill rendered above the ledger table. */
+export type ProviderLayer = "l1" | "l2";
+
/**
* Per-provider data availability. Drives whether the leaderboard renders
* the numbers or a "currently unavailable" stub.
@@ -25,6 +30,10 @@ export type ProviderResult = {
/** Architectural category. Drives the small badge next to the name so
* readers know whether comparisons are apples-to-apples. */
type?: ProviderType;
+ /** Optional network-layer classification. When set on every provider of
+ * a bench, the bench page renders an L1/L2/All toggle pill above the
+ * ledger that filters rows by this field. */
+ layer?: ProviderLayer;
ms: { p50: number; p90: number; p99: number; mean: number };
/** Optional slot-level companion to ms. Used by Solana-native benches
* where confirmation is a slot-level event (~400 ms granularity).