Skip to content
Open
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
26 changes: 15 additions & 11 deletions benchmarks/rpc-keyed-latency.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ seo_intro: |
Singapore, staying far inside every provider's free quota. Chains
covered. Ethereum (5 providers), Base + Arbitrum + BNB + Polygon
(Alchemy, Infura, Ankr, Chainstack), Optimism (Alchemy, Infura,
Chainstack, QuickNode), Solana (Alchemy, Helius, Chainstack). Current leaders per the live data on this page.
Chainstack, QuickNode), Robinhood Chain (Chainstack, Alchemy — Arbitrum
Orbit L2, chain ID 4663, ~100ms blocks), Solana (Alchemy, Helius,
Chainstack). Current leaders per the live data on this page.
The companion question, which RPC works with no signup at all, is
answered by the [rpc-capabilities](/benchmarks/rpc-capabilities)
bench with the identical methodology, so the keyed premium (or its
Expand All @@ -54,7 +56,7 @@ methodology:
- "Authentication: each provider's standard free-tier key, obtained via normal signup, no credit card, no sales contact. Keys live in env vars on the probe services and never in the repo. Endpoint shapes: key-in-path (Alchemy `/v2/<key>`, Infura `/v3/<key>`, Chainstack, Ankr) or query param (Helius `?api-key=`)."
- "Quota guard: each region gets 1/3 of a provider's audited monthly free quota; probing pauses at 90% of that budget until calendar-month rollover (`rpc_keyed_quota_used_ratio`, `rpc_call_total{result=\"quota_paused\"}`). Budgets target ≤2/3 of the real quota, so the bench can never exhaust a key."
- "Call-result classification: `ok` (HTTP 200 + usable result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks / 300 slots behind the cross-provider tip), `timeout`. Latency is recorded only for `ok` responses."
- "Cohort: Alchemy (6 EVM + Solana), Infura / MetaMask Developer (6 EVM), Ankr (5 EVM; Optimism pending chain enablement on the key), Chainstack (6 EVM + Solana via Global Nodes, see plan disclosure below), Helius (Solana only), QuickNode (BNB + Optimism, see plan disclosure below). Free-tier scope differences are disclosed, not hidden: a provider missing from a chain tab means its free tier does not cover that chain (or caps the node count), which is itself a finding."
- "Cohort: Alchemy (7 EVM + Solana), Infura / MetaMask Developer (6 EVM), Ankr (5 EVM; Optimism pending chain enablement on the key), Chainstack (7 EVM incl. Robinhood + Solana via Global Nodes, see plan disclosure below), Helius (Solana only), QuickNode (BNB + Optimism, see plan disclosure below). Robinhood Chain (Arbitrum Orbit L2, chain ID 4663) is covered by Chainstack and Alchemy only; QuickNode support exists per their docs but a keyed endpoint is pending. Free-tier scope differences are disclosed, not hidden: a provider missing from a chain tab means its free tier does not cover that chain (or caps the node count), which is itself a finding."
- "Excluded by design: Tenderly (Node RPC excluded from the free plan), GetBlock (50k CU/day cannot sustain even one chain at probe cadence), Moralis (free plan capped at 2 node endpoints), Blast API (shut down Oct 2025). Each exclusion is quota math or plan scope, never editorial."
- "Plan disclosure: Alchemy, QuickNode and Chainstack are measured through standard shared endpoints of paid accounts. Alchemy and QuickNode route free and paid keys to the same shared fleets; Chainstack Global Nodes are the identical anycast product the free plan provides. The paid difference is quota and node count (free Chainstack caps at one node), not the serving infrastructure, so the latency read is representative of both plans."
- "Fair-play note: free tiers do not necessarily run on the provider's paid-tier infrastructure (e.g. dRPC routes free traffic to a separate provider pool; several providers serve free keys from shared clusters). This page measures exactly what a free signup gets, read it as the free-tier experience, not the provider's ceiling."
Expand All @@ -64,6 +66,7 @@ findings:
- "{{name:alchemy}} aggregates {{p50:alchemy}} across its 7 covered chains, the widest free-tier footprint in the cohort (6 EVM + Solana on one key)."
- "{{name:infura}} sits at {{p50:infura}} on the cross-chain aggregate. Its 2026 free tier meters 3M credits per day (80 credits per call), the tightest effective budget of the EVM cohort."
- "{{name:chainstack}} aggregates {{p50:chainstack}} across 7 chains via Global Nodes, the same anycast product its free plan ships (capped at one node there), measured from a paid org and disclosed as such."
- "On Robinhood Chain (Arbitrum Orbit L2, chain ID 4663), {{best_name:chain:robinhood}} leads at {{best_p50:chain:robinhood}} (p50, 24h). Chainstack and Alchemy are the only keyed providers with support; official public RPC clocked ~170ms in internal tests."
- "On Solana, {{best_name:chain:solana}} leads at {{best_p50:chain:solana}} (p50, 24h) between Helius (Solana-native) and Alchemy."

faq:
Expand All @@ -88,14 +91,15 @@ rank_matrix_query: avg by (provider, chain, region) (ocb:rpc_latency_millisecond

dimensions:
chain:
- { value: all, label: All chains }
- { value: ethereum, label: Ethereum }
- { value: base, label: Base }
- { value: arbitrum, label: Arbitrum }
- { value: optimism, label: Optimism }
- { value: bnb, label: BNB Chain }
- { value: polygon, label: Polygon }
- { value: solana, label: Solana }
- { value: all, label: All chains }
- { value: ethereum, label: Ethereum }
- { value: base, label: Base }
- { value: arbitrum, label: Arbitrum }
- { value: optimism, label: Optimism }
- { value: bnb, label: BNB Chain }
- { value: polygon, label: Polygon }
- { value: robinhood, label: Robinhood }
- { value: solana, label: Solana }
region:
- { value: all, label: All regions }
- { value: us-east, label: US-East }
Expand Down Expand Up @@ -174,7 +178,7 @@ providers:

- slug: chainstack
name: Chainstack
tag: Global Nodes on 6 EVM + Solana, plan disclosed
tag: Global Nodes on 7 EVM incl. Robinhood + Solana, plan disclosed
formula: "50th percentile over 24h of client-side round-trip latency (ms) for the anti-cache probe sent every 60s from 3 regions to Chainstack's free-tier Ethereum Global Node."
queries:
p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="chainstack"})
Expand Down
2 changes: 1 addition & 1 deletion harnesses/rpc-keyed-latency/cmd/script/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func intervalMultFor(provider string) int {
}
return 1
}
var chainsEVM = []string{"ethereum", "base", "arbitrum", "optimism", "bnb", "polygon"}
var chainsEVM = []string{"ethereum", "base", "arbitrum", "optimism", "bnb", "polygon", "robinhood"}

func endpoints() []Endpoint {
var out []Endpoint
Expand Down
277 changes: 277 additions & 0 deletions src/app/api/fee-compare/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import { NextResponse } from "next/server";
import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit";

export const runtime = "nodejs";
export const maxDuration = 30;

const HL_API = "https://api.hyperliquid.xyz/info";
const ARB_RPC = "https://arb1.arbitrum.io/rpc";
const GAINS_DIAMOND_ARB = "0xFF162c694eAA571f685030649814282eA457f169";
const GAINS_VARS_URL = "https://backend-arbitrum.gains.trade/trading-variables";
const FEES_PROCESSED_TOPIC =
"0x71555a7cc983000fe069574303ed2e47aa16417d297441f6d5e314bd6c58b2fe";

const GAINS_FEE_PRECISION = 1e12;
const HL_TAKER_PER_SIDE = 0.00035;

const WALLET_RE = /^0x[0-9a-fA-F]{40}$/;
const BLOCKS_PER_DAY = 43200;
const MAX_DISPLAY_FILLS = 50;

let gainsFeeCache: { coinRoundTrip: Record<string, number>; ts: number } | null = null;
const GAINS_CACHE_TTL_MS = 60 * 60 * 1000;

type HlFill = {
coin: string;
px: string;
sz: string;
fee: string;
time: number;
dir: string;
side: string;
closedPnl: string;
crossed: boolean;
};

type HlFundingEvent = {
time: number;
delta: { usdc: string };
};

type GainsLog = {
topics: string[];
data: string;
blockNumber: string;
transactionHash: string;
};

type GainsTradingVars = {
pairs: Array<{ from: string; feeIndex: string }>;
fees: Array<{ totalPositionSizeFeeP: string }>;
};

async function fetchGainsFeeRates(): Promise<Record<string, number>> {
const now = Date.now();
if (gainsFeeCache && now - gainsFeeCache.ts < GAINS_CACHE_TTL_MS) {
return gainsFeeCache.coinRoundTrip;
}
const res = await fetch(GAINS_VARS_URL, {
signal: AbortSignal.timeout(8000),
next: { revalidate: 3600 },
});
const vars = (await res.json()) as GainsTradingVars;
const coinRoundTrip: Record<string, number> = {};
for (const p of vars.pairs) {
if (coinRoundTrip[p.from]) continue;
const fi = parseInt(p.feeIndex, 10);
const entry = vars.fees[fi];
if (!entry) continue;
const perSide = parseInt(entry.totalPositionSizeFeeP, 10) / GAINS_FEE_PRECISION;
coinRoundTrip[p.from] = perSide * 2;
}
gainsFeeCache = { coinRoundTrip, ts: now };
return coinRoundTrip;
}

async function rpcCall(method: string, params: unknown[]): Promise<unknown> {
const res = await fetch(ARB_RPC, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
signal: AbortSignal.timeout(15000),
});
const d = (await res.json()) as { result?: unknown; error?: { message: string } };
if (d.error) throw new Error(`RPC ${method}: ${d.error.message}`);
return d.result;
}

async function getLatestBlock(): Promise<number> {
const hex = (await rpcCall("eth_blockNumber", [])) as string;
return parseInt(hex, 16);
}

async function fetchGainsLogs(wallet: string, fromBlock: number, toBlock: number) {
const walletPadded = "0x" + wallet.replace("0x", "").toLowerCase().padStart(64, "0");
const logs = (await rpcCall("eth_getLogs", [
{
address: GAINS_DIAMOND_ARB,
topics: [FEES_PROCESSED_TOPIC, null, walletPadded],
fromBlock: "0x" + fromBlock.toString(16),
toBlock: "0x" + toBlock.toString(16),
},
])) as GainsLog[];

return logs.map((log) => {
const collateralIndex = parseInt(log.topics[1] ?? "0x0", 16);
const data = log.data.replace("0x", "");
if (data.length < 192) return null;
const posSize = BigInt("0x" + data.slice(0, 64));
const orderType = parseInt(data.slice(64, 128), 16);
const totalFees = BigInt("0x" + data.slice(128, 192));
return { collateralIndex, orderType, posSize, totalFees };
}).filter(Boolean) as Array<{
collateralIndex: number; orderType: number; posSize: bigint; totalFees: bigint;
}>;
}

async function fetchHlFills(wallet: string): Promise<HlFill[]> {
const res = await fetch(HL_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "userFills", user: wallet }),
signal: AbortSignal.timeout(15000),
});
const data = await res.json();
return Array.isArray(data) ? (data as HlFill[]) : [];
}

async function fetchHlFunding(wallet: string, startMs: number): Promise<HlFundingEvent[]> {
const res = await fetch(HL_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "userFunding", user: wallet, startTime: startMs }),
signal: AbortSignal.timeout(15000),
});
const data = await res.json();
return Array.isArray(data) ? (data as HlFundingEvent[]) : [];
}

export async function GET(req: Request) {
const rl = rateLimit(clientKey(req, "fee-compare"), 5, 60);
if (!rl.ok) return tooManyRequests(rl.retryAfterSec);

const url = new URL(req.url);
const wallet = url.searchParams.get("wallet")?.trim() ?? "";
const days = Math.min(180, Math.max(7, parseInt(url.searchParams.get("days") ?? "90", 10)));

if (!WALLET_RE.test(wallet)) {
return NextResponse.json({ error: "invalid_wallet" }, { status: 400 });
}

const cutoffMs = Date.now() - days * 86400 * 1000;

try {
const [hlFills, hlFunding, latestBlock, gainsFeeRates] = await Promise.all([
fetchHlFills(wallet),
fetchHlFunding(wallet, cutoffMs),
getLatestBlock(),
fetchGainsFeeRates(),
]);

const fromBlock = Math.max(0, latestBlock - Math.ceil(days * BLOCKS_PER_DAY));
const gainsLogs = await fetchGainsLogs(wallet, fromBlock, latestBlock);

// ── HL side (100% real data from HL API) ──
const recentFills = hlFills.filter((f) => f.time >= cutoffMs);
let hlNotional = 0;
let hlFees = 0;
const coinMap: Record<string, { fills: number; notional: number; fees: number; onGains: boolean }> = {};

for (const f of recentFills) {
const notional = parseFloat(f.px) * parseFloat(f.sz);
const fee = parseFloat(f.fee);
hlNotional += notional;
hlFees += fee;
if (!coinMap[f.coin]) coinMap[f.coin] = { fills: 0, notional: 0, fees: 0, onGains: f.coin in gainsFeeRates };
coinMap[f.coin].fills++;
coinMap[f.coin].notional += notional;
coinMap[f.coin].fees += fee;
}

const recentFunding = hlFunding.filter((f) => f.time >= cutoffMs);
const hlFundingTotal = recentFunding.reduce((s, f) => s + parseFloat(f.delta?.usdc ?? "0"), 0);

// Per-trade display (most recent first, capped)
const displayFills = recentFills
.slice()
.sort((a, b) => b.time - a.time)
.slice(0, MAX_DISPLAY_FILLS)
.map((f) => {
const notional = parseFloat(f.px) * parseFloat(f.sz);
const hlFee = parseFloat(f.fee);
const gainsRate = gainsFeeRates[f.coin];
// Per-side Gains fee for this fill (one leg of the trade)
const gainsPerSide = gainsRate !== undefined ? notional * (gainsRate / 2) : null;
return {
time: f.time,
coin: f.coin,
dir: f.dir,
side: f.side,
notional,
hlFee,
closedPnl: parseFloat(f.closedPnl),
isTaker: f.crossed,
gainsPerSide,
};
});

// Top coins
const topCoins = Object.entries(coinMap)
.sort((a, b) => b[1].notional - a[1].notional)
.slice(0, 5)
.map(([coin, d]) => ({
coin, ...d,
gainsRoundTripRate: gainsFeeRates[coin] ?? null,
}));

// Gains equivalent for HL trades (per-coin live rates)
// data.notional counts every fill (open + close separately), so use per-side rate
let gainsEquivForHl = 0;
let hlNotionalOnGains = 0;
let hlFeesOnGainsCoins = 0;
for (const [coin, data] of Object.entries(coinMap)) {
const gainsRate = gainsFeeRates[coin];
if (gainsRate === undefined) continue;
gainsEquivForHl += data.notional * (gainsRate / 2);
hlNotionalOnGains += data.notional;
hlFeesOnGainsCoins += data.fees;
}

// ── Gains side ──
const usdcLogs = gainsLogs.filter((l) => l.collateralIndex === 3);
const gainsFeesUsdc = usdcLogs.reduce((s, l) => s + Number(l.totalFees) / 1e6, 0);
const gainsSizeUsdc = usdcLogs.reduce((s, l) => s + Number(l.posSize) / 1e6, 0);

const hlRoundTrip = HL_TAKER_PER_SIDE * 2;
// gainsSizeUsdc sums every FeesProcessed event (open + close separately), so per-side rate
const hlEquivForGains = gainsSizeUsdc * HL_TAKER_PER_SIDE;

return NextResponse.json({
wallet: wallet.toLowerCase(),
days,
generatedAt: Date.now(),
hl: {
fills: recentFills.length,
notionalUsd: hlNotional,
feesUsd: hlFees,
fundingUsd: hlFundingTotal,
netCostUsd: hlFees - hlFundingTotal,
avgFeeRateBps: hlNotional > 0 ? (hlFees / hlNotional) * 10000 : 0,
topCoins,
recentFills: displayFills,
},
gains: {
events: usdcLogs.length,
feesUsdc: gainsFeesUsdc,
positionSizeUsdc: gainsSizeUsdc,
avgFeeRateBps: gainsSizeUsdc > 0 ? (gainsFeesUsdc / gainsSizeUsdc) * 10000 : 0,
},
comparison: {
hlNotionalOnGains,
hlFeesOnGainsCoins,
gainsEquivForHlNotional: gainsEquivForHl,
hlSavedVsGains: gainsEquivForHl - hlFeesOnGainsCoins,
hlCheaperMultiple: hlFeesOnGainsCoins > 0 ? gainsEquivForHl / hlFeesOnGainsCoins : null,
hlRoundTripRate: hlRoundTrip,
hlEquivForGainsVolume: hlEquivForGains,
gainsSavedVsHl: gainsFeesUsdc - hlEquivForGains,
},
gainsFeeRates: Object.fromEntries(
Object.entries(gainsFeeRates).filter(([coin]) => coinMap[coin]?.onGains)
),
});
} catch (err) {
console.error("[fee-compare]", err);
return NextResponse.json({ error: "fetch_failed" }, { status: 502 });
}
}
35 changes: 35 additions & 0 deletions src/app/fee-compare/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/page-metadata";
import { FeeCompareClient } from "@/components/fee-compare-client";

export const metadata: Metadata = pageMetadata({
path: "/fee-compare",
title: "Hyperliquid vs Gains fee comparison — analyze any wallet",
description:
"Paste any wallet address and see exactly what was paid in fees on Hyperliquid or Gains, and what it would have cost on the other platform. Live on-chain data, no API key.",
});

export default function FeeComparePage() {
return (
<article className="mx-auto max-w-[720px] px-4 sm:px-6 py-10 sm:py-14">
<p className="font-sans text-[11px] uppercase tracking-[0.18em] text-ink-faint mb-3">
Fee comparison
</p>
<h1 className="display text-3xl sm:text-4xl text-ink leading-[1.05]">
Hyperliquid vs Gains
</h1>
<p className="mt-4 max-w-2xl text-base text-ink-soft leading-snug">
Paste a wallet address. We fetch actual fees paid on Hyperliquid
(fills API) and Gains (on-chain FeesProcessed events, Arbitrum)
then show what the same trades would have cost on the other platform.
</p>
<p className="mt-2 text-sm text-ink-faint">
No API key required. Data is fetched live from public endpoints.
</p>

<div className="mt-8">
<FeeCompareClient />
</div>
</article>
);
}
Loading