diff --git a/alternatives/alchemy.yml b/alternatives/alchemy.yml index 689918146..a2d7aa699 100644 --- a/alternatives/alchemy.yml +++ b/alternatives/alchemy.yml @@ -7,5 +7,5 @@ benchmark: aggregator-head-lag intro: | Alchemy is an EVM-first node and API provider whose surface is shaped by its enhanced endpoints (`alchemy_getAssetTransfers`, NFT API, webhooks, getTokenBalances, the simulation and debug suite, the account-abstraction bundler) rather than by a raw RPC tuned for edge latency. Pricing is metered in compute units, which charges more for an enhanced call than for a plain `eth_call`, so the ceiling shows up first on teams streaming high-cardinality events into a backend rather than on dapp front-ends issuing single reads. Solana coverage exists but sits on a stack built around Ethereum and its L2s. Teams that need a live view of trades on Base, BNB Chain or Solana usually leave Alchemy at the point where the enhanced API list stops mapping to what the product actually reads from chain. -seo_title: Alchemy alternatives. live head-lag benchmark across data providers +seo_title: "Alchemy alternatives: live benchmark" seo_description: Compare Alchemy alternatives on real-time data freshness. Live head-lag against a canonical archive node, refreshed every minute and published openly. diff --git a/alternatives/birdeye.yml b/alternatives/birdeye.yml index c08a661d3..1dead0c1f 100644 --- a/alternatives/birdeye.yml +++ b/alternatives/birdeye.yml @@ -8,5 +8,5 @@ chain: solana intro: | Birdeye is a Solana token analytics platform with a price + metadata API. If you need an alternative, one practical axis is metadata completeness. for a freshly-launched Solana token, how often does the API return a populated logo, description, twitter and website? Below is the live percentage for each provider on Solana, refreshed every minute against fresh-launch tokens detected from Mobula Pulse. -seo_title: Birdeye alternatives. live Solana metadata coverage benchmark -seo_description: Compare Birdeye alternatives on token metadata coverage for Solana. Logo, description, twitter and website availability for freshly-launched tokens, measured live. +seo_title: "Birdeye alternatives: live benchmark" +seo_description: "Birdeye alternatives on Solana token metadata coverage: logo, description, socials in seconds. Codex vs Mobula live." diff --git a/alternatives/bitquery.yml b/alternatives/bitquery.yml index e3280c40f..76380c11b 100644 --- a/alternatives/bitquery.yml +++ b/alternatives/bitquery.yml @@ -7,5 +7,5 @@ benchmark: network-coverage intro: | Bitquery is a GraphQL surface on top of indexed onchain data, with the schema as the actual product: queries declare exactly the fields needed across transfers, trades, DEX events and account state, and the indexer fans those out across chains. Pricing is point-based, with each field, filter and join consuming from a monthly point budget rather than from a flat request quota, which means a single dashboard with many panels can hit the ceiling well before request volume looks high. Chain coverage is broad but uneven in depth: some networks expose full DEX trade indexing, others only basic transfers. The team usually compared next is the one whose chain list either covers a long-tail network Bitquery does not, or covers the same network with a flatter pricing model that does not penalise wide GraphQL fragments on a per-field basis. -seo_title: Bitquery alternatives. live network coverage benchmark +seo_title: "Bitquery alternatives: live benchmark" seo_description: Compare Bitquery alternatives on the number of blockchains each major onchain data provider officially supports. Live data, refreshed every six hours. diff --git a/alternatives/chainlink.yml b/alternatives/chainlink.yml index 8aca8e550..401fbea03 100644 --- a/alternatives/chainlink.yml +++ b/alternatives/chainlink.yml @@ -7,7 +7,7 @@ benchmark: oracle-deviation intro: | Chainlink publishes on-chain price feeds via AggregatorV3 contracts deployed on Ethereum mainnet and most major EVM chains, with node operators posting new rounds on either a deviation trigger (typically 0.25 to 0.5 percent for blue chips) or a heartbeat interval. Every on-chain product that settles in fiat (perp DEXes, lending markets, derivatives, stablecoin issuers) picks one oracle and inherits its drift, which is why the practical question is how aligned the major feeds actually are at the moment a settlement happens. This benchmark polls four oracles every 30 seconds for ten USD-quoted blue chips, computes the pairwise deviation `|a-b|/avg * 100` across every source pair, and publishes the per-pair maximum as a Prometheus gauge in basis points. Sources include Chainlink AggregatorV3 (via `eth_call` against the canonical mainnet contracts), Pyth Network's Hermes REST feed, Binance and Coinbase REST tickers. Pairs are BTC, ETH, SOL, BNB, AVAX, LINK and POL on all four sources, plus XRP, ADA and DOGE on three sources because the Chainlink mainnet feeds for those USD pairs are deprecated. A stale-price guard skips samples older than 60 seconds so a dead poller cannot artificially flatten the deviation, and the companion gauge `ocb_oracle_last_round_age_seconds{source="chainlink"}` surfaces Chainlink's on-chain update gap separately so a wide deviation can be attributed to the right cause. The headline leaderboard ranks the ten pairs by p99 of the max deviation over 24 hours, lower meaning tighter cross-oracle alignment. -seo_title: Chainlink alternatives. Live oracle deviation benchmark in basis points -seo_description: Compare Chainlink against Pyth, Binance and Coinbase on live oracle deviation. Max pairwise gap on 10 USD pairs, polled every 30 seconds, p99 over 24 hours. +seo_title: "Chainlink alternatives: live benchmark" +seo_description: "Chainlink vs Pyth, Binance and Coinbase on live oracle deviation. Max pairwise gap on BTC, ETH, SOL." status: live diff --git a/alternatives/coingecko.yml b/alternatives/coingecko.yml index 6fc8c9810..45c32a58e 100644 --- a/alternatives/coingecko.yml +++ b/alternatives/coingecko.yml @@ -7,5 +7,5 @@ benchmark: network-coverage intro: | CoinGecko is a coin-centric price and market data REST API built around a curated asset list (the `/coins` universe) rather than around on-chain events. Tokens are added through a listing process, prices land on the API after that process completes, and the free tier is famously throttled (around 30 requests per minute on the demo key) which pushes any real product onto a paid plan within days of integration. The on-chain DEX side ships as a separate `/onchain` namespace inherited from GeckoTerminal, with its own quotas. Teams usually leave CoinGecko at one of two points: when the launch cadence of the tokens they care about outpaces the listing pipeline, or when the per-minute cap on the free tier forces a migration before the product is monetised enough to justify the enterprise tier. -seo_title: CoinGecko alternatives. live network coverage benchmark +seo_title: "CoinGecko alternatives: live benchmark" seo_description: Compare CoinGecko alternatives on the number of blockchains each major onchain data API officially supports. Live data, refreshed every six hours. diff --git a/alternatives/drpc.yml b/alternatives/drpc.yml index 92d2e27ba..cfc1b3733 100644 --- a/alternatives/drpc.yml +++ b/alternatives/drpc.yml @@ -7,7 +7,7 @@ benchmark: rpc-capabilities intro: | dRPC is a decentralized RPC mesh that routes each JSON-RPC request across third-party node providers with consensus checks, exposing a no-key public tier on every major EVM network. Picking a default endpoint for a dapp comes down to a per-chain question: which free public RPC is actually fast and reliable on the chain my product runs on, right now, not according to a marketing page. This benchmark answers it directly. The harness sends an identical `eth_blockNumber` POST every 15 seconds against 15 audited no-key public RPC providers across 10 EVM chains (Ethereum, Arbitrum, Base, Optimism, Avalanche, BNB, Polygon, Linea, Scroll, Mantle), from three Railway regions (us-east, eu-west, sgp). p50, p90 and p99 are computed via Prometheus `quantile_over_time` over the last 24 hours, and a five-class result label (`ok`, `http_err`, `jsonrpc_err`, `stale`, `timeout`) lets the success-rate column catch endpoints that return HTTP 200 with a JSON-RPC error body (the Cloudflare-eth trap). An archive-depth probe runs every 5 minutes against five depths so a fast endpoint that silently serves pruned state is not ranked as a viable archive source. The cohort competing with dRPC includes PublicNode, 1RPC, MeowRPC, Tenderly Gateway, Nodies, Lava, Merkle, Flashbots Protect and Cloudflare, plus the chain-official foundation endpoints on Base, BNB, Arbitrum, Optimism and Avalanche. The chain tab filters every query so the leader is reported per network rather than as a cross-chain aggregate that rewards whichever single-chain endpoint happens to be fastest on its one chain. -seo_title: dRPC alternatives. Live free public RPC benchmark across EVM chains -seo_description: Compare dRPC alternatives on eth_blockNumber p50 latency. PublicNode, 1RPC, Tenderly, Nodies, Lava and chain-official endpoints measured every 15 seconds from 3 regions. +seo_title: "dRPC alternatives: live benchmark" +seo_description: "dRPC alternatives on eth_blockNumber p50 latency. PublicNode, 1RPC, Tenderly, Nodies, Lava ranked live." status: live diff --git a/alternatives/dune.yml b/alternatives/dune.yml index 9bea413ea..42784527c 100644 --- a/alternatives/dune.yml +++ b/alternatives/dune.yml @@ -7,5 +7,5 @@ benchmark: wallet-labels-coverage intro: | Dune lets analysts query indexed blockchain data with SQL, often via labelled wallet datasets contributed by the community. When sizing alternatives, one practical axis is wallet labelling depth. how many addresses does each provider have a label for, and how confident are those labels? Below is the live coverage from each major labelling provider, refreshed continuously against a shared sample of recently active wallets. -seo_title: Dune alternatives. live wallet-labels coverage benchmark -seo_description: Compare Dune alternatives on wallet labelling depth. Coverage of recently active addresses across major providers, measured continuously and published openly. +seo_title: "Dune alternatives: live benchmark" +seo_description: "Dune alternatives on wallet labelling depth. Recently active address coverage across major EVM chains." diff --git a/alternatives/dydx.yml b/alternatives/dydx.yml index e984f5848..9a136733b 100644 --- a/alternatives/dydx.yml +++ b/alternatives/dydx.yml @@ -7,7 +7,7 @@ benchmark: perp-fees intro: | dYdX v4 is a Cosmos-based decentralized perpetuals exchange that runs an on-chain orderbook on its own appchain, with a published tier-0 taker fee of 5 basis points and one of the tightest non-zero-fee books outside Hyperliquid. Published fee schedules are only half the story for a trader picking a venue, because the rack rate ignores the spread crossed at market and the price impact eaten at notional. This benchmark measures the live all-in cost of opening a $1000 ETH long 10x position across major perpetual venues, every five minutes, by reading taker fees from each venue's own API (no hardcoded schedules) and walking each orderbook for $1000 of buy-side notional to compute the spread plus impact component. No transactions are sent. dYdX is queried via its indexer at `/orderbooks/perpetualMarket/ETH-USD` and `/perpetualMarkets`, plus the Cosmos REST endpoint `/dydxprotocol/v4/feetiers/perpetual_fee_params` for the tier-0 default fee. The same harness reads Hyperliquid via `userFees` and `l2Book`, GMX v2 through the synthetics-arbitrum subgraph for `positionFeeFactor`, Lighter via `/orderBookDetails` and `/orderBookOrders`, and gains.trade on Base by reading the fee directly on-chain. All-in cost equals taker fee plus spread, both surfaced as separate metrics. Funding rate is recorded separately (`perp_fees_funding_rate_per_hour_bps`) because it accrues per hour held rather than at open, so bundling it into a single one-number leaderboard would conflate two different fee mechanics. The dimension tab at the top of the page swaps the underlying asset between ETH, BTC and SOL. -seo_title: dYdX alternatives. Live all-in perp fee benchmark on a $1000 ETH 10x long +seo_title: "dYdX alternatives: live benchmark" seo_description: Compare dYdX v4 against Hyperliquid, Lighter, GMX v2 and gains.trade on live taker fee plus spread crossed at $1000 notional, refreshed every five minutes. status: live diff --git a/alternatives/etherscan.yml b/alternatives/etherscan.yml index 5baa7b6d3..a964714d5 100644 --- a/alternatives/etherscan.yml +++ b/alternatives/etherscan.yml @@ -7,7 +7,7 @@ benchmark: gas-estimation intro: | Etherscan is the most-visited block explorer on the web and publishes a gas tracker exposing `SafeGasPrice`, `ProposeGasPrice` and `FastGasPrice` via the v2 API on Ethereum and Polygon (free tier). The tiering was originally designed for the pre-EIP-1559 single-price world and is now mapped onto a priority-fee comparison alongside dedicated oracles. The question every wallet, swap router and bridge UI faces before sending a transaction is which gas oracle actually matches what the next block will charge on the chain their product runs on, rather than what a marketing page claims. This benchmark polls each oracle at its tier-tolerant cadence (Etherscan every 15 seconds with a global rate-gate enforcing at least 6 seconds between any two Etherscan requests across chains, because the no-key limit is 1 req per 5 s per IP shared), buffers the prediction with the predicted block height, and when that block is mined pulls the full block via `eth_getBlockByNumber(.., true)` on the chain's PublicNode RPC. Realized percentiles are computed from actual `maxPriorityFeePerGas` values, and the absolute error per (oracle, tier, chain) lands on both a gauge and a histogram. The ranking metric is the p99 gap over 24 hours, the worst 1 percent of blocks, because typical-minute gaps sit within fractions of a micro-gwei (economically indistinguishable noise) while gas spikes are where a wrong prediction either overpays or misses the block. A covered-rate column shows the share of time each prediction sat at or above the realized p50, the inclusion-side risk an absolute gap cannot show. The cohort competing with Etherscan includes PublicNode `eth_feeHistory` (thin wrapper over EIP-1559 reward percentiles) and Owlracle (multi-oracle aggregator). -seo_title: Etherscan alternatives. Live gas oracle accuracy benchmark +seo_title: "Etherscan alternatives: live benchmark" seo_description: Compare Etherscan against PublicNode feeHistory and Owlracle on gwei gap between predicted and realized priority fee, ranked on p99 over 24h. status: live diff --git a/alternatives/gmx.yml b/alternatives/gmx.yml index da11c695a..c3187ae06 100644 --- a/alternatives/gmx.yml +++ b/alternatives/gmx.yml @@ -7,7 +7,7 @@ benchmark: perp-fees intro: | GMX v2 is an oracle-priced perpetuals venue on Arbitrum that settles against synthetic pools rather than a central orderbook, with a position fee split into a positive-impact branch (4 basis points, reduces venue skew) and a negative-impact branch (6 basis points, adds to skew). Which branch fires depends on open interest at the moment of trade, not on user input, so the worst-case open is the right conservative figure to compare against an orderbook venue's all-in cost. This benchmark measures the live all-in cost of opening a $1000 ETH long 10x position across major perpetual venues, every five minutes, by reading the variable position fee from the synthetics-arbitrum subgraph (`positionFeeFactorForNegativeImpact`) and computing the all-in figure in basis points. No transactions are sent. The same harness queries Hyperliquid (`userFees` plus `l2Book`), dYdX v4 (Cosmos REST plus indexer), Lighter (`/orderBookDetails` plus `/orderBookOrders`), and gains.trade on Base where the fee is read directly on-chain via `eth_call pairs(N)` and `fees(feeIndex).openFeeP`. Because GMX is oracle-priced, the all-in number has no orderbook spread component; the rack rate effectively becomes the figure, which differs structurally from Hyperliquid and Lighter where the half-spread plus impact at $1000 notional gets layered on top of the taker fee. Funding rate is published separately (`perp_fees_funding_rate_per_hour_bps`) because it accrues per hour held, not at open. The asset tab at the top of the page swaps between ETH, BTC and SOL. -seo_title: GMX alternatives. Live all-in perp fee benchmark on a $1000 ETH 10x long +seo_title: "GMX alternatives: live benchmark" seo_description: Compare GMX v2 against Hyperliquid, dYdX, Lighter and gains.trade on live taker fee plus spread crossed at $1000 notional, refreshed every five minutes. status: live diff --git a/alternatives/hyperliquid.yml b/alternatives/hyperliquid.yml index f7710ff97..5c8a0f0db 100644 --- a/alternatives/hyperliquid.yml +++ b/alternatives/hyperliquid.yml @@ -7,7 +7,7 @@ benchmark: perp-fees intro: | Hyperliquid is an L1 perpetuals exchange running on HyperBFT consensus, with an on-chain orderbook and a published base taker fee of 4.5 basis points. Its book is among the deepest of any decentralized venue, which means the spread crossed at market and the impact eaten at $1000 notional usually stay compressed and the all-in cost tracks the rack rate closely. This benchmark measures the live all-in cost of opening a $1000 ETH long 10x position across major perpetual venues, every five minutes, by reading taker fees from each venue's own API and walking each orderbook for $1000 of buy-side notional. No transactions are sent. Hyperliquid is queried via `POST /info {type: l2Book}` for asks, `{type: userFees, user: 0x000...000}` for taker fee and `{type: metaAndAssetCtxs}` for funding. The same harness queries Lighter (`/orderBookDetails` plus `/orderBookOrders`), dYdX v4 (indexer plus Cosmos REST), GMX v2 (synthetics-arbitrum subgraph for `positionFeeFactor`), and gains.trade on Base (fee read directly on-chain via `eth_call`). All-in cost equals taker fee plus half-spread plus impact, all in basis points, with both components emitted as separate Prometheus gauges for transparency. Funding rate is published separately (`perp_fees_funding_rate_per_hour_bps`) because it accrues per hour held rather than at open, so a trader sizing a multi-hour position can layer it on top of the open cost. The dimension tab at the top of the page swaps the underlying asset between ETH, BTC and SOL, with the leaderboard re-sorting on each switch. -seo_title: Hyperliquid alternatives. Live all-in perp fee on a $1000 ETH 10x long +seo_title: "Hyperliquid alternatives: live benchmark" seo_description: Compare Hyperliquid against Lighter, dYdX, GMX v2 and gains.trade on live taker fee plus spread crossed at $1000 notional, refreshed every five minutes. status: live diff --git a/alternatives/jupiter.yml b/alternatives/jupiter.yml new file mode 100644 index 000000000..3288f0e93 --- /dev/null +++ b/alternatives/jupiter.yml @@ -0,0 +1,13 @@ +slug: jupiter +target_product: Jupiter +target_url: https://jup.ag +description: Solana DEX aggregator with multi-venue routing +benchmark: solana-dex-quote-latency + +intro: | + Jupiter is a Solana DEX aggregator that routes swaps across most major venues on the network, exposing a public quote endpoint at `lite-api.jup.ag/swap/v1/quote`. Quote latency sits on the hot path of every swap a wallet, trading UI or routing front-end builds: a 600 ms response pushes a perceptible delay onto the user, while an 80 ms response feels instant. This benchmark measures the wall-clock round-trip latency of Solana DEX aggregator quote APIs every 60 seconds from three Railway regions (us-east, eu-west, sgp), against tokens that are actually trending on Solana rather than the canonical SOL to USDC pair that every provider's edge cache already memorises. The harness maintains a 30-minute sliding window of bonded tokens from Mobula Pulse V2's WebSocket feed (post-bonding-curve graduates from Pump.fun, Meteora, Raydium LaunchLab and friends), picks one at random each tick and sends an identical 100 USDC to tokenOut quote request at 1 percent slippage to each provider in parallel. The HTTP client reuses TCP and TLS connections across ticks so the recorded latency reflects steady-state cost, not a cold-start handshake. Liquidity-gap failures (a provider that cannot route the picked token) land on a separate `solana_quote_no_route_total` counter and are excluded from the latency histogram, so a provider that fails fast on long-tail coverage is not penalised on the percentiles. Histogram buckets cover 10 ms to 5 s. Providers measured alongside Jupiter are Mobula, OpenOcean and Raydium; per-region tabs at the top of the page surface where each provider's infrastructure actually sits. + +seo_title: "Jupiter alternatives: live benchmark" +seo_description: Compare Jupiter against Mobula, OpenOcean and Raydium on live Solana quote latency. 100 USDC to long-tail token, polled every 60 seconds from 3 regions. + +status: live diff --git a/alternatives/kalshi.yml b/alternatives/kalshi.yml index 3acafba07..ec8401bbe 100644 --- a/alternatives/kalshi.yml +++ b/alternatives/kalshi.yml @@ -7,7 +7,7 @@ benchmark: pm-rate-limits intro: | Kalshi runs a CFTC-regulated event contracts exchange with the only documented token bucket in its cohort (around 20 requests per second on the basic read tier at the time of writing). Its order book and single-market endpoints go to origin on every request, but the market list is served from CloudFront with `max-age=15`, so list latency measures the edge rather than the API origin. The WebSocket requires authentication, which excludes Kalshi from the public WS panel. Builders sizing against this kind of API need two numbers: the real latency of the hot endpoints at a polite request rate, and what happens as the rate climbs. This benchmark probes book (`/markets/{ticker}/orderbook`), price (`/markets/{ticker}`) and list (`/markets`) endpoints continuously from three Railway regions (us-east, eu-west, sgp), every 5 to 7 seconds over warm keep-alive connections, with a separate cold-connect probe once per minute. Once per day per venue it runs a 60-second-per-tier ramp at rising request rates, recording added latency against the same-hour warm baseline and any 429 onset. Kalshi's ramp uses tiers 25/50/100 requests per 10 seconds and stops at the first 429 out of respect for the documented contract. The harness flags CDN cache hits via `cf-cache-status`, `x-cache` and `age` headers and excludes them from latency aggregates so no venue ranks on its CDN. Every request carries an identifying User-Agent with a contact address. The cohort competing with Kalshi includes Polymarket (Cloudflare-queued CLOB), Limitless (undocumented limits, errors CDN cached for 4 hours), Manifold (500 req/min per IP, whole API behind a 5-second cache) and Myriad (single-region Heroku origin, no order book). -seo_title: Kalshi alternatives. Live prediction market API benchmark -seo_description: Compare Kalshi against Polymarket, Limitless, Manifold and Myriad on warm book latency and daily ramp throttle behaviour, measured from us-east, eu-west and sgp. +seo_title: "Kalshi alternatives: live benchmark" +seo_description: "Kalshi vs Polymarket, Limitless, Manifold, Myriad on warm book latency and ramp throttle (us-east, eu-west, sgp)." status: live diff --git a/alternatives/lifi.yml b/alternatives/lifi.yml index 997f34755..6e1eff7af 100644 --- a/alternatives/lifi.yml +++ b/alternatives/lifi.yml @@ -7,5 +7,5 @@ benchmark: bridge-quote-latency intro: | Li.Fi is a cross-chain aggregator that stitches together third-party bridges (Stargate, Across, Connext, Hop and others) and DEX aggregators behind a single `/quote` endpoint. The quote response carries a full execution plan with calldata, approval steps and gas estimates, which is convenient for a wallet integration but means the latency reflects fan-out across many underlying providers plus the time to pick a winner. Each upstream has its own SLA, and a slow leg in the cohort drags the quote even when the chosen route is fast. The trade-off is breadth for tail-latency: Li.Fi covers a long tail of bridges that intent-routed competitors do not, but pays for that breadth on the p95 of its `/quote` call. Teams move off when the latency of the fastest route they actually use exceeds what a narrower router can return for the same pair. -seo_title: Li.Fi alternatives. Debridge, Mobula & Relay live quote latency benchmark +seo_title: "Li.Fi alternatives: quote latency benchmark" seo_description: Compare Li.Fi alternatives on cross-chain quote latency. Debridge, Mobula and Relay measured on identical routes, refreshed every five minutes. diff --git a/alternatives/polymarket.yml b/alternatives/polymarket.yml index f76ca855b..f98918357 100644 --- a/alternatives/polymarket.yml +++ b/alternatives/polymarket.yml @@ -7,7 +7,7 @@ benchmark: pm-rate-limits intro: | Polymarket runs a prediction market platform with a CLOB API (`/book`, `/midpoint`) and a Gamma markets endpoint, fronted by Cloudflare and accompanied by a public market WebSocket. Documented rate limits sit on the order of 1500 requests per 10 seconds for the book endpoint, but the Cloudflare layer queues bursts rather than rejecting them, so a polling loop or trading bot only sees rising latency before it ever sees a 429. Builders sizing against this kind of API need two numbers nobody publishes: the real latency of the hot endpoints at a polite request rate, and what actually happens as the request rate climbs. This benchmark probes book, price and list endpoints of five prediction market venue APIs continuously from three Railway regions (us-east, eu-west, sgp), every 5 to 7 seconds over warm keep-alive connections, with a separate cold-connect probe once per minute that forces a fresh TCP and TLS handshake. Once per day per venue it runs a 60-second-per-tier ramp at rising request rates, recording added latency versus the same-hour warm baseline and any 429 or 5xx onset. Polymarket's ramp tiers are 25/50/100 requests per 10 seconds (at most 7 percent of the documented book budget) with an automatic abort the moment throttled plus 5xx responses exceed 1 percent of a 10-second window. CDN cache hits are flagged from `cf-cache-status`, `x-cache` and `age` headers and excluded from latency aggregates so no venue ranks on its CDN. The cohort competing with Polymarket includes Kalshi (documented token bucket), Limitless (undocumented limits, errors CDN cached for 4 hours), Manifold (500 req/min per IP) and Myriad (30 req/10s keyless budget, single-region Heroku origin). -seo_title: Polymarket alternatives. Live prediction market API benchmark +seo_title: "Polymarket alternatives: live benchmark" seo_description: Compare Polymarket against Kalshi, Limitless, Manifold and Myriad on warm book latency and daily ramp throttle behaviour, from us-east, eu-west and sgp. status: live diff --git a/alternatives/publicnode.yml b/alternatives/publicnode.yml index bd29d1403..687f0a32e 100644 --- a/alternatives/publicnode.yml +++ b/alternatives/publicnode.yml @@ -7,7 +7,7 @@ benchmark: rpc-capabilities intro: | PublicNode is the no-key public RPC service operated by Allnodes, covering 70+ chains with endpoints anchored close to a large EU-resident validator footprint. It is the de-facto default fallback for many dapps because it shows up on most EVM networks at once and serves archive-depth state on most of them. Comparing it against the other candidates a developer might paste into a wallet config or a backend service is per chain and per region, not per marketing page. This benchmark probes `eth_blockNumber` every 15 seconds against 15 audited no-key RPC providers across 10 EVM chains (Ethereum, Arbitrum, Base, Optimism, Avalanche, BNB, Polygon, Linea, Scroll, Mantle) from three Railway regions (us-east, eu-west, sgp). Latency is recorded with millisecond precision and exposed as both a gauge and a histogram so p50, p90 and p99 are computed via Prometheus `quantile_over_time` over the last 24 hours. A five-class result label catches endpoints that answer HTTP 200 with a JSON-RPC error body or serve a stale head more than 20 blocks behind the cross-provider tip, and a separate archive-depth probe runs every 5 minutes across five depths (300 to 5,000,000 blocks). The cohort competing with PublicNode includes dRPC, 1RPC, MeowRPC, Tenderly Gateway, Nodies, Lava, Merkle, Flashbots Protect and Cloudflare, plus the chain-official foundation endpoints on Base, BNB, Arbitrum, Optimism and Avalanche. The chain tab filters every query so the leader is reported per network, not as a cross-chain aggregate. -seo_title: PublicNode alternatives. Live no-key RPC benchmark across EVM chains -seo_description: Compare PublicNode alternatives on eth_blockNumber p50 latency. dRPC, 1RPC, Tenderly, Nodies, Lava, Merkle and chain-official endpoints probed every 15 seconds from 3 regions. +seo_title: "PublicNode alternatives: live benchmark" +seo_description: "PublicNode alternatives on eth_blockNumber p50 latency. dRPC, 1RPC, Tenderly, Nodies, Lava ranked live." status: live diff --git a/alternatives/pump-portal.yml b/alternatives/pump-portal.yml index f30f60a52..f907d4db0 100644 --- a/alternatives/pump-portal.yml +++ b/alternatives/pump-portal.yml @@ -7,5 +7,5 @@ benchmark: aggregator-head-lag intro: | Pump Portal is a single-purpose WebSocket on Solana, scoped to pump.fun and the bonding-curve launchpad ecosystem around it (new token creations, migration to Raydium, per-mint trade events). The surface is intentionally narrow: no support for other chains, no general DEX coverage outside the pump.fun graduates and no historical query layer beyond the live stream. The free tier exists, but high-volume readers route through a paid trading endpoint that adds a per-trade priority fee on top. The provider sits close to the launchpad it tracks rather than near a canonical Solana archive, so the bottleneck is usually the upstream indexer rather than the WebSocket itself. Teams that outgrow the pump.fun-only scope (cross-DEX routing, non-Solana chains, OHLCV history) tend to drop it the moment a second venue or chain enters the product. -seo_title: Pump Portal alternatives. Codex, GeckoTerminal & Mobula live latency benchmark -seo_description: Looking for an alternative to Pump Portal? Compare Codex, GeckoTerminal and Mobula on real-time blockchain data latency, measured continuously and published openly. +seo_title: "PumpPortal alternatives: live benchmark" +seo_description: "Pump Portal alternatives: Codex, GeckoTerminal, Mobula on real-time block ingestion + Solana mint feed." diff --git a/alternatives/pyth.yml b/alternatives/pyth.yml index 3a2c13a71..00fce2ef5 100644 --- a/alternatives/pyth.yml +++ b/alternatives/pyth.yml @@ -7,7 +7,7 @@ benchmark: oracle-deviation intro: | Pyth Network is a pull-based price oracle with publisher-signed feeds delivered through the Hermes REST endpoint and on-chain via wormhole-attested updates. Unlike Chainlink's push model with a deviation trigger, Pyth refreshes continuously off-chain, which means the off-chain quote available to a protocol team is usually fresher but the on-chain inscription of that quote still depends on when an integrator submits the update. The question every protocol designer picking an oracle asks is how aligned the major feeds actually are at the moment a settlement happens, not what each vendor's whitepaper claims. This benchmark polls four oracles every 30 seconds for ten USD-quoted blue chips, computes the pairwise deviation `|a-b|/avg * 100` across every source pair, and publishes the per-pair maximum as a Prometheus gauge in basis points. Sources are Chainlink AggregatorV3 (via `eth_call`), Pyth Hermes (batched `latest_price_feeds`), Binance REST ticker (USDT quoted, treated as approximately USD) and Coinbase REST ticker. Pairs are BTC, ETH, SOL, BNB, AVAX, LINK and POL on all four sources, plus XRP, ADA and DOGE on three sources because the Chainlink mainnet feeds for those USD pairs are deprecated. A 60-second stale-price guard excludes any source whose last sample is older than two poll intervals, so a dead poller cannot register a false zero deviation against itself. The leaderboard ranks the ten pairs by p99 of the max deviation over 24 hours; lower means tighter cross-oracle alignment. -seo_title: Pyth alternatives. Live oracle deviation benchmark in basis points -seo_description: Compare Pyth against Chainlink, Binance and Coinbase on live oracle deviation. Max pairwise gap on 10 USD pairs, polled every 30 seconds, p99 over 24 hours. +seo_title: "Pyth alternatives: live benchmark" +seo_description: "Pyth vs Chainlink, Binance and Coinbase on live oracle deviation. Max pairwise gap on BTC, ETH, SOL." status: live diff --git a/alternatives/quicknode.yml b/alternatives/quicknode.yml index 17ce396e6..a008bf40e 100644 --- a/alternatives/quicknode.yml +++ b/alternatives/quicknode.yml @@ -7,5 +7,5 @@ benchmark: aggregator-head-lag intro: | QuickNode runs a multi-chain RPC business plus a Marketplace of paid add-ons (token API, NFT API, streams, Yellowstone for Solana) that bolt onto the base node subscription. The pricing model layers per-add-on fees on top of the per-method credit metering, so the bill scales with the breadth of features turned on rather than just request volume; teams running a single workload often discover that two add-ons cost more than the base plan. Chain coverage is broad across EVM and Solana, with regional endpoints in major cloud zones, but the streams product (filter-based push) lives behind a separate quota from the RPC. Real-time price and swap pipelines often end up offloading the head-of-chain stream to a dedicated provider so the QuickNode bill stays on the dapp-read side of the workload. -seo_title: QuickNode alternatives. live head-lag benchmark across data providers +seo_title: "QuickNode alternatives: live benchmark" seo_description: Compare QuickNode alternatives on real-time data freshness. Live head-lag against a canonical archive node, refreshed every minute and published openly. diff --git a/alternatives/relay.yml b/alternatives/relay.yml index f5cf12b50..0eb9985d3 100644 --- a/alternatives/relay.yml +++ b/alternatives/relay.yml @@ -7,5 +7,5 @@ benchmark: bridge-quote-latency intro: | Relay is an intent-based bridge: the user signs an order, a solver fronts the destination-chain funds and the cross-chain settlement happens off the user's critical path. The architecture compresses the user-felt latency because the quote does not have to scan many upstream venues; the solver inventory itself is the route. That same architecture exposes a different failure mode, which is solver depth: routes the solvers do not maintain inventory on either fail or fall back to a slower path, so coverage on long-tail pairs is narrower than a fan-out aggregator's. Fees are quoted inclusive of the solver spread rather than as a separate gas-plus-bridge breakdown. Teams that switch away usually do so for a chain pair the Relay solvers do not actively support, where a route that exists at all beats a fast quote that does not. -seo_title: Relay alternatives. Debridge, Li.Fi & Mobula live quote latency benchmark +seo_title: "Relay alternatives: live benchmark" seo_description: Compare Relay alternatives on cross-chain bridge quote latency. Debridge, Li.Fi and Mobula measured on identical routes, refreshed every five minutes. diff --git a/alternatives/tenderly.yml b/alternatives/tenderly.yml index 190187cfe..7c448ac72 100644 --- a/alternatives/tenderly.yml +++ b/alternatives/tenderly.yml @@ -7,7 +7,7 @@ benchmark: rpc-capabilities intro: | Tenderly runs a multi-chain public RPC gateway at `gateway.tenderly.co/public/` alongside its simulation and observability stack. The free, no-key tier covers nine EVM networks and gets pulled into the same dapps that pick PublicNode or dRPC as their default fallback endpoint. The question every developer faces before pasting a URL into a wallet config or a backend service is whether that endpoint is actually fast and reliable on the chain their product runs on, not whether the vendor's homepage says it is. This benchmark probes `eth_blockNumber` every 15 seconds against 15 audited no-key public RPC providers across 10 EVM chains (Ethereum, Arbitrum, Base, Optimism, Avalanche, BNB, Polygon, Linea, Scroll, Mantle), from three Railway regions (us-east, eu-west, sgp). Each provider gets the same identical JSON-RPC POST every cycle, so the recorded p50/p90/p99 reflects sustained round-trip latency rather than a marketing burst. The harness also classifies every response as `ok`, `http_err`, `jsonrpc_err`, `stale` or `timeout` and exposes archive-depth support for five historical depths (300 to 5,000,000 blocks). The cohort competing with Tenderly's gateway includes PublicNode, dRPC, 1RPC, MeowRPC, Nodies, Lava, Merkle, Flashbots Protect and Cloudflare, plus the chain-official foundation endpoints on Base, BNB, Arbitrum, Optimism and Avalanche. The leaderboard re-sorts every 15 seconds against fresh Prometheus samples, and the chain tab at the top of the page filters every query so the comparison stays per chain rather than smeared across an aggregate that mechanically favours single-chain endpoints. -seo_title: Tenderly alternatives. Live RPC latency benchmark across 10 EVM chains -seo_description: Compare Tenderly Gateway alternatives on eth_blockNumber p50 latency. PublicNode, dRPC, 1RPC, Nodies, Lava and chain-official endpoints probed every 15 seconds from 3 regions. +seo_title: "Tenderly alternatives: live benchmark" +seo_description: "Tenderly Gateway alternatives on eth_blockNumber p50 latency. PublicNode, dRPC, 1RPC, Nodies ranked live." status: live diff --git a/alternatives/the-graph.yml b/alternatives/the-graph.yml index c1fbeb566..e36e686b9 100644 --- a/alternatives/the-graph.yml +++ b/alternatives/the-graph.yml @@ -7,5 +7,5 @@ benchmark: network-coverage intro: | The Graph is a decentralised indexing protocol where each dataset (a subgraph) is written, deployed and queried independently rather than read off a pre-built schema. Queries are GQL against indexer nodes, billed in GRT, with a hosted gateway in front. The model means data shape is owned by whoever wrote the subgraph: a missing field, a stale index or a deprecated mapping is on the publisher, not on a central API team. Chain coverage tracks what indexers have chosen to support, which leans heavily EVM and lags on newer L2s and non-EVM networks until someone publishes a subgraph for them. The two reasons teams move off are the cost of running a private subgraph at production load, and the operational burden of debugging an indexer regression on a chain the public hosted service does not cover. -seo_title: The Graph alternatives. live network coverage benchmark +seo_title: "The Graph alternatives: live benchmark" seo_description: Compare The Graph alternatives on the number of blockchains each major onchain data provider officially supports. Live data, refreshed every six hours. diff --git a/answers/which-evm-aggregator-has-the-fastest-quote.yml b/answers/which-evm-aggregator-has-the-fastest-quote.yml new file mode 100644 index 000000000..b1d85c8b0 --- /dev/null +++ b/answers/which-evm-aggregator-has-the-fastest-quote.yml @@ -0,0 +1,42 @@ +slug: which-evm-aggregator-has-the-fastest-quote +question: "Which EVM swap quote API is the fastest in 2026?" +short_answer: | + {{best_name}} currently returns EVM swap quotes the fastest at {{best_p50}} (p50, 24h) across Mobula, KyberSwap, Bebop, CoW, Enso, LI.FI and OpenOcean on Ethereum, Base, Arbitrum and BSC, measured live by OpenChainBench from 3 regions. + +benchmark: evm-quote-latency + +intro: | + EVM swap aggregator APIs all promise sub second quotes. This page measures how often they actually deliver one. Every wallet integration, intent layer and routing front end is bound by the quote API it sits behind, and the gap between a 200 ms and a 1500 ms p99 is the difference between a swap UI that feels native and one that needs a loading spinner on every input change. OpenChainBench rotates a 5 pair basket across Ethereum, Base, Arbitrum and BSC (1 ETH to USDT on Ethereum, 1000 USDC to USDT on Ethereum, 0.5 ETH to USDC on Base, 100 USDC to ETH on Arbitrum, 1 BNB to USDT on BSC), fires a quote request at every supported provider in parallel every 60 seconds, and records the wall clock round trip. The leaderboard sorts by p50 latency over the last 24 hours. A geographic split is available with the same probe running independently from us-east, eu-west and Singapore, so an integrator can pick the provider whose edge gateway is closest to their backend. + +methodology: | + Each provider's public quote endpoint is hit on the 5 fixed pairs above, rotating one pair per 60 second tick. Per (provider, chain) the bench lands roughly 288 samples in 24 hours per region, enough to put p90 and p99 buckets on a solid base. Latency is wall clock from request dispatch to last byte received, observed only on the happy path (HTTP 2xx with a parseable output amount). Failures land on dedicated counters by class (auth via 401/403, throttle via 429, no route via empty or zero output amount on 200, other for everything else) and pull the success gauge to 0. Native asset sentinel is the canonical `0xEeeeeEeee` form; Odos uses the all zero form and Bebop substitutes the wrapped equivalent on the sell side, the adapter normalises per provider. The three regions are separate Railway services writing the same metric family with a `region` label, so the page can filter to a single edge or aggregate across all three. + +limitations: + - "Speed only, quote quality is not scored. Two providers can return the same time to first byte while one quotes a noticeably worse price. Converting all outputs to a comparable USD value introduces an oracle bias the leaderboard does not want to bake into a latency ranking. The fee benchmark measures realised cost separately." + - "Each provider declares its supported chain set. CoW Protocol skips BSC because CoW Settlement does not deploy there; the harness skips probes for chains a provider does not claim to cover so the failure counter is not inflated by structural non coverage." + - "Enso uses a global anonymous bucket at roughly 1 request per second, so about 20% of probes hit 429 and are dropped. Remaining samples keep p50 and p90 stable but the success rate is capped near 80%, which the leaderboard surfaces honestly." + - "The 5 pair basket favours liquid USDC and ETH routes. A long tail token route (low cap memecoin, RWA, niche bridge wrapper) has different route search complexity and the latency curves on this leaderboard do not generalise to those cases." + - "Polygon and Optimism are slated for a v2 expansion once they reach a comparable sample volume; the current Ethereum, Base, Arbitrum and BSC coverage is the integration grade core." + +faq: + - q: "Which EVM swap quote API has the lowest latency right now?" + a: "{{best_name}} currently leads at {{best_p50}} (p50 over the last 24 hours) on the active tab, across {{count}} measured providers. The leaderboard re-sorts every minute on fresh Prometheus samples, so the answer reflects measured latency on the live basket, not a marketing claim. Per chain leaders can differ from the cross chain aggregate; the chain tabs at the top of the bench page show each ranking separately." + - q: "Does this measure quote quality or just speed?" + a: "Speed only. Latency is wall clock round trip from request to last byte, recorded only on a successful parseable response. Output amount in USD is not scored on this bench because providers measure it differently (Bebop is net of fees on a gasless RFQ, others are gross), and converting all outputs to a comparable USD value introduces an oracle bias the leaderboard does not want to bake into a latency comparison." + - q: "Why are some providers not on every chain?" + a: "Each provider declares its supported chain set. The harness skips probes for chains a provider does not claim to cover so the failure counter is not inflated by structural non coverage. CoW skips BSC because CoW Settlement does not deploy there; LI.FI and OpenOcean cover 30+ chains each. The per chain tab on the bench page shows which providers actually run on each chain." + - q: "What happens when a provider rate limits the harness?" + a: "Each adapter classifies the response. 429 lands on a throttle counter and the success gauge drops to 0. 401 and 403 land on an auth counter. Empty or zero output amounts on a 200 land on a no route counter. None of those failure modes contribute to the latency histogram, so the p50 number stays representative of healthy quotes." + - q: "How is the geographic split implemented?" + a: "Three identical Railway services run the harness with different `MONITOR_REGION` env vars (us-east, eu-west, sgp). Each writes the same metric family with a `region` label. The bench page region filter selects which slice of the histogram is queried; the per chain x per region grid shows a 4 chain by 3 region cell ranking for each provider." + - q: "Why does Bebop sometimes win on latency despite being RFQ?" + a: "Bebop's RFQ model resolves price through a maker network rather than via on chain simulation. The output amount it quotes is already net of fees because settlement is gasless for the taker. The latency the bench measures is the maker network resolving the price, not a public mempool route search; on liquid pairs that path can clock comparably to or below pre warmed aggregator graphs." + +related: + - which-solana-dex-aggregator-is-the-fastest + - which-bridge-has-the-fastest-quote-api + - which-crypto-price-api-is-the-fastest + +seo_title: "Which EVM swap quote API is the fastest in 2026?" +seo_description: "{{best_name}} leads EVM swap quote latency at {{best_p50}} (p50, 24h) across Mobula, KyberSwap, Bebop, CoW, Enso, LI.FI and OpenOcean on Ethereum, Base, Arbitrum and BSC." +status: live diff --git a/answers/which-solana-dex-aggregator-is-the-fastest.yml b/answers/which-solana-dex-aggregator-is-the-fastest.yml new file mode 100644 index 000000000..11c45532c --- /dev/null +++ b/answers/which-solana-dex-aggregator-is-the-fastest.yml @@ -0,0 +1,42 @@ +slug: which-solana-dex-aggregator-is-the-fastest +question: "Which Solana DEX aggregator returns the fastest swap quote?" +short_answer: | + {{best_name}} currently returns Solana swap quotes the fastest at {{best_p50}} (p50, 24h) across Jupiter, Mobula, OpenOcean and Raydium on a rotating trending token basket, measured live by OpenChainBench from 3 regions. + +benchmark: solana-dex-quote-latency + +intro: | + Solana DEX aggregators sit on the hot path of every swap a wallet, trading UI or routing front end builds. A quote API at 80 ms feels instant to the user; a quote API at 600 ms pushes a perceptible delay between input change and updated output. Most "fastest aggregator" comparisons hardcode the canonical SOL to USDC pair, which Jupiter's lite endpoint serves from a CloudFront edge in 30 to 50 ms, measuring cache hit rather than routing. This page answers the question integrators actually need answered. Which Solana DEX aggregator returns the fastest quote when the target token is one nobody has cached, against the long tail tokens actually trending right now. OpenChainBench rotates the target every tick by picking a fresh bonded token from Mobula Pulse V2's live WebSocket feed (post bonding curve graduates from Pump.fun, Meteora, Raydium LaunchLab and friends), then fires a 100 USDC to tokenOut quote at 1% slippage against each provider in parallel from us-east, eu-west and Singapore. + +methodology: | + Every 60 seconds, in each of three Railway regions, the harness picks one Solana token from a sliding 30 minute window of bonded tokens emitted by Mobula Pulse V2 over WebSocket, then sends an identical 100 USDC to tokenOut quote (1% slippage, no fee, no referrer) to each provider. Latency is wall clock from HTTP request dispatch to the first byte of a response body containing a usable quoted output amount (Jupiter `outAmount`, Mobula `data.amountOutTokens`, OpenOcean `data.outAmount`, Raydium `data.outputAmount`). The HTTP client reuses TCP and TLS connections across ticks, so the recorded number is the steady state round trip a long lived backend integration sees, not the one off cold start handshake. Histogram buckets are 10, 25, 50, 100, 200, 500, 1000, 2000 and 5000 ms; p50, p90 and p99 are computed per region via Prometheus `histogram_quantile` over 24h. Failure classes (HTTP 429 throttled, 401/403 auth error, no route, other) each land on their own counter and are excluded from the latency histogram. + +limitations: + - "Raydium's compute API is single venue (Raydium AMM v4, CPMM, CLMM only) and does not multi hop via SOL like Jupiter or Mobula. On the Pulse fed rotation (mostly Pump.fun graduates living on PumpSwap, plus Meteora and Orca pools) Raydium returns INSUFFICIENT_LIQUIDITY on roughly 80% of picks. Its latency cells go blank below 50 successful quotes per 24h; the success rate column is its honest score." + - "Jupiter serves quotes from regional pods behind CloudFront (`x-region: eu-central-1` observed from EU, `x-pod-name: jupiter-core-*`), so each probe region reaches a nearby replica and its cross region spread is flat by infrastructure, not by caching. Repeated identical requests return `x-cache: Miss` every time; CloudFront does not cache this endpoint." + - "Quote quality (output amount, net of gas) is not scored here. Two providers can return the same time to first byte while one quotes a noticeably worse price. Converting all outputs to a comparable USD value introduces an oracle bias the leaderboard does not want to bake into a latency ranking." + - "Long tail token rotation defeats per pair edge caches but biases the comparison toward providers whose route engine handles fresh mints quickly. A pure SOL to USDC trader would see different absolute numbers; relative ordering on the cohort is the integration grade signal." + - "Direct egress from Railway, no residential proxy. The bench measures backend to backend latency as a real integration sees it; a wallet calling the API from a mobile network adds the mobile network's RTT on top." + +faq: + - q: "Which Solana DEX has the fastest quote API right now?" + a: "{{best_name}} currently returns quotes the fastest at {{best_p50}} (p50, 24h) across {{count}} measured providers. The leaderboard re-sorts every 60 seconds against fresh samples and rotates the target token each tick so no provider can serve from edge cache. The answer reflects 24 hours of measured latency across us-east, eu-west and Singapore." + - q: "What is quote latency on a DEX aggregator?" + a: "Quote latency is the wall clock time between a swap UI asking a DEX aggregator 'what would I get if I traded 100 USDC for this token right now' and the aggregator answering with a usable routed price. A quote API at 80 ms feels instant; a quote API at 600 ms introduces a visible delay between input change and updated output. The number sets the floor on how live a swap UI can feel before any other latency (RPC, signing, broadcast) is added." + - q: "Why does the bench rotate the target token instead of always quoting SOL to USDC?" + a: "SOL to USDC is the most cached pair on Solana. Jupiter's lite endpoint serves it from a CloudFront edge in 30 to 50 ms, but that measures cache hit, not routing. Rotating the target every tick against the tokens actually trending right now via Mobula Pulse V2's bonded WebSocket feed defeats every per pair edge cache and forces each provider to actually run a routing search. The metric becomes a fair comparison of routing engines, not CDN configurations." + - q: "What if a provider cannot quote a particular long tail token?" + a: "It is counted as a no route, not as a slow quote. Each provider has a recognisable 'I have no path for this pair' signal (Jupiter NO_ROUTES_FOUND, Mobula 'No route found', Raydium INSUFFICIENT_LIQUIDITY, OpenOcean payload level). When the harness sees it, the tick lands on `solana_quote_no_route_total` and is excluded from the latency histogram entirely, so providers that fail fast on coverage gaps are not penalised on the percentiles." + - q: "Why does Raydium have a much lower success rate than the aggregators?" + a: "Raydium's compute endpoint is single venue: it only routes against Raydium's own AMM v4, CPMM and CLMM pools and does not multi hop via SOL the way Jupiter or Mobula do. Because the rotation pulls from Mobula Pulse V2's bonded feed (most of which are Pump.fun graduates living on PumpSwap, plus Meteora and Orca), Raydium returns INSUFFICIENT_LIQUIDITY or ROUTE_NOT_FOUND on roughly 80% of picks. The Raydium latency column is the conditional p50 over the small subset of bonded tokens that happen to have a Raydium pool." + - q: "Are authentication errors and rate limits counted as slow?" + a: "No. HTTP 401, 403 and 429 responses are excluded from the latency histogram entirely. They are counted in separate counters (`solana_quote_auth_error_total`, `solana_quote_throttled_total`) which show up on the success rate column. A provider that is fast but rate limits at the configured cadence loses points on success rate, not on latency." + +related: + - which-evm-aggregator-has-the-fastest-quote + - which-solana-rpc-lands-the-most-transactions + - which-crypto-price-api-is-the-fastest + +seo_title: "Which Solana DEX aggregator returns the fastest swap quote in 2026?" +seo_description: "{{best_name}} leads Solana DEX quote latency at {{best_p50}} (p50, 24h) across Jupiter, Mobula, OpenOcean and Raydium on a rotating trending token basket. Measured live by OpenChainBench from 3 regions." +status: live diff --git a/answers/which-solana-rpc-lands-the-most-transactions.yml b/answers/which-solana-rpc-lands-the-most-transactions.yml new file mode 100644 index 000000000..6921b2f18 --- /dev/null +++ b/answers/which-solana-rpc-lands-the-most-transactions.yml @@ -0,0 +1,38 @@ +slug: which-solana-rpc-lands-the-most-transactions +question: "Which Solana RPC provider lands the most transactions in 2026?" +short_answer: | + {{best_name}} currently leads Solana transaction landing latency at {{best_p50}} (p50, 24h), the lowest slot-delta between transaction submission and confirmation across the measured RPC field. + +benchmark: solana-tx-landing-latency + +intro: | + Solana trading bots, MEV searchers and on-chain settlement all live or die on the same metric: how reliably and quickly does the RPC endpoint actually land the transaction on a leader's block. Marketing pages publish landing-rate numbers; almost none publish methodology or a live, neutral comparison. This page answers the question that wallet integrations, agent infrastructure and trading desks ask before pasting a URL into production. Which RPC provider is actually landing transactions the fastest right now, measured in slot delta between submission and confirmation, with a probe that runs continuously from multiple regions against the same canonical leader schedule. + +methodology: | + The harness submits a self-signed compute-unit-cheap transaction every few seconds through each RPC provider's submission endpoint, then watches a canonical archive node for the resulting confirmation. The landing latency is the wall-clock slot delta between submission and confirmation, expressed in milliseconds at Solana's 400 ms slot interval. The p50 over 24h is the headline metric; p99 captures the worst 1 percent of cases, where a provider's regional infrastructure or leader proximity surfaces clearly. Probes run from US-East, EU-West and Singapore against the same canonical archive node so any geographic asymmetry shows up as a per-region split, not as a noise floor on the aggregate. + +limitations: + - "Slot delta is not the same as fee. A provider can land transactions fastest while charging a per-transaction priority fee through Jito or a similar bundler; cost-per-landed-transaction is a composite metric the leaderboard does not currently surface." + - "Self-signed test transactions do not exercise the full priority-fee mempool. A real production transaction with a high priority fee and CU budget lands faster than the probes shown here, and the relative ordering can shift when paying for inclusion." + - "Provider landing performance shifts with Solana validator leader schedule. A provider with relayers physically close to today's leader can outperform on this window and lose its lead next epoch when the schedule rotates." + - "This is not a stake-weighted measurement. The harness measures wall-clock landing time at the canonical archive node level, not the share of stake reached at each submission." + +faq: + - q: "What does landing latency actually measure?" + a: "Wall-clock milliseconds between the moment a probe submits a self-signed transaction to a Solana RPC and the moment a canonical archive node sees the same transaction in a confirmed block. Lower is faster. The number is the time from your code calling send to the network treating the transaction as included." + - q: "Why is this different from Solana block time?" + a: "Solana block time is the chain's slot interval, fixed at 400 milliseconds. Landing latency is the time your transaction takes to reach the leader plus the leader's time to include it plus the propagation back to a canonical observer. The chain produces a slot every 400 milliseconds whether or not your transaction lands in it; the question this page answers is which provider's path gets you into the next available slot most consistently." + - q: "Does Jito's bundler beat raw RPC landing?" + a: "On the measured probes, Jito bundling is treated as a provider option, not as a separate metric. When the harness submits through a Jito-aware provider with bundle inclusion enabled, the path includes the bundler. The leaderboard surfaces both Jito and non-Jito providers in the same field so the relative cost of bundling is visible." + - q: "What regions are the probes from?" + a: "US-East, EU-West and Singapore. Cross-region probes catch providers whose landing performance is asymmetric across geography (an RPC fast from EU but slow from APAC is common). The leaderboard reports the cross-region p50; the per-region breakdown is on the bench page." + - q: "Why not measure with my own real workload?" + a: "Real workloads are the ground truth, but they are not comparable across providers because they carry different priority fees, different program calls, and run from different infrastructure. The harness controls for those variables to publish a fair cross-provider comparison; for your specific workload, run the same harness yourself (it is open source) and compare." + +related: + - which-blockchain-has-cheapest-transaction-fees + - which-l1-has-the-fastest-finality + +seo_title: "Which Solana RPC provider lands the most transactions in 2026?" +seo_description: "{{best_name}} leads Solana transaction landing at {{best_p50}} slot delta (p50, 24h) measured live by OpenChainBench. Methodology, regional probes and limitations on this page." +status: live diff --git a/benchmarks/aggregator-head-lag.yml b/benchmarks/aggregator-head-lag.yml index 1ea556cd4..39fdfe0ce 100644 --- a/benchmarks/aggregator-head-lag.yml +++ b/benchmarks/aggregator-head-lag.yml @@ -3,7 +3,7 @@ slug: aggregator-head-lag number: "001" title: Fastest crypto price API, live head lag across Mobula, Codex, GeckoTerminal -seo_title: "Fastest crypto price API 2026: Mobula, Codex, GeckoTerminal" +seo_title: "Fastest crypto price API 2026" seo_description: "{{best_name}} leads fastest crypto price API at {{best_p50}} (p50, 24h). Mobula WebSocket, Codex GraphQL, GeckoTerminal REST live across Base, BNB, Solana." subtitle: Wall-clock head lag in seconds from on-chain swap event to API emission, measured live for Mobula, Codex and GeckoTerminal on Base, BNB Chain and Solana. diff --git a/benchmarks/bridge-fee.yml b/benchmarks/bridge-fee.yml index c50e26003..872221f6f 100644 --- a/benchmarks/bridge-fee.yml +++ b/benchmarks/bridge-fee.yml @@ -3,8 +3,8 @@ slug: bridge-fee number: "003" title: Cheapest cross-chain bridge for USDC at $300 notional -seo_title: "Cheapest cross-chain bridge 2026: deBridge, LI.FI, Mobula, Relay, Near Intents" -seo_description: "Cheapest cross-chain bridge for USDC at $300 notional. Total cost (fees, slippage, dest gas) across Solana, Base, Arbitrum. deBridge, LI.FI, Mobula, Relay, Near Intents." +seo_title: "Cheapest cross-chain bridge 2026" +seo_description: "Cheapest cross-chain bridge for USDC at $300. Total cost (fees, slippage, gas) across deBridge, LI.FI, Mobula, Relay." subtitle: Total cost as a percent of notional, fees plus slippage plus destination gas combined, sampled at $300 USDC across Solana, Base and Arbitrum corridors. category: Bridges status: live diff --git a/benchmarks/bridge-quote-latency.yml b/benchmarks/bridge-quote-latency.yml index 7fea30c9e..56088baff 100644 --- a/benchmarks/bridge-quote-latency.yml +++ b/benchmarks/bridge-quote-latency.yml @@ -3,8 +3,8 @@ slug: bridge-quote-latency number: "002" title: Fastest cross-chain bridge quote API, live ms ranking -seo_title: "Fastest bridge quote API 2026: Mobula, deBridge, Relay, LI.FI, Near Intents" -seo_description: "{{best_name}} leads fastest bridge quote API at {{best_p50}} (p50, 24h). Mobula, deBridge, Relay, LI.FI, Near Intents on identical USDC routes, refreshed every 5 minutes." +seo_title: "Fastest bridge quote API 2026" +seo_description: "Live p50 latency for cross-chain bridge quote APIs. Mobula, deBridge, Relay, LI.FI, Near Intents ranked." subtitle: Time to receive a usable cross-chain quote, in milliseconds. Identical route and identical notional, measured every five minutes across Mobula, deBridge, Relay, LI.FI and Near Intents. category: Bridges status: live diff --git a/benchmarks/bridge-revenue.yml b/benchmarks/bridge-revenue.yml new file mode 100644 index 000000000..16d51bba9 --- /dev/null +++ b/benchmarks/bridge-revenue.yml @@ -0,0 +1,188 @@ +# OpenChainBench. Bench № 028 + +slug: bridge-revenue +number: "028" +title: Cross-chain bridge implied protocol revenue +seo_title: "Cross-chain bridge revenue tracker 2026" +seo_description: "Live cross-chain bridge implied protocol revenue. (USD in − out − gas − fees) summed daily, ranked in USD." +subtitle: Implied margin in USD that each cross-chain bridge plus its solver network collectively retain per swap, summed over rolling 24h / 7d / 30d windows. Upper bound, not exact. Read methodology before citing. +category: Bridges +status: live +metric: Implied revenue USD (24h) +unit: usd +higher_is_better: true + +disclaimer: | + This is an UPPER BOUND, not an exact protocol-revenue number. The margin = USD-in minus USD-out minus gas minus disclosed app fees. Some of that margin Relay shares with solvers in ways we cannot distinguish from the public swap data, so the bench attributes the full amount to "Relay plus solvers collectively". Treat the headline as a ceiling on Relay's own take, not a P&L line. Unpriced swaps (token without a USD reference at swap time) are excluded from the sum and counted separately. + +seo_intro: | + This benchmark answers a question that has no honest public + answer today. how much money does Relay.link, the leading + cross-chain swap routing protocol, actually retain from the + flow it processes. The bench computes implied margin per swap + as (USD value the user paid) minus (USD value the user + received) minus (gas total in USD) minus (any external app + fees the API discloses), then sums that margin over rolling + 24-hour, 7-day and 30-day windows. The result is the smallest + possible number consistent with the on-chain swap data, but + not necessarily the smallest possible number consistent with + Relay's books. Relay shares revenue with the solver network + in ways the public API does not break out, so the bench + attributes the full margin to "Relay plus solvers collectively". + Treat the headline as a ceiling on what Relay alone keeps. + Why publish an upper bound. Because no lower bound exists in + public data. Relay does not publish a fee schedule mapping + volume to revenue. Solver-rebate arrangements are private. + Community-tracked volume ($80M-$150M per day per public + disclosures) times a market-implied 1-5 bps blended take + rate yields an expected band of $20k-$100k per day, or + $0.75M-$2.5M per month. This bench tightens that band by + measuring directly from the swap stream and discloses every + methodology choice so readers can reproduce or argue with + the number. + How it works. A Go program polls the Relay.link public swap + API every 60 s, joins each completed swap against a USD + reference at the swap timestamp, subtracts gas and disclosed + external app fees, and emits the resulting margin as a + Prometheus gauge labeled by window. Unpriced swaps are + excluded from the sum and counted separately. Headline + numbers render as p50 / p90 / p99 on the bench page; those + slots are repurposed for 24h / 7d / 30d windows of the same + margin metric. Take rate in bps is published alongside. + +abstract: | + Relay.link is a cross-chain swap routing protocol that + publishes per-swap detail through a public API. This + benchmark consumes that API on a fixed cadence, computes + per-swap implied margin in USD as (input USD value) minus + (output USD value) minus (gas USD) minus (disclosed external + app fees), and sums the margin over rolling 24-hour, 7-day + and 30-day windows. The result is published as the protocol- + revenue upper bound. Volume USD, swap count and the implied + take rate in basis points are surfaced alongside as context. + The bench does not have access to Relay's internal solver- + rebate accounting, so the number is interpreted as the + margin retained collectively by Relay plus its solver + network, not Relay's own bottom-line revenue. Cross- + validation. Public disclosures put Relay volume at $80M to + $150M per day. Bridge / aggregator take rates cluster at 1 + to 5 basis points blended. Multiplying yields an expected + protocol-revenue band of $20k to $100k per day, or $0.75M + to $2.5M per month, which the live bench number should fall + inside. Significant deviation is either a data-quality issue + the methodology section flags (price-feed gap, unpriced + swap surge, API change) or genuine signal that solver share + has shifted. Limitations. The margin definition is an upper + bound. Solver rebates would lower it; data-quality gaps + (missing USD reference at swap time, gas estimation drift, + undisclosed app fees) would lower the published headline + relative to true protocol revenue. The harness publishes + unpriced-swap count and last-poll timestamp as freshness + signals so readers can spot data-quality regressions. + +methodology: + - "Data source. The Relay.link public swap API is polled on a fixed cadence (default 60 s). The harness consumes the swap stream incrementally, deduping by swap ID, and emits each completed swap as a single contribution to the running window sums. Only swaps with status `success` are counted; pending or failed swaps are tracked separately and excluded from the revenue sum." + - "Margin definition. per_swap_margin_usd = input_usd - output_usd - gas_usd - external_app_fees_usd. input_usd and output_usd are computed by joining the on-chain token amounts against a USD reference at the swap timestamp from CoinGecko or equivalent. gas_usd is the chain-native gas cost (gas_used * gas_price) converted to USD at the same timestamp. external_app_fees_usd is the sum of any frontend or referrer fees the swap payload discloses." + - "Window sums. relay_revenue_usd{window=\"24h\"} is the rolling 24-hour sum of per-swap margin. relay_revenue_usd{window=\"7d\"} is the rolling 7-day sum. relay_revenue_usd{window=\"30d\"} is the rolling 30-day sum. relay_volume_usd and relay_swap_count are computed the same way over the same windows. The bench page binds the three timescales to the p50 / p90 / p99 slots respectively, so the leaderboard surfaces all three at a glance." + - "Take rate. relay_take_rate_bps = (relay_revenue_usd{window=\"24h\"} / relay_volume_usd{window=\"24h\"}) * 10000. Reported on the 24-hour window as the most recent operational signal. Expected band per cross-validation is 1-5 bps blended; a take-rate reading outside that band is either a data-quality issue or a regime change worth investigating." + - "Pricing coverage. relay_swaps_priced_total counts swaps where every leg had a USD reference at swap time. relay_swaps_unpriced_total counts swaps where at least one leg did not (long-tail token, missing reference, price-feed gap). The success column on the bench page is priced / (priced + unpriced), a data-quality ratio rather than a service-uptime ratio. A low ratio means the published headline understates true volume and margin." + - "Why margin is an upper bound on protocol revenue. Relay shares revenue with its solver network through arrangements the public API does not break out. Per-swap margin therefore captures what Relay AND solvers collectively retain, not Relay alone. Without access to internal rebate accounting, the bench cannot decompose the split. Headline is published as 'Relay implied protocol revenue (upper bound)'. Readers building a P&L model should apply their own solver-share assumption to the headline." + - "Freshness. relay_last_poll_timestamp_seconds is a Unix-timestamp gauge of the most recent successful API poll. The bench page's success indicator and any cron alerter use this to detect API outages or harness stalls. expected_freshness_seconds is set to 300 (5 min) which is 5x the 60 s poll cadence, so one missed cycle is tolerated and two missed cycles fire." + - "Cross-validation expected band. Public disclosures put Relay daily volume at $80M to $150M per day. Industry-standard cross-chain take rates cluster at 1 to 5 basis points blended. Multiplying the two ranges yields an expected revenue band of $20k to $100k per day, or $0.75M to $2.5M per month. The live number should fall inside this band. Significant deviation is either a data-quality issue or genuine signal that solver share has shifted." + - "Reproducibility. The full harness source is published in the OpenChainBench harnesses directory (Go). Anyone can clone, set the Relay.link API endpoint env var, run the binary against a Prometheus scraper, and reproduce these metrics. The bench does not rely on any private Mobula service for measurement, the only external dependency is the Relay.link public API and a public USD price reference (CoinGecko or equivalent)." + - "Methodology versioning. Any change to the margin definition, window sizes, pricing source, or excluded-swap rules ships as a public PR with a 14-day comment window. Major changes (e.g. switching from upper-bound margin to a solver-net-decomposed model once private data becomes available) run a 30-day shadow period publishing old and new metric series in parallel." + +findings: + - "Relay implied protocol revenue (24h, upper bound) is currently {{p50:relay}}. Sum of (USD paid - USD received - gas - disclosed app fees) across every successful swap in the last 24 hours. This is the smallest number consistent with the public on-chain swap data and an upper bound on what Relay alone retains after solver-share rebates." + - "The 7-day rolling sum sits at {{p90:relay}}. Smoothing across a week removes day-of-week volume effects and the noise from a single large arbitrage swap that can dominate a 24-hour window. Read this number as the steady-state protocol-revenue ceiling." + - "The 30-day rolling sum is {{p99:relay}}. Monthly recurring revenue ceiling, useful for comparing against public revenue disclosures from comparable cross-chain protocols and against the $0.75M-$2.5M per month expected band derived from volume times take-rate cross-validation." + - "Implied take rate over the last 24 hours is approximately (relay_revenue_usd / relay_volume_usd) * 10000 basis points. Expected blended band per industry cross-validation is 1 to 5 bps. A reading outside this band is either a data-quality issue (priced-swap ratio dropping, gas-estimation drift) or genuine signal that solver share has shifted." + - "Pricing coverage (priced swaps / total swaps over 24h) controls how much of true volume the headline captures. A 100 % ratio means every swap was joined against a USD reference and the headline reflects the full flow. A lower ratio means long-tail tokens are dropping out of the margin sum and the published number understates true protocol revenue proportionally." + +faq: + - q: "Is this Relay.link's actual revenue?" + a: "No. It is an upper bound. Implied margin equals USD paid minus USD received minus gas minus disclosed app fees, summed over completed swaps. Relay shares an unknown fraction of that margin with its solver network through arrangements the public API does not break out. The headline therefore captures what Relay AND its solver network collectively retain, not Relay's own bottom-line revenue. Without access to internal rebate accounting, the bench cannot decompose the split. Treat the headline as a ceiling and apply your own solver-share assumption if you need a Relay-only number." + - q: "Why publish an upper bound instead of an exact number?" + a: "Because no exact number exists in public data and an upper bound is the most rigorous estimate the public swap stream supports. Relay does not publish a fee schedule mapping volume to protocol revenue, and solver-rebate arrangements are private. The alternatives are either to publish nothing, or to publish a guess. An upper bound computed directly from the swap data is more useful than a guess, and is honest about the residual uncertainty. The methodology section spells out exactly what the upper-bound assumption is so readers can apply their own adjustments." + - q: "What does the expected band of $20k-$100k per day come from?" + a: "Cross-validation. Public disclosures put Relay daily volume at $80M to $150M per day. Industry-standard cross-chain take rates cluster at 1 to 5 basis points blended (bridge aggregators and routing protocols typically sit in this range). Multiplying the two ranges yields an expected revenue band of $20k to $100k per day, or $0.75M to $2.5M per month. The live bench number should fall inside this band. A reading meaningfully outside it is either a data-quality issue the methodology flags, or a signal that the cross-validation assumptions need to be revisited." + - q: "Why are 24h, 7d and 30d shown in the p50 / p90 / p99 columns?" + a: "OpenChainBench's standard leaderboard renders p50 / p90 / p99 of a latency-like metric. For a revenue benchmark there is no native percentile interpretation, so the three slots are repurposed to surface the same metric at three timescales: 24h in p50, 7d in p90, 30d in p99. This preserves the at-a-glance comparability of the leaderboard while making the multi-window nature of the metric explicit. The methodology section calls this out so readers do not misread the columns as actual percentiles." + - q: "What happens to swaps whose tokens have no USD price at swap time?" + a: "They are excluded from the margin sum and the volume sum, and counted separately as relay_swaps_unpriced_total. The success column on the bench page is priced / (priced + unpriced), so a low ratio is visible. Causes of an unpriced swap include long-tail token without a CoinGecko entry, price-feed gap, or a brand-new token launched within minutes of the swap. The published headline understates true volume and margin proportionally to the unpriced share, so a 95 % priced ratio means the headline is about 5 % low." + - q: "Why is the freshness threshold 5 minutes?" + a: "The harness polls Relay's public API every 60 seconds. expected_freshness_seconds is set to 5 minutes which is 5x the poll cadence, so one missed cycle is tolerated and roughly two missed cycles fire the cron alerter. Setting it tighter (e.g. 90 s) would cause spurious alerts on transient API hiccups; setting it looser (e.g. 30 min) would let a multi-cycle outage go unnoticed. 5 min is the standard balance for a 1-min-cadence harness in this repo." + - q: "Could solver rebates make the real number much lower than the headline?" + a: "Yes. If Relay rebates half of every basis point of margin back to the solver that filled the swap, the Relay-only revenue is half the headline. Public disclosures from comparable protocols (e.g. CowSwap, 1inch Fusion) suggest solver-share fractions in the 30 % to 70 % range depending on intent design. The bench does not pick a number for Relay because that would be a guess. Readers building a P&L model should apply their own solver-share assumption to the headline. The headline itself is the ceiling, the floor is open." + - q: "Why is this bench in the Bridges category?" + a: "Relay.link is a cross-chain swap routing protocol, the closest fit in the OpenChainBench category list is Bridges. Other benches in this category measure bridge fees, bridge quote latency and bridge-monitor finality. A Trading or Aggregators classification would also be defensible since Relay is structurally closer to an intent-settlement layer than a classic bridge, but Bridges is the most discoverable bucket for the readers most likely to want this number." + +source: https://github.com/ChainBench/OpenChainBench/blob/main/benchmarks/bridge-revenue.yml +# The Relay harness lives in the private mobula-api/miniapps tree for +# now; this spec self-links until the harness is ported to the public +# OCB repo. Additional bridges (across-revenue, socket-revenue) will +# land as sibling harnesses and feed their own provider entries into +# this same bench spec. + +prometheus: + window: 24h + expected_freshness_seconds: 300 + +# Real metrics emitted by the Relay.link revenue harness: +# relay_revenue_usd{window} gauge (window: 24h | 7d | 30d) +# relay_volume_usd{window} gauge (window: 24h | 7d | 30d) +# relay_swap_count{window} gauge (window: 24h | 7d | 30d) +# relay_take_rate_bps gauge (24h window only, derived margin / volume * 10000) +# relay_swaps_priced_total counter +# relay_swaps_unpriced_total counter +# relay_last_poll_timestamp_seconds gauge +# +# Mapping to leaderboard columns: +# p50 = relay_revenue_usd{window="24h"} (latest 24h window) +# p90 = relay_revenue_usd{window="7d"} (rolling 7d window) +# p99 = relay_revenue_usd{window="30d"} (rolling 30d window) +# mean = relay_revenue_usd{window="7d"} / 7 (daily avg over 7d) +# series = relay_revenue_usd{window="24h"} (chart, evolves over time) +# sample_size = relay_swap_count{window="24h"} +# success = priced / (priced + unpriced) (data-quality ratio) +# +# Note: the p50 / p90 / p99 slots here are repurposed for the 24h / 7d / 30d +# windows because a revenue gauge has no native percentile interpretation. +# This is called out in the methodology and the FAQ. + +providers: + - slug: relay + name: Relay + tag: Cross-chain swap routing protocol + formula: "Default headline = ceiling: sum over 24h of (input USD − output USD − gas USD − app fees) per successful swap. Toggle Floor for the on-chain solver-wallet stablecoin delta." + type: relay + secondary: + label: Take rate (24h, bps) + value: "see relay_take_rate_bps" + queries: + p50: relay_revenue_usd{window="24h"} + p90: relay_revenue_usd{window="7d"} + p99: relay_revenue_usd{window="30d"} + mean: relay_revenue_usd{window="7d"} / 7 + success: sum(increase(relay_swaps_priced_total[24h])) / (sum(increase(relay_swaps_priced_total[24h])) + sum(increase(relay_swaps_unpriced_total[24h]))) + sample_size: relay_swap_count{window="24h"} + series: relay_revenue_usd{window="24h"} + +# Two switchable metric panels above the chart so readers can flip between +# the public-API ceiling (the default headline) and the on-chain solver- +# wallet floor. The actual revenue Relay retains lives between the two. +metric_panels: + - id: ceiling + label: Ceiling (implied margin, 24h) + metric: relay_revenue_usd{window="24h"} + label_key: provider + unit: usd + higher_is_better: true + description: "Upper bound from the public Relay swap API. Sum over 24h of input minus output minus gas minus disclosed app fees. Includes solver and integrator share." + - id: floor + label: Floor (solver stablecoin delta, 24h) + metric: relay_solver_balance_delta_usd{kind="stables",window="24h"} + label_key: provider + unit: usd + higher_is_better: true + description: "Lower bound from the Relay solver EOA stablecoin balance change over 24h, sampled from Mobula's wallet portfolio every 5 min. What the system actually retained before any sweep to ops." diff --git a/benchmarks/buyback-audit.yml b/benchmarks/buyback-audit.yml index 0ac802b80..8ab41a4a8 100644 --- a/benchmarks/buyback-audit.yml +++ b/benchmarks/buyback-audit.yml @@ -3,8 +3,8 @@ slug: buyback-audit number: "018" title: DeFi token buyback tracker, live executed vs promised ratio -seo_title: "DeFi buyback tracker 2026: Hyperliquid HYPE, Sky SKY live ratio" -seo_description: "DeFi token buyback tracker ranked live by executed over promised ratio. Hyperliquid Assistance Fund (HYPE) and Sky Smart Burn Engine (SKY). 7d and 30d windows." +seo_title: "DeFi buyback tracker 2026" +seo_description: "DeFi token buyback tracker: ratio of executed vs promised, live. Hyperliquid HYPE, Sky SKY ranked." subtitle: Executed USD over promised USD for live on-chain buyback programs, computed against destination-wallet inflows over rolling 7-day and 30-day windows. category: Trading status: live diff --git a/benchmarks/evm-quote-latency.yml b/benchmarks/evm-quote-latency.yml new file mode 100644 index 000000000..efdf1b819 --- /dev/null +++ b/benchmarks/evm-quote-latency.yml @@ -0,0 +1,194 @@ +# OpenChainBench. Bench № 033 + +slug: evm-quote-latency +number: "033" +title: Fastest EVM swap quote API +seo_title: "Fastest EVM swap quote API 2026" +seo_description: "Live p50/p90/p99 latency for major EVM swap quote APIs measured every minute on a rotating token basket." +subtitle: Wall clock p50 latency of GET /quote per provider, measured every minute on a 5 pair basket across 4 chains. + +per_chain_explainer: + - slug: ethereum + h2: "Fastest Ethereum swap quote API" + body: | + {{best_name:chain:ethereum}} currently leads the Ethereum swap-quote field at {{best_p50:chain:ethereum}} (p50, 24h) across the 7 measured providers. Ethereum mainnet holds the deepest EVM liquidity (Uniswap v3 tick-range pools, Curve stable pools, Maker PSM), so route-search complexity is the dominant component of quote latency: aggregators trading off depth-of-search against time-to-first-byte show their architecture here more than on any other chain. Probes run every 60 seconds on a 5-pair basket from us-east, eu-west and Singapore. + - slug: base + h2: "Fastest Base swap quote API" + body: | + {{best_name:chain:base}} currently leads the Base swap-quote field at {{best_p50:chain:base}} (p50, 24h). Base liquidity is dominated by Aerodrome's ve(3,3) gauges and Uniswap v3, with a thinner long tail than Ethereum, so quote engines that pre-warm a per-chain pool graph (KyberSwap, Mobula) have a structural edge against bridge-first aggregators that re-resolve routes per request. Probes run from us-east, eu-west and Singapore on a 0.5 ETH to USDC pair. + - slug: arbitrum + h2: "Fastest Arbitrum swap quote API" + body: | + {{best_name:chain:arbitrum}} currently leads the Arbitrum swap-quote field at {{best_p50:chain:arbitrum}} (p50, 24h). Arbitrum One concentrates liquidity in Camelot v3, Uniswap v3 and Curve, with sub-second sequencer blocks that let quote engines using `eth_call` simulation refresh their price source between every probe. Bebop's RFQ model resolves price through a maker network rather than via on-chain simulation. Probes run every 60s on a 100 USDC to ETH pair from three regions. + - slug: bsc + h2: "Fastest BSC swap quote API" + body: | + {{best_name:chain:bsc}} currently leads the BSC swap-quote field at {{best_p50:chain:bsc}} (p50, 24h). BSC liquidity is concentrated in PancakeSwap v2 and v3, with a long memecoin tail that pushes aggregators toward exhaustive route enumeration. CoW Protocol is absent because CoW Settlement does not deploy on BSC. Probes fire every 60 seconds on a 1 BNB to USDT pair from us-east, eu-west and Singapore against the public sequencer RPC. + +category: Aggregators +status: live +metric: Quote latency +unit: ms +higher_is_better: false + +seo_intro: | + EVM swap aggregator APIs all promise sub second quotes. This benchmark + measures how often they actually deliver one. The harness rotates a 5 + pair basket across Ethereum, Base, Arbitrum and BSC, fires a quote + request at every supported provider in parallel every 60 seconds, and + records the wall clock round trip. The leaderboard sorts by p50 latency + over the last 24 hours, lower is better. A geographic split is also + available, with the same probe running independently from us east, eu + west and Singapore so a wallet integrator can pick the provider whose + edge gateway is closest to their backend. + +abstract: | + Each provider's public quote endpoint is hit on five fixed pairs + (1 ETH to USDT on Ethereum, 1000 USDC to USDT on Ethereum, 0.5 ETH to + USDC on Base, 100 USDC to ETH on Arbitrum, 1 BNB to USDT on BSC), + rotating one pair per 60 second tick. Per (provider, chain) we land + roughly 288 samples in 24 hours, enough to put p90 and p99 buckets on a + solid base. Quote quality (output amount, net of gas) is not scored, + only latency. + +methodology: + - "Five fixed pairs across four chains. Polygon and Optimism will be added in v2 once they reach a comparable sample volume." + - "60 second tick, one pair per tick, round robin. Every (provider, chain) sees a quote every 5 minutes." + - "Three regions (us east, eu west, sgp). Each is a separate Railway service writing the same metric family with a region label, so the page can filter to a single edge or aggregate across all three." + - "Latency is wall clock from request dispatch to last byte received, observed only on the happy path (HTTP 2xx and a parseable output amount). Failures land on dedicated counters (auth, throttle, no route, other) and pull the success gauge to 0." + - "Native asset sentinel is the canonical 0xEeeeeEeee form. Odos uses 0x0000... and Bebop substitutes the wrapped equivalent on the sell side. The adapter normalises per provider." + - "Provider auth: Mobula requires the sponsor key in an Authorization header. KyberSwap, Bebop, LI.FI and OpenOcean accept anonymous traffic." + +findings: + - "{{best_name}} currently leads at {{best_p50}} on the active chain tab, across {{count}} measured providers. Rankings shift per chain tab: a provider's edge placement and per-chain engine split matter more than its global average." + - "{{name:kyberswap}} returns {{p50:kyberswap}} (p50, 24 h). Its aggregator engine is split per chain and the no-auth path keeps the HTTP round trip lean." + - "{{name:openocean}} ({{p50:openocean}}) and {{name:lifi}} ({{p50:lifi}}) trade latency for breadth: both cover 30+ chains, and the route search across many underlying venues dominates their latency curve." + - "Bebop is RFQ. The output amount it quotes is already net of fees because settlement is gasless for the taker. The latency you see is the maker network resolving the price, not a public mempool route search." + +faq: + - q: "Which EVM swap quote API has the lowest latency right now?" + a: "{{best_name}} currently leads at {{best_p50}} (p50 over the last 24 hours) on the active tab, across {{count}} measured providers. The leaderboard re sorts every minute on fresh Prometheus samples, so the answer reflects measured latency on the live basket, not a marketing claim." + - q: "Does this measure quote quality or just speed?" + a: "Speed only. Latency is wall clock round trip from request to last byte, recorded only on a successful parseable response. Output amount in USD is not scored on this bench because providers measure it differently (Bebop is net of fees on a gasless RFQ, others are gross), and converting all outputs to a comparable USD value introduces an oracle bias we do not want to bake into a latency leaderboard." + - q: "Why are some providers not on every chain?" + a: "Each provider declares its supported chain set. The harness skips probes for chains a provider does not claim to cover so the failure counter is not inflated by structural non coverage." + - q: "What happens when a provider rate limits the harness?" + a: "Each adapter classifies the response. 429 lands on a throttle counter and the success gauge drops to 0. 401 and 403 land on an auth counter. Empty or zero output amounts on a 200 land on a no route counter. None of those failure modes contribute to the latency histogram, so the p50 number stays representative of healthy quotes." + - q: "How is the geographic split implemented?" + a: "Three identical Railway services run the harness with different MONITOR_REGION env vars (us east, eu west, sgp). Each writes the same metric family with a region label. The bench page filter on region selects which slice of the histogram is queried." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/evm-swap-quoting + +prometheus: + window: 24h + expected_freshness_seconds: 600 + freshness_metric: evm_swap_quote_latency_ms_count + +dimensions: + chain: + - { value: all, label: All chains } + - { value: ethereum, label: Ethereum } + - { value: base, label: Base } + - { value: arbitrum, label: Arbitrum } + - { value: bsc, label: BSC } + region: + - { value: all, label: All regions } + - { value: us-east, label: US East } + - { value: eu-west, label: EU West } + - { value: sgp, label: Singapore } + +# Exact per-cell (chain x region) rankings for scoped badges. One query, +# one sample per (provider, chain, region) cell. +rank_matrix_query: avg by (provider, chain, region) (ocb:evm_swap_quote_latency_ms:p50_24h) + +providers: + - slug: mobula + name: Mobula + tag: API key required, dedicated edge + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=mobula} over the last 24 hours, only successful 2xx responses with a non zero output amount." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="mobula"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="mobula"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="mobula"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="mobula"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="mobula"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="mobula"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="mobula"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="mobula"}[1h])) by (le)) + + - slug: kyberswap + name: KyberSwap + tag: No auth, per chain aggregator engine + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=kyberswap} over the last 24 hours." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="kyberswap"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="kyberswap"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="kyberswap"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="kyberswap"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="kyberswap"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="kyberswap"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="kyberswap"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="kyberswap"}[1h])) by (le)) + + - slug: bebop + name: Bebop + tag: RFQ gasless, EIP 55 checksum required + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=bebop} over the last 24 hours." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="bebop"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="bebop"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="bebop"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="bebop"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="bebop"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="bebop"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="bebop"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="bebop"}[1h])) by (le)) + + - slug: lifi + name: LI.FI + tag: 30+ chain coverage, same chain via fromChain equal toChain + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=lifi} over the last 24 hours." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="lifi"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="lifi"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="lifi"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="lifi"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="lifi"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="lifi"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="lifi"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="lifi"}[1h])) by (le)) + + - slug: openocean + name: OpenOcean + tag: 40+ chain coverage, numeric chainId in URL + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=openocean} over the last 24 hours." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="openocean"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="openocean"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="openocean"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="openocean"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="openocean"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="openocean"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="openocean"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="openocean"}[1h])) by (le)) + + - slug: cow + name: CoW Protocol + tag: No auth, RFQ batch auction, no BSC + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=cow} over the last 24 hours. BSC is skipped since CoW Settlement does not deploy there." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="cow"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="cow"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="cow"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="cow"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="cow"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="cow"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="cow"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="cow"}[1h])) by (le)) + + - slug: enso + name: Enso + tag: Anonymous shared bucket, success rate capped near 80 percent + formula: "Avg of per-(chain, region) 24h medians of evm_swap_quote_latency_ms{provider=enso}. Enso uses a global 1rps anonymous bucket so ~20% of probes hit 429 and are dropped. Remaining samples keep p50 and p90 stable." + queries: + p50: avg(ocb:evm_swap_quote_latency_ms:p50_24h{provider="enso"}) + p90: avg(ocb:evm_swap_quote_latency_ms:p90_24h{provider="enso"}) + p99: avg(ocb:evm_swap_quote_latency_ms:p99_24h{provider="enso"}) + mean: sum(ocb:evm_swap_quote_latency_ms:sum_rate_24h{provider="enso"}) / sum(ocb:evm_swap_quote_latency_ms:count_rate_24h{provider="enso"}) + success: avg(ocb:evm_swap_quote_success:avg_24h{provider="enso"}) + sample_size: sum(ocb:evm_swap_quote_latency_ms:count_increase_24h{provider="enso"}) + series: histogram_quantile(0.50, sum(rate(evm_swap_quote_latency_ms_bucket{provider="enso"}[1h])) by (le)) diff --git a/benchmarks/gas-estimation.yml b/benchmarks/gas-estimation.yml index 1f28abe7e..68d23177c 100644 --- a/benchmarks/gas-estimation.yml +++ b/benchmarks/gas-estimation.yml @@ -3,8 +3,8 @@ slug: gas-estimation number: "013" title: Most accurate gas oracle, live gap vs realized priority fee -seo_title: "Most accurate gas oracle 2026: Etherscan, Owlracle, PublicNode" -seo_description: "Most accurate gas oracle ranked live on Ethereum and Polygon. Gwei gap between predicted and realized priority fee in the next mined block. Ranked on the p99 gap over 24h." +seo_title: "Most accurate gas oracle 2026" +seo_description: "Most accurate gas oracle live on Ethereum and Polygon: gwei gap between predicted and realized effective_gas_price." subtitle: Absolute gap in gwei between each oracle's predicted priority-fee tier and the realized percentile in the next mined block, measured per chain. Ranked on the p99 gap, the worst 1% of blocks, because that is where gas spikes hurt integrations. per_chain_explainer: diff --git a/benchmarks/hyperliquid-frontends.yml b/benchmarks/hyperliquid-frontends.yml index 04c649cb6..00eddfff7 100644 --- a/benchmarks/hyperliquid-frontends.yml +++ b/benchmarks/hyperliquid-frontends.yml @@ -3,8 +3,8 @@ slug: hyperliquid-frontends number: "030" title: Hyperliquid frontends builder revenue leaderboard -seo_title: "Hyperliquid frontends 2026: builder fee revenue USD live (Phantom Perps, MetaMask, Rabby, Insilico, Axiom, pvp.trade) over 24h, 7d, 30d" -seo_description: "Live USD revenue collected by every Hyperliquid frontend via the on chain builder code field. Phantom Perps, MetaMask, Rabby, Insilico, based.app, OneKey, Axiom, pvp.trade and 95+ others ranked by 24h, 7d and 30d builder fees. Data source is a local hl node tailing the Hyperliquid mainnet fill stream." +seo_title: "Hyperliquid frontends: builder fees 2026" +seo_description: "Live USD revenue collected by every Hyperliquid frontend via the on-chain builder code. Ranked by 24h fees." subtitle: How much USD builder fee revenue each Hyperliquid frontend collected over the rolling 24h, 7 day and 30 day windows. Data from a local hl node tailing every fill on mainnet. category: Trading status: live diff --git a/benchmarks/hyperliquid-hip3-deployers.yml b/benchmarks/hyperliquid-hip3-deployers.yml index c1c05fa23..8beb26077 100644 --- a/benchmarks/hyperliquid-hip3-deployers.yml +++ b/benchmarks/hyperliquid-hip3-deployers.yml @@ -3,8 +3,8 @@ slug: hyperliquid-hip3-deployers number: "035" title: Hyperliquid HIP-3 deployer revenue leaderboard -seo_title: "Hyperliquid HIP-3 dexes 2026: deployer fee revenue USD live (trade.xyz, Ventuals, Felix, HyENA, Kinetiq, Dreamcash, Paragon)" -seo_description: "Live USD revenue collected by every HIP-3 builder-deployed dex on Hyperliquid via the on chain deployerFee field. trade.xyz, Ventuals, Dreamcash, Kinetiq, HyENA, Felix and Paragon ranked by 24h, 7d and 30d fees, with volume, unique traders and market counts, from a local hl node tailing the mainnet fill stream." +seo_title: "Hyperliquid HIP-3 dex deployer fees 2026" +seo_description: "Live USD revenue collected by every HIP-3 builder-deployed dex on Hyperliquid via the deployer fee code." subtitle: How much USD deployer fee revenue each HIP-3 builder-deployed dex collected over the rolling 24h, 7 day and 30 day windows. Data from a local hl node tailing every fill on mainnet. category: Trading status: live diff --git a/benchmarks/l1-finality.yml b/benchmarks/l1-finality.yml index 3d9c07a78..9ddc8885b 100644 --- a/benchmarks/l1-finality.yml +++ b/benchmarks/l1-finality.yml @@ -3,8 +3,8 @@ slug: l1-finality number: "006" title: Fastest L1 blockchain finality, live across 11 chains -seo_title: "Fastest L1 finality 2026: Gram, SUI, Stellar, Solana, Ethereum" -seo_description: "Fastest L1 blockchain finality, measured live for 11 chains. Gram (formerly TON), SUI and Hedera in seconds, Solana ~13 s, Ethereum ~12.8 min. Live percentiles over 24h, open methodology." +seo_title: "Fastest L1 finality 2026" +seo_description: "Fastest L1 finality live across 11 chains: Gram, SUI, Hedera, Stellar, Solana, Ethereum and more." subtitle: Wall-clock seconds from latest block to the finalized block on Ethereum, Solana, Gram, SUI, Stellar and 5 more chains, refreshed every 10 seconds. seo_intro: | This page measures L1 finality time live for every major Layer-1 blockchain, with p50 / p90 / p99 refreshed every 10 seconds. Stellar finality time is ~5 seconds, the close interval the Stellar Consensus Protocol locks in via federated Byzantine agreement. Solana finality time goes from sub-second on the processed commitment to ~12.8 s on finalized after 32 confirmed slots. Ethereum finality time is ~12.8 minutes, the 2-epoch Casper FFG window. Hedera finality time clears in 3-5 seconds via Hashgraph aBFT. SUI finality time and Gram finality time (formerly TON) both sit under one second via Mysticeti DAG-BFT and BAG consensus. BNB and Avalanche finality time land near two seconds through fast-finality forks. Probabilistic chains (Litecoin, Monero) settle on a confirmation-depth convention measured here in minutes. diff --git a/benchmarks/l2-block-time.yml b/benchmarks/l2-block-time.yml index a37a08c9d..17199113b 100644 --- a/benchmarks/l2-block-time.yml +++ b/benchmarks/l2-block-time.yml @@ -3,8 +3,8 @@ slug: l2-block-time number: "009" title: Fastest L2 block time, live across Arbitrum, Base, Optimism and 6 more -seo_title: "Fastest L2 block time 2026: Arbitrum, Base, Optimism, zkSync" -seo_description: "Fastest L2 sequencer block time ranked live. Wall-clock newHeads interval for Arbitrum, Blast, Optimism, Base, zkSync, Linea, Scroll, Mantle and Taiko. p50 over 24h." +seo_title: "Fastest L2 block time 2026" +seo_description: "Fastest L2 sequencer block time live: Arbitrum, Blast, Optimism, Base, Linea and more ranked by newHeads interval." subtitle: Wall-clock interval in milliseconds between two consecutive newHeads events on each L2 sequencer, refreshed continuously. per_chain_explainer: diff --git a/benchmarks/metadata-coverage.yml b/benchmarks/metadata-coverage.yml index c02aafbf9..da8829208 100644 --- a/benchmarks/metadata-coverage.yml +++ b/benchmarks/metadata-coverage.yml @@ -3,8 +3,8 @@ slug: metadata-coverage number: "004" title: Best crypto data API for token metadata, live across Mobula, Codex, Jupiter -seo_title: "Best crypto data API token metadata 2026: Mobula, Codex, Jupiter" -seo_description: "Best crypto data API for token metadata ranked live. Share of logo, description, twitter populated on fresh Solana, BNB, Base tokens. Mobula, Codex, Jupiter." +seo_title: "Best crypto token metadata API 2026" +seo_description: "Best crypto data API for token metadata live: share of logo, description, twitter populated within minutes of mint." subtitle: Share of metadata fields (logo, description, twitter, website) populated for fresh-launch tokens, audited every launch on Solana, BNB and Base. per_chain_explainer: @@ -13,7 +13,7 @@ per_chain_explainer: body: | {{best_name:chain:solana}} currently leads Solana token metadata coverage at {{best_p50:chain:solana}} (p50 of hourly rate, 24h) across 3 providers including Solana-native Jupiter. Coverage measures the share of (logo, description, twitter, website) field checks that return a populated value on tokens within minutes of mint, sampled via Mobula Pulse V2 on pump.fun, Meteora DBC, Raydium CPMM and Moonshot launchpads. The Solana memecoin volume creates the hardest indexer load in the bench. - slug: bnb - h2: "Best BNB Chain token metadata coverage" + h2: "BNB Chain token metadata coverage" body: | {{best_name:chain:bnb}} currently leads BNB Chain token metadata coverage at {{best_p50:chain:bnb}} (p50 of hourly rate, 24h). BNB is the cleaner read on indexer quality because Jupiter does not cover EVM, so the matchup is decided between Mobula and Codex on the same footing on Four.meme and PancakeSwap launches. Coverage tests the same four canonical fields (logo, description, twitter, website) on tokens within minutes of mint, queried through each provider's metadata endpoint. diff --git a/benchmarks/network-coverage.yml b/benchmarks/network-coverage.yml index 489fe254f..7fa66d30c 100644 --- a/benchmarks/network-coverage.yml +++ b/benchmarks/network-coverage.yml @@ -3,8 +3,8 @@ slug: network-coverage number: "005" title: Crypto data API with most blockchains supported, live coverage ranking -seo_title: "Crypto data API most chains 2026: CoinPaprika, GeckoTerminal" -seo_description: "Crypto data API with the most blockchains supported, ranked live. {{best_name}} leads at {{best_p50}} networks; GeckoTerminal, CoinStats, Codex, Dune and Mobula compared. Audited every six hours." +seo_title: "Crypto data API most chains 2026" +seo_description: "Crypto data API with the most blockchains supported, ranked live across every major aggregator." subtitle: Number of blockchains each major crypto data API officially supports, audited every six hours against each provider's public network endpoint. category: Aggregators status: live diff --git a/benchmarks/network-fees.yml b/benchmarks/network-fees.yml index c288e4545..36e65e3a5 100644 --- a/benchmarks/network-fees.yml +++ b/benchmarks/network-fees.yml @@ -2,35 +2,35 @@ slug: network-fees number: "031" -title: Cheapest blockchain transaction fee, live across 20 L1 and L2 chains -seo_title: "Cheapest crypto transaction fee 2026: Solana, Base, BNB live" -seo_description: "Cheapest blockchain transaction fee in USD ranked live across 20 chains. Solana, Base, Arbitrum, Optimism, BNB, Ethereum and 14 more. Refreshed every 30 seconds." -subtitle: "Live USD cost of one native token transaction on 20 Layer 1 and Layer 2 chains, refreshed every 30 seconds." +title: Cheapest blockchain transaction fee, live across 14 L1 and L2 chains +seo_title: "Cheapest crypto transaction fee 2026" +seo_description: "Cheapest blockchain transaction fee in USD live across 14 chains. Solana, Base, Arbitrum, BNB Chain ranked." +subtitle: "Live USD cost of one native token transaction on 14 Layer 1 and Layer 2 chains, refreshed every 30 seconds." seo_intro: | - This page answers one question. How much does it cost in dollars to send one transaction on each major blockchain right now. We track 20 chains in parallel and refresh the number every 30 seconds. The eleven Layer 1 chains are Ethereum, Solana, BNB Chain, Avalanche, TRON, Cardano, Sui, Gram (formerly TON), Stellar, Litecoin and Monero. The nine Layer 2 rollups are Arbitrum, Optimism, Base, zkSync Era, Linea, Scroll, Blast, Mantle and Taiko. For every chain we query its own fee market directly (eth_feeHistory for the EVM family, getRecentPrioritizationFees on Solana, koios epoch params on Cardano, fee_stats on Stellar, get_fee_estimate on Monero, the mempool oracle on Litecoin, getChainParameters on TRON, suix_getReferenceGasPrice on Sui), convert the result to the smallest native unit, then multiply by the live USD price of the chain's native token from Mobula. The output is the actual dollar amount a wallet user pays today. No gwei to lamport conversion, no marketing claim. Compare Ethereum gas now versus Solana fee in USD, see whether Arbitrum is still cheaper than Base today, find out which Layer 1 has the lowest transaction cost this minute. + This page answers one question. How much does it cost in dollars to send one transaction on each major blockchain right now. We track 14 chains in parallel and refresh the number every 30 seconds. The eight Layer 1 chains are Ethereum, Solana, BNB Chain, TRON, Cardano, Sui, Litecoin and Monero. The six Layer 2 rollups are Arbitrum, Base, zkSync Era, Linea, Mantle and Taiko. For every chain we query its own fee market directly (eth_feeHistory for the EVM family, getRecentPrioritizationFees on Solana, koios epoch params on Cardano, get_fee_estimate on Monero, the mempool oracle on Litecoin, getChainParameters on TRON, suix_getReferenceGasPrice on Sui), convert the result to the smallest native unit, then multiply by the live USD price of the chain's native token from Mobula. The output is the actual dollar amount a wallet user pays today. No gwei to lamport conversion, no marketing claim. Compare Ethereum gas now versus Solana fee in USD, see whether Arbitrum is still cheaper than Base today, find out which Layer 1 has the lowest transaction cost this minute. faq: - q: "What does this benchmark measure?" - a: "The USD cost of one native token transaction on each of the 20 tracked chains, refreshed every 30 seconds. A native transaction is the simplest action on a chain. Send ETH on Ethereum, SOL on Solana, ADA on Cardano, XLM on Stellar, and so on. We do not yet measure ERC 20 transfers, DEX swaps or smart contract deployments. Those will ship as companion metrics in a later phase." + a: "The USD cost of one native token transaction on each of the 14 tracked chains, refreshed every 30 seconds. A native transaction is the simplest action on a chain. Send ETH on Ethereum, SOL on Solana, ADA on Cardano, TRX on TRON, and so on. We do not yet measure ERC 20 transfers, DEX swaps or smart contract deployments. Those will ship as companion metrics in a later phase." - q: "Which chains are tracked?" - a: "Eleven Layer 1 chains on the L1 tab. Ethereum, Solana, BNB Chain, Avalanche, TRON, Cardano, Sui, Gram (formerly TON), Stellar, Litecoin and Monero. Nine Layer 2 rollups on the L2 tab. Arbitrum, Optimism, Base, zkSync Era, Linea, Scroll, Blast, Mantle and Taiko. The list matches the L1 finality and L2 block time benches so you can read cost and speed side by side." + a: "Eight Layer 1 chains on the L1 tab. Ethereum, Solana, BNB Chain, TRON, Cardano, Sui, Litecoin and Monero. Six Layer 2 rollups on the L2 tab. Arbitrum, Base, zkSync Era, Linea, Mantle and Taiko. The list matches the L1 finality and L2 block time benches so you can read cost and speed side by side." - q: "Why USD instead of gas price in gwei?" - a: "Gas price in gwei on Ethereum cannot be compared to lamports per compute unit on Solana, stroops per operation on Stellar or sun per byte on TRON. 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 delivers the prices we multiply by." + a: "Gas price in gwei on Ethereum cannot be compared to lamports per compute unit on Solana or sun per byte on TRON. 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 delivers the prices we multiply by." - q: "What do slow, standard and fast tiers mean?" - a: "Tiers exist on chains with a priority market where users can pay more for faster inclusion. Slow targets the 25th percentile of recent priority bids, standard the 50th, fast the 90th. Chains with deterministic or near deterministic fees (Cardano, Stellar, Gram, TRON native transfer) emit a single tier because there is no priority market to bid into." + a: "Tiers exist on chains with a priority market where users can pay more for faster inclusion. Slow targets the 25th percentile of recent priority bids, standard the 50th, fast the 90th. Chains with deterministic or near deterministic fees (Cardano, TRON native transfer) emit a single tier because there is no priority market to bid into." - q: "Why is the Solana fee so low?" a: "Solana charges 5000 lamports per signature as a hard base, plus an optional priority fee priced in micro lamports per compute unit. A simple SOL transfer uses around 200 compute units, so the priority component is typically dwarfed by the base. At current SOL prices the headline fee sits well under one cent on non congested blocks." - q: "Why is the Cardano fee always similar?" a: "Cardano fees are deterministic. The protocol parameters min_fee_a per byte and min_fee_b base are set by governance and updated rarely. A standard ADA transaction 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: "Are the L2 numbers complete?" - a: "Not yet. The figure for each Layer 2 reflects L2 execution cost only (the wallet visible gas price times 21000 gas times the ETH price). The L1 data posting fee (blob market for EIP 4844 rollups like Arbitrum, Optimism and Base after Dencun, calldata for the rest) is a separate component that varies block to block and is currently excluded. A blended total cost figure will ship in a later phase. For now use the L1 view for true wallet cost comparison, and read the L2 view as the execution component only." + a: "Not yet. The figure for each Layer 2 reflects L2 execution cost only (the wallet visible gas price times 21000 gas times the ETH price). The L1 data posting fee (blob market for EIP 4844 rollups like Arbitrum and Base after Dencun, calldata for the rest) is a separate component that varies block to block and is currently excluded. A blended total cost figure will ship in a later phase. For now use the L1 view for true wallet cost comparison, and read the L2 view as the execution component only." - q: "Which Layer 1 chain has the cheapest transaction fee right now?" - a: "Open the page. The leaderboard refreshes every 30 seconds and is sorted by cost. As a general pattern, Stellar, Avalanche, Litecoin, Solana, BNB Chain and Gram cluster below one cent, Ethereum and Cardano around three to five cents, and TRON and Monero in the ten cent range. Sui sits in the low one cent range. Exact ordering depends on congestion and native token price at the moment of read." + a: "Open the page. The leaderboard refreshes every 30 seconds and is sorted by cost. As a general pattern, Litecoin, Solana and BNB Chain cluster below one cent, Ethereum and Cardano around one to five cents, and TRON and Monero in the ten cent range. Sui sits in the sub cent range. Exact ordering depends on congestion and native token price at the moment of read." - 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 on the same cadence, so headline values are at most 30 seconds stale plus chain RPC latency (typically under one second)." - q: "Why are some chains showing one tier instead of three?" - a: "Cardano fees are protocol deterministic. Stellar's base fee is 100 stroops per operation network wide. Gram's typical fee is a conservative observed value because the Gram chain has no clean fee estimate RPC. TRON native transfers consume bandwidth at the published rate per byte. None of these chains expose a priority market a user can bid into for a TRX, ADA, XLM or GRAM transfer, so emitting a single tier is more honest than fabricating three identical values." + a: "Cardano fees are protocol deterministic. TRON native transfers consume bandwidth at the published rate per byte. Neither chain exposes a priority market a user can bid into for a TRX or ADA transfer, so emitting a single tier is more honest than fabricating three identical values." - q: "Can I cite a value from this page?" a: "Yes. Every number is a Prometheus query over a 24h window. The query string is shown in the row's hover tooltip. The harness source is open at the link in the source field below. Cite the value and the timestamp at the top of the page." @@ -43,10 +43,6 @@ per_chain_explainer: h2: "BNB Chain transaction fee" body: | BNB Chain native transfer fee is {{p50:bnb}} (p50, 24h). BNB Smart Chain runs a Parlia PoSA consensus with 21 active validators and a fixed 3 s block; fee market is EIP-1559-style but the base fee has historically hovered near a 1 gwei floor enforced by validators, which is why a native BNB transfer typically stays under one cent. Computed as (base + p50 priority) times 21000 gas times live BNB price. - - slug: avalanche - h2: "Avalanche transaction fee" - body: | - Avalanche native transfer fee is {{p50:avalanche}} (p50, 24h). The C-Chain runs an EVM under Avalanche's Snowman consensus with sub-second finality and a dynamic fee mechanism where the base fee target adjusts every 10 seconds; a native AVAX transfer is 21000 gas and sits in the low-cent range when blocks are not saturated by subnet bridge or NFT activity. Computed via `eth_feeHistory` percentile-25/50/90 times live AVAX price. - slug: solana h2: "Solana transaction fee" body: | @@ -63,14 +59,6 @@ per_chain_explainer: h2: "Sui transaction fee" body: | Sui native transfer fee is {{p50:sui}} (p50, 24h). Sui uses a reference-gas-price model where validators agree on a per-epoch gas price via DPoS auction; a `Coin::transfer` consumes around 76000 computation units, so the per-transfer cost stays in the sub-cent range even on busy epochs. Sui has separate computation and storage fees, with the latter rebated when objects are deleted. Computed via `suix_getReferenceGasPrice` times 76000 gas times SUI price. - - slug: gram - h2: "Gram transaction fee" - body: | - Gram (formerly TON) native transfer fee is {{p50:gram}} (p50, 24h). The Gram chain has no clean fee-estimate RPC because its fee model uses a Bag-of-Cells emulation that accounts for storage, gas, forward and import fees separately per workchain and shard; a typical Wallet v4 transfer settles around 0.005 GRAM. We publish that conservative observed value times live GRAM price as a single deterministic tier. - - slug: stellar - h2: "Stellar transaction fee" - body: | - Stellar native transfer fee is {{p50:stellar}} (p50, 24h). The Stellar base fee is 100 stroops (0.00001 XLM) per operation network-wide, surging only when the ledger fills above its capacity threshold; a native XLM payment is one operation, so the fee sits at fractions of a cent except during surge windows. Computed from `horizon fee_stats.last_ledger_base_fee` times one operation times live XLM price. - slug: litecoin h2: "Litecoin transaction fee" body: | @@ -83,14 +71,10 @@ per_chain_explainer: h2: "Arbitrum transaction fee" body: | Arbitrum One native transfer fee is {{p50:arbitrum}} (p50, 24h). Arbitrum Nitro runs an EIP-1559 fee market against the sequencer at sub-second cadence; native ETH transfers are 21000 gas and sit at fractions of a cent because the sequencer's priority fee is near-zero by design. The L1 data-posting component (Ethereum blobs since Dencun) is not yet included in this figure, only L2 execution cost. Computed via `eth_feeHistory` on the Arbitrum sequencer RPC times live ETH price. - - slug: optimism - h2: "Optimism transaction fee" - body: | - Optimism native transfer fee is {{p50:optimism}} (p50, 24h). Optimism uses an OP Stack EIP-1559 fee market with priority fee near-zero because the sequencer is centralized and there is no public mempool to bid against; a native ETH transfer is 21000 gas and L2 execution cost sits well under a cent. The L1 blob-posting cost (separate from this figure) is the dominant wallet-visible component during expensive blob windows. Computed via `eth_feeHistory` on the Optimism sequencer RPC. - slug: base h2: "Base transaction fee" body: | - Base native transfer fee is {{p50:base}} (p50, 24h). Base is Coinbase's OP Stack rollup with the same priority-fee-near-zero pattern as Optimism; native ETH transfers settle at fractions of a cent on L2 execution alone. Base posts batches to Ethereum via EIP-4844 blobs, and the L1 data fee (not in this figure) tracks the blob fee market. Computed via `eth_feeHistory` on the Base sequencer RPC times live ETH price. + Base native transfer fee is {{p50:base}} (p50, 24h). Base is Coinbase's OP Stack rollup with a priority-fee-near-zero pattern; native ETH transfers settle at fractions of a cent on L2 execution alone. Base posts batches to Ethereum via EIP-4844 blobs, and the L1 data fee (not in this figure) tracks the blob fee market. Computed via `eth_feeHistory` on the Base sequencer RPC times live ETH price. - slug: zksync h2: "zkSync Era transaction fee" body: | @@ -99,14 +83,6 @@ per_chain_explainer: h2: "Linea transaction fee" body: | Linea native transfer fee is {{p50:linea}} (p50, 24h). Linea is the Consensys zkEVM and exposes a standard EIP-1559 fee surface to wallets; the sequencer's posted base fee tracks zk-prover-batch economics rather than mempool congestion, so native ETH transfers sit in the sub-cent range even when Ethereum L1 is congested. L1 data-posting cost (blob calldata) is excluded from this figure. Computed via `eth_feeHistory` on the Linea sequencer RPC. - - slug: scroll - h2: "Scroll transaction fee" - body: | - Scroll native transfer fee is {{p50:scroll}} (p50, 24h). Scroll's bytecode-equivalent zkEVM exposes a 1-to-1 EIP-1559 surface so wallets can submit transactions unchanged; the L2 base fee is set by the sequencer to cover Halo2 prover work plus blob-posting, but a native ETH transfer's L2 execution cost still sits in the sub-cent range. Computed via `eth_feeHistory` on the Scroll sequencer RPC times live ETH price. - - slug: blast - h2: "Blast transaction fee" - body: | - Blast native transfer fee is {{p50:blast}} (p50, 24h). Blast is an OP Stack fork with auto-rebasing native ETH yield; the fee market mirrors Optimism's (EIP-1559, near-zero priority, single sequencer), and a 21000-gas native transfer sits at fractions of a cent on L2 execution. The L1 blob posting fee is the dominant wallet cost during congested blob periods but is excluded from this figure. Computed via `eth_feeHistory` on the Blast sequencer RPC. - slug: mantle h2: "Mantle transaction fee" body: | @@ -123,43 +99,41 @@ unit: usd higher_is_better: false abstract: | - Every 30 seconds we ask each of 20 chains for its current native + Every 30 seconds we ask each of 14 chains for its current native transaction 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, standard, fast) mapped to roughly the 25th, 50th and 90th - percentile of the recent fee distribution. Deterministic fee chains - emit a single tier. The eleven Layer 1 chains mirror the L1 finality - bench so users can compare cost and speed side by side. The nine - Layer 2 rollups add the layer dimension so a wallet routing decision - can be made on real data instead of marketing claims. + sun, MIST, 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, + standard, fast) mapped to roughly the 25th, 50th and 90th percentile + of the recent fee distribution. Deterministic fee chains emit a + single tier. The eight Layer 1 chains mirror the L1 finality bench + so users can compare cost and speed side by side. The six Layer 2 + rollups add the layer dimension so a wallet routing decision can be + made on real data instead of marketing claims. methodology: - - "Refresh cadence. 30 seconds. One process samples all 20 chains in parallel goroutines." - - "Ethereum, BNB Chain and Avalanche on the L1 tab. eth_feeHistory over the last 4 blocks at percentiles 25, 50 and 90. Cost = (base_fee + reward_percentile) * 21000 gas, mapped to slow, standard and fast." - - "Arbitrum, Optimism, Base, zkSync Era, Linea, Scroll, Blast, Mantle and Taiko on the L2 tab. Same eth_feeHistory flow against each rollup's sequencer RPC. ETH is the native gas asset on every tracked rollup." + - "Refresh cadence. 30 seconds. One process samples all 14 chains in parallel goroutines." + - "Ethereum and BNB Chain on the L1 tab. eth_feeHistory over the last 4 blocks at percentiles 25, 50 and 90. Cost = (base_fee + reward_percentile) * 21000 gas, mapped to slow, standard and fast." + - "Arbitrum, Base, zkSync Era, Linea, Mantle and Taiko on the L2 tab. Same eth_feeHistory flow against each rollup's sequencer RPC. ETH is the native gas asset on every tracked rollup except Mantle, which prices gas in MNT." - "Layer 2 caveat. The published figure is L2 execution cost only. The L1 data posting fee (blob market for EIP 4844 rollups, calldata for the rest) is excluded from this page and will be added as a separate blended figure in a later phase. On OP Stack rollups the L1 data fee can dominate the wallet visible total during expensive blob periods." - "Solana. getRecentPrioritizationFees percentiles 25, 50 and 90 of micro lamports per compute unit, times 200 compute units, plus 5000 lamports base. Empty fees response collapses to a single standard tier at the 5000 base." - "TRON. getChainParameters.getTransactionFee (currently 1000 sun per byte) times 268 bytes for a typical native transfer. Single tier because TRON native transfers do not bid into a priority market." - "Cardano. koios epoch_params.min_fee_a and min_fee_b, times 250 bytes for a typical native transfer. Deterministic by protocol, refreshes only when on chain parameters change." - - "Stellar. horizon fee_stats.last_ledger_base_fee times 1 operation. Single tier." - "Sui. suix_getReferenceGasPrice times 76000 gas (typical observed for a Coin::transfer call). Single standard tier." - - "Gram (formerly TON). Hardcoded 0.005 GRAM, the typical observed wallet v4 transfer. The Gram fee model uses Bag of Cells emulation and has no clean fee estimate RPC." - "Litecoin. litecoinspace.org /api/v1/fees/recommended (hour, half hour and fastest fees in litoshi per vByte) times 225 vBytes for a typical 1 input 1 output P2WPKH transfer." - "Monero. monero rpc get_fee_estimate.fees[0..2] times 1500 bytes for a typical 1 input 2 output RingCT transaction." - - "USD prices. api.mobula.io/api/1/market/multi-data polled every 30 seconds for all 20 native tokens in one call." + - "USD prices. api.mobula.io/api/1/market/multi-data polled every 30 seconds for all 14 native tokens 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} to zero." findings: - "{{best_name}} is the cheapest tracked native transaction at {{best_p50}} over the last 24 hours." - "{{name:ethereum}} sits at {{p50:ethereum}} (standard tier, 24h median), the most expensive Layer 1 transaction on the leaderboard during normal congestion." - - "{{name:bnb}}, {{name:avalanche}}, {{name:stellar}}, {{name:solana}} and {{name:litecoin}} cluster near or below one cent for a standard transaction." + - "{{name:bnb}}, {{name:solana}} and {{name:litecoin}} cluster near or below one cent for a standard transaction." - "{{name:cardano}}, {{name:tron}} and {{name:monero}} use deterministic or near deterministic fee models. The USD figure on the leaderboard moves with native token price, not with network congestion." - "USD costs are computed at scrape time. A 10 percent intraday move in the native token's USD price shifts the headline by 10 percent even if the chain native fee is flat. We surface that intentionally because the dollar cost is what a wallet user actually pays." - "Layer 2 values currently capture L2 execution only. The L1 data posting component is excluded until the blended figure ships in a follow up phase." -source: https://github.com/MobulaFi/mobula-monorepo/tree/main/miniapps/transaction-fee +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/transaction-fee prometheus: window: 24h @@ -200,19 +174,6 @@ providers: success: avg_over_time(tx_fee_health{chain="bnb"}[24h]) series: tx_fee_native_transfer_usd{chain="bnb",tier="std"} - - 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: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="avalanche",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="avalanche",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="avalanche",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="avalanche",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="avalanche"}[24h]) - series: tx_fee_native_transfer_usd{chain="avalanche",tier="std"} - - slug: solana name: Solana layer: l1 @@ -265,32 +226,6 @@ providers: success: avg_over_time(tx_fee_health{chain="sui"}[24h]) series: tx_fee_native_transfer_usd{chain="sui",tier="std"} - - slug: gram - name: Gram - layer: l1 - tag: Hardcoded 0.005 GRAM typical wallet v4 transfer - formula: "Conservative typical observed cost of a Gram (formerly TON) wallet transfer × GRAM USD price. Gram has no fee estimate RPC; this is the observed median." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="gram",tier="single"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="gram",tier="single"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="gram",tier="single"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="gram",tier="single"}[24h]) - success: avg_over_time(tx_fee_health{chain="gram"}[24h]) - series: tx_fee_native_transfer_usd{chain="gram",tier="single"} - - - 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: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h]) - success: avg_over_time(tx_fee_health{chain="stellar"}[24h]) - series: tx_fee_native_transfer_usd{chain="stellar",tier="single"} - - slug: litecoin name: Litecoin layer: l1 @@ -329,18 +264,6 @@ providers: 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 for now." - 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 @@ -377,30 +300,6 @@ providers: 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 for now." - 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 for now." - 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 diff --git a/benchmarks/nft-collection-metadata.yml b/benchmarks/nft-collection-metadata.yml index 0faf2387a..f2cc7f31c 100644 --- a/benchmarks/nft-collection-metadata.yml +++ b/benchmarks/nft-collection-metadata.yml @@ -3,8 +3,8 @@ slug: nft-collection-metadata number: "040" title: "Best NFT collection metadata API: Moralis, Alchemy, OpenSea benchmarked" -seo_title: "Best NFT collection metadata API 2026: Moralis, Alchemy, OpenSea" -seo_description: "NFT collection metadata API ranked live. Share of name, image, description, floor_eth, external_url populated on 50 Ethereum blue-chip collections. Moralis, Alchemy, OpenSea." +seo_title: "Best NFT collection metadata API 2026" +seo_description: "NFT collection metadata API ranked live: name, image, description, floor_eth populated across major collections." subtitle: Share of metadata fields (name, image, description, floor_eth, external_url) populated across 50 Ethereum blue-chip NFT collections, audited every 6 hours. category: NFT APIs status: live diff --git a/benchmarks/oracle-deviation.yml b/benchmarks/oracle-deviation.yml index 4bf578e10..79e38f0c3 100644 --- a/benchmarks/oracle-deviation.yml +++ b/benchmarks/oracle-deviation.yml @@ -3,7 +3,7 @@ slug: oracle-deviation number: "025" title: Chainlink vs Pyth vs Binance vs Coinbase, live oracle deviation -seo_title: "Chainlink vs Pyth oracle deviation 2026: BTC, ETH, SOL ranked" +seo_title: "Chainlink vs Pyth oracle deviation 2026" seo_description: "Chainlink, Pyth, Binance, Coinbase oracle deviation ranked live on BTC, ETH, SOL, BNB, XRP, ADA, DOGE, AVAX, LINK, POL. Max pairwise bps, p99 over 24h." subtitle: Maximum pairwise price disagreement in basis points across Chainlink, Pyth, Binance and Coinbase, polled every 30 seconds on 10 USD pairs. category: Trading diff --git a/benchmarks/perp-fees.yml b/benchmarks/perp-fees.yml index 775cbfbb3..f61af0b6c 100644 --- a/benchmarks/perp-fees.yml +++ b/benchmarks/perp-fees.yml @@ -3,7 +3,7 @@ slug: perp-fees number: "007" title: Cheapest perp DEX, live all-in fee on a $1000 ETH 10x long -seo_title: "Cheapest perp DEX 2026: Lighter, Hyperliquid, dYdX, GMX ranked" +seo_title: "Cheapest perp DEX 2026" seo_description: "{{best_name}} leads cheapest perp DEX at {{best_p50}} all-in (24h avg). $1000 ETH 10x long. Lighter, Hyperliquid, dYdX v4, GMX v2, gains.trade ranked live." subtitle: All-in cost in basis points to open a $1000 ETH long 10x position. Taker fee plus half-spread plus impact, measured live from public APIs across Lighter, Hyperliquid, dYdX, GMX and gains.trade. category: Trading diff --git a/benchmarks/perp-funding-stability.yml b/benchmarks/perp-funding-stability.yml index ee15d5fb9..3a7cd761c 100644 --- a/benchmarks/perp-funding-stability.yml +++ b/benchmarks/perp-funding-stability.yml @@ -3,8 +3,8 @@ slug: perp-funding-stability number: "043" title: Perp DEX funding stability, 24h stddev of ETH funding ranked -seo_title: "Perp funding stability 2026: ETH funding stddev across 15 venues ranked" -seo_description: "Live 24h standard deviation of ETH perpetual futures funding rate in bps across 15 major venues: Hyperliquid, Bybit, dYdX v4, Binance, OKX, Paradex, Aster, Bitget, Coinbase, Deribit, Gate, Kraken, KuCoin, Lighter, MEXC. Lower means more stable funding for carry traders and basis arbitrageurs." +seo_title: "Perp funding stability 2026: ETH stddev" +seo_description: "Live 24h stddev of ETH perpetual funding rate in bps across 15 major venues. Deepest book = smoothest rate." subtitle: 24h standard deviation of ETH funding rate, in basis points per 24h, across the perp-funding cohort. Lower means funding stays in a tight band, the carry trader's preferred signal. category: Trading status: live diff --git a/benchmarks/perp-funding.yml b/benchmarks/perp-funding.yml index 704692d5d..9e93acd99 100644 --- a/benchmarks/perp-funding.yml +++ b/benchmarks/perp-funding.yml @@ -3,8 +3,8 @@ slug: perp-funding number: "036" title: Cheapest perp venue to hold a position, live funding normalized -seo_title: "Perp funding rates live 2026: Hyperliquid, Binance, Bybit, OKX, dYdX, Paradex, Aster normalized and ranked" -seo_description: "Live funding rates across Hyperliquid, Binance, Bybit, OKX, dYdX, Paradex and Aster, normalized to the same time base (venues settle on 1h, 4h or 8h periods). Ranked by the cost of holding a $1,000 ETH long for 24 hours, with BTC and SOL columns, annualized rates and 7d/30d averages." +seo_title: "Perp funding rates live 2026" +seo_description: "Live perp funding rates across Hyperliquid, Binance, Bybit, OKX, dYdX, Paradex and Aster, normalized hourly." subtitle: Funding cost in basis points to hold a long position for 24 hours at the current rate, normalized across venues that settle funding on 1 hour, 4 hour and 8 hour periods. Negative means longs get paid. category: Trading status: live diff --git a/benchmarks/perp-open-interest.yml b/benchmarks/perp-open-interest.yml index 95d29f408..4749f8df9 100644 --- a/benchmarks/perp-open-interest.yml +++ b/benchmarks/perp-open-interest.yml @@ -3,9 +3,9 @@ slug: perp-open-interest number: "042" title: Perp DEX open interest, live USD notional ranked -seo_title: "Perp DEX open interest 2026: Hyperliquid, Lighter, GMX, gains.trade ranked" -seo_description: "Live perpetual futures open interest in USD across Hyperliquid, Lighter, GMX v2 and gains.trade. Public API sourced, refreshed every 5 minutes." -subtitle: Live aggregate open interest in USD across major decentralized perpetual futures venues. Higher means more notional sits open. Read live from each venue's public API. +seo_title: "Perp DEX open interest 2026" +seo_description: "Live perpetual futures open interest in USD across 11 major perp DEX venues: Hyperliquid, Aster, Lighter, GRVT, Extended, Ostium, Pacifica, Vertex, dYdX, Aevo, Paradex. Refreshed every 5 minutes." +subtitle: Live aggregate open interest in USD across 11 major decentralized perpetual futures venues. Higher means more notional sits open. Read live from each venue's public API. category: Trading status: live metric: Open interest @@ -18,36 +18,53 @@ seo_intro: | number for perpetual futures: it tells you how much notional sits open right now, on every market, on a given venue. We poll each venue's public API every 5 minutes, sum open interest across all - listed perps, and publish one gauge per venue. Hyperliquid, Lighter, - GMX v2 and gains.trade are the cohort. Headline number is the 24h - average of the live OI gauge so a single noisy print does not move - the ranking. + listed perps, and publish one gauge per venue. The cohort covers 11 + venues: Hyperliquid, Aster, Lighter, GRVT, Extended, Ostium, Pacifica, + Vertex, dYdX v4, Aevo and Paradex. Headline number is the 24h average + of the live OI gauge so a single noisy print does not move the + ranking. abstract: | - The bench polls four perp DEX venues every 5 minutes for live open - interest and exposes a USD-aggregated gauge per venue. Sources: - Hyperliquid info metaAndAssetCtxs (openInterest per asset, summed in - USD), Lighter orderBookDetails (open interest per market), GMX - Subsquid synthetics-arbitrum (openInterest by market), gains.trade - onchain reads aggregated by indexer. Each value carries a freshness - timestamp and a health gauge so a stale or errored venue does not - poison the leaderboard. Higher is better. + The bench polls 11 perp DEX venues every 5 minutes for live open + interest and exposes a USD-aggregated gauge per venue. Each venue's + public API is queried directly (Hyperliquid info metaAndAssetCtxs, + Aster fapi openInterest, Lighter orderBookDetails, GRVT public + markets, Extended API, Ostium subgraph, Pacifica onchain reads, + Vertex indexer, dYdX v4 indexer, Aevo public markets, Paradex + markets summary) and the notional is summed in USD across all + listed perps. Each value carries a freshness timestamp and a health + gauge so a stale or errored venue does not poison the leaderboard. + Higher is better. methodology: - "Cadence: every 5 minutes per venue in parallel, 10 second timeout per request." - "Hyperliquid: info metaAndAssetCtxs, openInterest summed across all assets and priced in USD using the venue's own mark price." + - "Aster: fapi openInterest endpoint per instrument, summed across all listed USDT perps." - "Lighter: orderBookDetails per market, open interest summed across markets in USD." - - "GMX v2: synthetics-arbitrum subgraph, openInterestUSD summed across markets." - - "gains.trade: onchain indexer over Gains v8 contracts on Base, open interest summed across pairs in USD." + - "GRVT: public markets endpoint, open interest per instrument summed in USD." + - "Extended: public markets API, open interest per instrument summed in USD." + - "Ostium: subgraph read for open interest across all listed pairs on Arbitrum." + - "Pacifica: onchain reads on the Solana program state for aggregate open interest." + - "Vertex: public indexer, open interest per product summed across all perps." + - "dYdX v4: indexer perpetualMarkets, openInterest per market summed in USD." + - "Aevo: public markets endpoint, open interest per instrument summed in USD." + - "Paradex: markets summary endpoint on Starknet L2, open interest summed across perps." - "Headline. avg_over_time of the live OI gauge over the last 24 hours, so a one-print spike does not move the ranking. The Series tab plots the raw gauge." - - "Failures. A venue that errors or times out keeps its last gauge value and its perp_venue_health gauge drops to 0; the leaderboard tags it as stale." + - "Failures. A venue that errors or times out keeps its last gauge value and its perp_venue_health gauge drops toward 0; the leaderboard tags it as stale." findings: - - "{{best_name}} currently leads the cohort at {{best_p50}} of open interest (24h average)." - - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} of open interest. HyperBFT orderbook is the deepest decentralized perp venue." - - "{{name:lighter}} clocks {{p50:lighter}} of open interest. Fully onchain orderbook on zkSync." - - "{{name:gmx}} sits at {{p50:gmx}} of open interest. Pool-based execution on Arbitrum and Avalanche." - - "{{name:gains}} sits at {{p50:gains}} of open interest. Synthetic perps on Base." + - "{{best_name}} currently leads the cohort at {{best_p50}} of open interest (24h average) across 11 measured perp DEX venues." + - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} of open interest. HyperBFT orderbook is the deepest decentralized perp venue by a wide margin." + - "{{name:lighter}} clocks {{p50:lighter}} of open interest. Fully onchain orderbook on zkSync with zero taker fee." + - "{{name:aster}} sits at {{p50:aster}} of open interest. BNB Chain perps DEX with a Binance-compatible API surface." + - "{{name:grvt}} sits at {{p50:grvt}} of open interest. Hybrid CEX/DEX perps on ZK Stack." + - "{{name:extended}} sits at {{p50:extended}} of open interest. StarkEx-based perps venue." + - "{{name:pacifica}} sits at {{p50:pacifica}} of open interest. Solana-native onchain perps." + - "{{name:dydx}} sits at {{p50:dydx}} of open interest. Cosmos appchain orderbook, one of the oldest decentralized perp venues in the cohort." + - "{{name:ostium}} sits at {{p50:ostium}} of open interest. Synthetic perps on Arbitrum covering FX and commodities alongside crypto." + - "{{name:vertex}} sits at {{p50:vertex}} of open interest. Arbitrum orderbook plus AMM hybrid." + - "{{name:paradex}} sits at {{p50:paradex}} of open interest. Starknet L2 perps." + - "{{name:aevo}} sits at {{p50:aevo}} of open interest. Optimism L2 orderbook perps, options-focused history." source: https://github.com/ChainBench/OpenChainBench/tree/main/benchmarks/perp-open-interest.yml @@ -57,11 +74,11 @@ prometheus: faq: - q: "What does this benchmark measure?" - a: "The live aggregate open interest of major perp DEX venues in USD. Headline is the 24h average of the live OI gauge so single prints do not skew the ranking." + a: "The live aggregate open interest of 11 major perp DEX venues in USD. Headline is the 24h average of the live OI gauge so single prints do not skew the ranking." - q: "How is open interest different from volume?" a: "Volume counts every trade that crosses the book during a window. Open interest counts notional that currently sits open, regardless of when it was opened. A high-volume venue with high turnover can carry lower OI than a slower venue with sticky positions." - - q: "Why these four venues?" - a: "Hyperliquid, Lighter, GMX v2 and gains.trade are the cohort whose public APIs expose open interest cleanly enough to compare without backfilling. Other venues join as their endpoints document the same series." + - q: "Which venues are in the cohort?" + a: "Hyperliquid, Aster, Lighter, GRVT, Extended, Ostium, Pacifica, Vertex, dYdX v4, Aevo and Paradex, the 11 decentralized perp venues whose public APIs expose open interest cleanly enough to compare without backfilling. Other venues join as their endpoints document the same series." - q: "Is OI a good proxy for venue health?" a: "It is one of the cleanest. A venue can spike volume with wash trading or incentive programs, but real OI is harder to fake because it ties up margin. Compared against perp-volume-share, persistent OI dominance signals real position-holding flow rather than turnover." @@ -79,6 +96,19 @@ providers: sample_size: count_over_time(perp_venue_oi_usd{venue="hyperliquid"}[24h]) series: perp_venue_oi_usd{venue="hyperliquid"} + - slug: aster + name: Aster + tag: BNB Chain perps DEX, Binance-compatible API + formula: "Open interest from Aster fapi openInterest per instrument, summed across all listed USDT perps; headline is the 24h time average." + queries: + p50: avg_over_time(perp_venue_oi_usd{venue="aster"}[24h]) + p90: quantile_over_time(0.90, perp_venue_oi_usd{venue="aster"}[24h]) + p99: quantile_over_time(0.99, perp_venue_oi_usd{venue="aster"}[24h]) + mean: avg_over_time(perp_venue_oi_usd{venue="aster"}[24h]) + success: avg_over_time(perp_venue_health{venue="aster"}[24h]) + sample_size: count_over_time(perp_venue_oi_usd{venue="aster"}[24h]) + series: perp_venue_oi_usd{venue="aster"} + - slug: lighter name: Lighter tag: zk-rollup, zero taker fee @@ -92,28 +122,106 @@ providers: sample_size: count_over_time(perp_venue_oi_usd{venue="lighter"}[24h]) series: perp_venue_oi_usd{venue="lighter"} - - slug: gmx - name: GMX v2 - tag: Synthetics on Arbitrum, oracle-priced - formula: "openInterestUSD from the GMX synthetics-arbitrum subgraph summed across markets; headline is the 24h time average." + - slug: grvt + name: GRVT + tag: Hybrid CEX/DEX perps on ZK Stack + formula: "Open interest from GRVT public markets endpoint per instrument, summed in USD; headline is the 24h time average." + queries: + p50: avg_over_time(perp_venue_oi_usd{venue="grvt"}[24h]) + p90: quantile_over_time(0.90, perp_venue_oi_usd{venue="grvt"}[24h]) + p99: quantile_over_time(0.99, perp_venue_oi_usd{venue="grvt"}[24h]) + mean: avg_over_time(perp_venue_oi_usd{venue="grvt"}[24h]) + success: avg_over_time(perp_venue_health{venue="grvt"}[24h]) + sample_size: count_over_time(perp_venue_oi_usd{venue="grvt"}[24h]) + series: perp_venue_oi_usd{venue="grvt"} + + - slug: extended + name: Extended + tag: StarkEx-based perps venue + formula: "Open interest from Extended public markets API per instrument, summed in USD; headline is the 24h time average." + queries: + p50: avg_over_time(perp_venue_oi_usd{venue="extended"}[24h]) + p90: quantile_over_time(0.90, perp_venue_oi_usd{venue="extended"}[24h]) + p99: quantile_over_time(0.99, perp_venue_oi_usd{venue="extended"}[24h]) + mean: avg_over_time(perp_venue_oi_usd{venue="extended"}[24h]) + success: avg_over_time(perp_venue_health{venue="extended"}[24h]) + sample_size: count_over_time(perp_venue_oi_usd{venue="extended"}[24h]) + series: perp_venue_oi_usd{venue="extended"} + + - slug: ostium + name: Ostium + tag: Synthetic perps on Arbitrum, FX and commodities alongside crypto + formula: "Open interest from the Ostium subgraph across all listed pairs on Arbitrum, summed in USD; headline is the 24h time average." + queries: + p50: avg_over_time(perp_venue_oi_usd{venue="ostium"}[24h]) + p90: quantile_over_time(0.90, perp_venue_oi_usd{venue="ostium"}[24h]) + p99: quantile_over_time(0.99, perp_venue_oi_usd{venue="ostium"}[24h]) + mean: avg_over_time(perp_venue_oi_usd{venue="ostium"}[24h]) + success: avg_over_time(perp_venue_health{venue="ostium"}[24h]) + sample_size: count_over_time(perp_venue_oi_usd{venue="ostium"}[24h]) + series: perp_venue_oi_usd{venue="ostium"} + + - slug: pacifica + name: Pacifica + tag: Solana-native onchain perps + formula: "Open interest read onchain from the Pacifica Solana program state, summed across all listed perps; headline is the 24h time average." queries: - p50: avg_over_time(perp_venue_oi_usd{venue="gmx-v2"}[24h]) - p90: quantile_over_time(0.90, perp_venue_oi_usd{venue="gmx-v2"}[24h]) - p99: quantile_over_time(0.99, perp_venue_oi_usd{venue="gmx-v2"}[24h]) - mean: avg_over_time(perp_venue_oi_usd{venue="gmx-v2"}[24h]) - success: avg_over_time(perp_venue_health{venue="gmx-v2"}[24h]) - sample_size: count_over_time(perp_venue_oi_usd{venue="gmx-v2"}[24h]) - series: perp_venue_oi_usd{venue="gmx-v2"} - - - slug: gains - name: gains.trade - tag: Synthetic perps on Base - formula: "Open interest across Gains v8 pairs on Base, read onchain and summed in USD; headline is the 24h time average." + p50: avg_over_time(perp_venue_oi_usd{venue="pacifica"}[24h]) + p90: quantile_over_time(0.90, perp_venue_oi_usd{venue="pacifica"}[24h]) + p99: quantile_over_time(0.99, perp_venue_oi_usd{venue="pacifica"}[24h]) + mean: avg_over_time(perp_venue_oi_usd{venue="pacifica"}[24h]) + success: avg_over_time(perp_venue_health{venue="pacifica"}[24h]) + sample_size: count_over_time(perp_venue_oi_usd{venue="pacifica"}[24h]) + series: perp_venue_oi_usd{venue="pacifica"} + + - slug: vertex + name: Vertex + tag: Arbitrum orderbook plus AMM hybrid + formula: "Open interest from the Vertex public indexer per product, summed across all perps in USD; headline is the 24h time average." + queries: + p50: avg_over_time(perp_venue_oi_usd{venue="vertex"}[24h]) + p90: quantile_over_time(0.90, perp_venue_oi_usd{venue="vertex"}[24h]) + p99: quantile_over_time(0.99, perp_venue_oi_usd{venue="vertex"}[24h]) + mean: avg_over_time(perp_venue_oi_usd{venue="vertex"}[24h]) + success: avg_over_time(perp_venue_health{venue="vertex"}[24h]) + sample_size: count_over_time(perp_venue_oi_usd{venue="vertex"}[24h]) + series: perp_venue_oi_usd{venue="vertex"} + + - slug: dydx + name: dYdX v4 + tag: Cosmos appchain orderbook perps + formula: "Open interest from the dYdX v4 indexer perpetualMarkets endpoint, summed per market in USD; headline is the 24h time average." + queries: + p50: avg_over_time(perp_venue_oi_usd{venue="dydx"}[24h]) + p90: quantile_over_time(0.90, perp_venue_oi_usd{venue="dydx"}[24h]) + p99: quantile_over_time(0.99, perp_venue_oi_usd{venue="dydx"}[24h]) + mean: avg_over_time(perp_venue_oi_usd{venue="dydx"}[24h]) + success: avg_over_time(perp_venue_health{venue="dydx"}[24h]) + sample_size: count_over_time(perp_venue_oi_usd{venue="dydx"}[24h]) + series: perp_venue_oi_usd{venue="dydx"} + + - slug: aevo + name: Aevo + tag: Optimism L2 orderbook perps, options-focused history + formula: "Open interest from the Aevo public markets endpoint per instrument, summed in USD; headline is the 24h time average." + queries: + p50: avg_over_time(perp_venue_oi_usd{venue="aevo"}[24h]) + p90: quantile_over_time(0.90, perp_venue_oi_usd{venue="aevo"}[24h]) + p99: quantile_over_time(0.99, perp_venue_oi_usd{venue="aevo"}[24h]) + mean: avg_over_time(perp_venue_oi_usd{venue="aevo"}[24h]) + success: avg_over_time(perp_venue_health{venue="aevo"}[24h]) + sample_size: count_over_time(perp_venue_oi_usd{venue="aevo"}[24h]) + series: perp_venue_oi_usd{venue="aevo"} + + - slug: paradex + name: Paradex + tag: Starknet L2 perps + formula: "Open interest from the Paradex markets summary endpoint on Starknet L2, summed across perps in USD; headline is the 24h time average." queries: - p50: avg_over_time(perp_venue_oi_usd{venue="gains"}[24h]) - p90: quantile_over_time(0.90, perp_venue_oi_usd{venue="gains"}[24h]) - p99: quantile_over_time(0.99, perp_venue_oi_usd{venue="gains"}[24h]) - mean: avg_over_time(perp_venue_oi_usd{venue="gains"}[24h]) - success: avg_over_time(perp_venue_health{venue="gains"}[24h]) - sample_size: count_over_time(perp_venue_oi_usd{venue="gains"}[24h]) - series: perp_venue_oi_usd{venue="gains"} + p50: avg_over_time(perp_venue_oi_usd{venue="paradex"}[24h]) + p90: quantile_over_time(0.90, perp_venue_oi_usd{venue="paradex"}[24h]) + p99: quantile_over_time(0.99, perp_venue_oi_usd{venue="paradex"}[24h]) + mean: avg_over_time(perp_venue_oi_usd{venue="paradex"}[24h]) + success: avg_over_time(perp_venue_health{venue="paradex"}[24h]) + sample_size: count_over_time(perp_venue_oi_usd{venue="paradex"}[24h]) + series: perp_venue_oi_usd{venue="paradex"} diff --git a/benchmarks/perp-volume-share.yml b/benchmarks/perp-volume-share.yml index ab70075f1..bbd47cb8c 100644 --- a/benchmarks/perp-volume-share.yml +++ b/benchmarks/perp-volume-share.yml @@ -2,53 +2,67 @@ slug: perp-volume-share number: "041" -title: Perp DEX volume share, live 30-day rolling notional ranked -seo_title: "Perp DEX volume 2026: Hyperliquid, Lighter, GMX, gains.trade ranked by 30d notional" -seo_description: "Live 30-day rolling perp DEX volume across Hyperliquid, Lighter, GMX v2 and gains.trade in USD notional. Public API sourced, refreshed every 5 minutes." -subtitle: 30-day rolling perpetual futures notional volume in USD across major decentralized venues. Higher means more flow. Read live from each venue's public API. +title: Perp DEX volume share, live 24h notional ranked +seo_title: "Perp DEX volume 24h ranking 2026" +seo_description: "Live 24h perp DEX volume in USD notional across 10 major venues: Hyperliquid, Aster, Lighter, GRVT, Extended, Pacifica, Vertex, dYdX, Aevo, Paradex. Refreshed every 5 minutes." +subtitle: 24h rolling perpetual futures notional volume in USD across 10 major decentralized venues. Higher means more flow. Read live from each venue's public API. category: Trading status: live -metric: 30d perp volume +metric: 24h perp volume unit: usd higher_is_better: true seo_intro: | - This benchmark ranks the major onchain perp venues by 30-day rolling + This benchmark ranks the major onchain perp venues by 24 hour rolling notional volume, in USD. Volume is the cleanest single-number proxy for where perpetual futures flow actually lives today. We poll each - venue's public API every 5 minutes, sum the trailing 30 days of taker - notional in USD, and publish a single gauge per venue. Hyperliquid, - Lighter, GMX v2 and gains.trade are the cohort. Headline number is - the 24h average of the 30d rolling sum, so a single noisy print does - not move the ranking. The companion benches price the cost side - (perp-fees for opening, perp-funding for holding); this one prices - flow. + venue's public API every 5 minutes, read the venue's own trailing 24 + hour taker notional in USD, and publish a single gauge per venue. + The cohort covers 10 venues: Hyperliquid, Aster, Lighter, GRVT, + Extended, Pacifica, Vertex, dYdX v4, Aevo and Paradex. Headline + number is the 24h average of the live 24h gauge so a single noisy + print does not move the ranking. The companion benches price the + cost side (perp-fees for opening, perp-funding for holding); this + one prices flow. abstract: | - The bench polls four perp DEX venues every 5 minutes for cumulative - taker notional and exposes a rolling 30 day USD sum per venue. - Sources: Hyperliquid info dayNtlVlm (per-asset day notional, summed - and rolled into 30d), Lighter public stats endpoint (24h volume per - market, rolled), GMX Subsquid synthetics-arbitrum (positionVolume by - day), gains.trade onchain reads aggregated by indexer. Each value - carries a freshness timestamp and a health gauge so a stale or - errored venue does not poison the leaderboard. Higher is better. + The bench polls 10 perp DEX venues every 5 minutes for the trailing + 24 hour taker notional and exposes a USD gauge per venue. Sources: + Hyperliquid info dayNtlVlm (per-asset day notional summed in USD), + Aster fapi 24h ticker per instrument, Lighter public stats endpoint, + GRVT public markets, Extended API, Pacifica onchain reads, Vertex + indexer, dYdX v4 indexer perpetualMarkets, Aevo public markets, + Paradex markets summary. Each value carries a freshness timestamp + and a health gauge so a stale or errored venue does not poison the + leaderboard. Higher is better. methodology: - "Cadence: every 5 minutes per venue in parallel, 10 second timeout per request." - - "Hyperliquid: info dayNtlVlm summed across all assets, rolled into a 30 day window via Prometheus sum_over_time on the daily gauge." - - "Lighter: public stats endpoint, 24h volume per market, summed across markets and rolled into 30 days the same way." - - "GMX v2: synthetics-arbitrum subgraph, positionVolume aggregated per day across all markets, rolled into 30 days." - - "gains.trade: onchain indexer over Gains v8 contracts on Base, taker notional summed per day and rolled." - - "Headline. avg_over_time of the rolling 30 day sum over the last 24 hours, so a one-print spike does not move the ranking. The Series tab plots the raw rolling sum." - - "Failures. A venue that errors or times out keeps its last gauge value and its perp_venue_health gauge drops to 0; the leaderboard tags it as stale." + - "Hyperliquid: info dayNtlVlm summed across all assets, published as the trailing 24 hour taker notional in USD." + - "Aster: fapi 24h ticker per instrument, quoteVolume summed across all USDT perps." + - "Lighter: public stats endpoint, 24h volume per market summed across markets in USD." + - "GRVT: public markets endpoint, 24h volume per instrument summed in USD." + - "Extended: public markets API, 24h volume per instrument summed in USD." + - "Pacifica: onchain reads on the Solana program state for trailing 24h taker notional." + - "Vertex: public indexer, 24h volume per product summed across all perps." + - "dYdX v4: indexer perpetualMarkets, volume24H summed across markets in USD." + - "Aevo: public markets endpoint, 24h volume per instrument summed in USD." + - "Paradex: markets summary on Starknet L2, 24h volume summed across perps." + - "Headline. avg_over_time of the 24 hour volume gauge over the last 24 hours, so a one-print spike does not move the ranking. The Series tab plots the raw gauge." + - "Failures. A venue that errors or times out keeps its last gauge value and its perp_venue_health gauge drops toward 0; the leaderboard tags it as stale." findings: - - "{{best_name}} currently leads the cohort at {{best_p50}} of 30d notional (24h average)." - - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} of 30d notional, the deepest decentralized perp book in the field." - - "{{name:lighter}} clocks {{p50:lighter}} of 30d notional. Zero taker fee plus a fully onchain orderbook on zkSync." - - "{{name:gmx}} sits at {{p50:gmx}} of 30d notional. Pool-based execution on Arbitrum and Avalanche." - - "{{name:gains}} sits at {{p50:gains}} of 30d notional. Synthetic perps on Base with onchain fee reads." + - "{{best_name}} currently leads the cohort at {{best_p50}} of 24h notional (24h average) across 10 measured perp DEX venues." + - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} of 24h notional, the deepest decentralized perp book in the field." + - "{{name:aster}} sits at {{p50:aster}} of 24h notional. BNB Chain perps DEX with a Binance-compatible API." + - "{{name:lighter}} clocks {{p50:lighter}} of 24h notional. Zero taker fee plus a fully onchain orderbook on zkSync." + - "{{name:grvt}} sits at {{p50:grvt}} of 24h notional. Hybrid CEX/DEX perps on ZK Stack." + - "{{name:pacifica}} sits at {{p50:pacifica}} of 24h notional. Solana-native onchain perps." + - "{{name:extended}} sits at {{p50:extended}} of 24h notional. StarkEx-based perps venue." + - "{{name:dydx}} sits at {{p50:dydx}} of 24h notional. Cosmos appchain orderbook." + - "{{name:vertex}} sits at {{p50:vertex}} of 24h notional. Arbitrum orderbook plus AMM hybrid." + - "{{name:paradex}} sits at {{p50:paradex}} of 24h notional. Starknet L2 perps." + - "{{name:aevo}} sits at {{p50:aevo}} of 24h notional. Optimism L2 orderbook perps." source: https://github.com/ChainBench/OpenChainBench/tree/main/benchmarks/perp-volume-share.yml @@ -58,11 +72,11 @@ prometheus: faq: - q: "What does this benchmark measure?" - a: "The 30 day rolling notional volume of major perp DEX venues in USD. Headline is the 24h average of the rolling 30d sum so single prints do not skew the ranking." - - q: "Why 30 day rolling instead of daily?" - a: "Daily perp volume is too noisy for ranking, a single thin Sunday can flip positions. The 30 day window smooths the cycle and matches the cadence at which serious flow makers compare venues." - - q: "Why these four venues?" - a: "Hyperliquid, Lighter, GMX v2 and gains.trade are the cohort whose public APIs expose taker notional cleanly enough to compare without backfilling. Other venues join as their endpoints document the same series." + a: "The trailing 24 hour taker notional volume of 10 major perp DEX venues in USD. Headline is the 24h average of the live 24h gauge so single prints do not skew the ranking." + - q: "Why 24h rolling instead of daily snapshots?" + a: "Every venue in the cohort publishes a live rolling 24 hour window on its own public API, so a rolling comparison sidesteps timezone bucketing and the noise of a single calendar day. The 24h average smooths intraday micro-spikes without hiding regime shifts within the window." + - q: "Which venues are in the cohort?" + a: "Hyperliquid, Aster, Lighter, GRVT, Extended, Pacifica, Vertex, dYdX v4, Aevo and Paradex, the 10 decentralized perp venues whose public APIs expose 24h taker notional cleanly enough to compare without backfilling. Other venues join as their endpoints document the same series." - q: "Is this the same as DeFiLlama volume?" a: "Methodology is similar in spirit but each venue is queried directly here, not via an aggregator. Numbers should sit close to DeFiLlama on most days, divergences usually mean a downstream feed lagged." @@ -70,51 +84,129 @@ providers: - slug: hyperliquid name: Hyperliquid tag: HyperBFT L1 perp DEX - formula: "Sum across all assets of Hyperliquid info dayNtlVlm, rolled into a 30 day window, headline is the 24h average." + formula: "Sum across all assets of Hyperliquid info dayNtlVlm, published as the trailing 24 hour taker notional in USD; headline is the 24h time average." queries: - p50: avg_over_time(perp_venue_volume_30d_usd{venue="hyperliquid"}[24h]) - p90: quantile_over_time(0.90, perp_venue_volume_30d_usd{venue="hyperliquid"}[24h]) - p99: quantile_over_time(0.99, perp_venue_volume_30d_usd{venue="hyperliquid"}[24h]) - mean: avg_over_time(perp_venue_volume_30d_usd{venue="hyperliquid"}[24h]) + p50: avg_over_time(perp_venue_volume_24h_usd{venue="hyperliquid"}[24h]) + p90: quantile_over_time(0.90, perp_venue_volume_24h_usd{venue="hyperliquid"}[24h]) + p99: quantile_over_time(0.99, perp_venue_volume_24h_usd{venue="hyperliquid"}[24h]) + mean: avg_over_time(perp_venue_volume_24h_usd{venue="hyperliquid"}[24h]) success: avg_over_time(perp_venue_health{venue="hyperliquid"}[24h]) - sample_size: count_over_time(perp_venue_volume_30d_usd{venue="hyperliquid"}[24h]) - series: perp_venue_volume_30d_usd{venue="hyperliquid"} + sample_size: count_over_time(perp_venue_volume_24h_usd{venue="hyperliquid"}[24h]) + series: perp_venue_volume_24h_usd{venue="hyperliquid"} + + - slug: aster + name: Aster + tag: BNB Chain perps DEX, Binance-compatible API + formula: "quoteVolume summed across all listed USDT perps from Aster fapi 24h ticker, in USD; headline is the 24h time average." + queries: + p50: avg_over_time(perp_venue_volume_24h_usd{venue="aster"}[24h]) + p90: quantile_over_time(0.90, perp_venue_volume_24h_usd{venue="aster"}[24h]) + p99: quantile_over_time(0.99, perp_venue_volume_24h_usd{venue="aster"}[24h]) + mean: avg_over_time(perp_venue_volume_24h_usd{venue="aster"}[24h]) + success: avg_over_time(perp_venue_health{venue="aster"}[24h]) + sample_size: count_over_time(perp_venue_volume_24h_usd{venue="aster"}[24h]) + series: perp_venue_volume_24h_usd{venue="aster"} - slug: lighter name: Lighter tag: zk-rollup, zero taker fee - formula: "Sum of 24h volume per market from the Lighter public stats endpoint, rolled into a 30 day window, headline is the 24h average." + formula: "Sum of 24h volume per market from the Lighter public stats endpoint, in USD; headline is the 24h time average." queries: - p50: avg_over_time(perp_venue_volume_30d_usd{venue="lighter"}[24h]) - p90: quantile_over_time(0.90, perp_venue_volume_30d_usd{venue="lighter"}[24h]) - p99: quantile_over_time(0.99, perp_venue_volume_30d_usd{venue="lighter"}[24h]) - mean: avg_over_time(perp_venue_volume_30d_usd{venue="lighter"}[24h]) + p50: avg_over_time(perp_venue_volume_24h_usd{venue="lighter"}[24h]) + p90: quantile_over_time(0.90, perp_venue_volume_24h_usd{venue="lighter"}[24h]) + p99: quantile_over_time(0.99, perp_venue_volume_24h_usd{venue="lighter"}[24h]) + mean: avg_over_time(perp_venue_volume_24h_usd{venue="lighter"}[24h]) success: avg_over_time(perp_venue_health{venue="lighter"}[24h]) - sample_size: count_over_time(perp_venue_volume_30d_usd{venue="lighter"}[24h]) - series: perp_venue_volume_30d_usd{venue="lighter"} + sample_size: count_over_time(perp_venue_volume_24h_usd{venue="lighter"}[24h]) + series: perp_venue_volume_24h_usd{venue="lighter"} + + - slug: grvt + name: GRVT + tag: Hybrid CEX/DEX perps on ZK Stack + formula: "24h volume from GRVT public markets endpoint per instrument, summed in USD; headline is the 24h time average." + queries: + p50: avg_over_time(perp_venue_volume_24h_usd{venue="grvt"}[24h]) + p90: quantile_over_time(0.90, perp_venue_volume_24h_usd{venue="grvt"}[24h]) + p99: quantile_over_time(0.99, perp_venue_volume_24h_usd{venue="grvt"}[24h]) + mean: avg_over_time(perp_venue_volume_24h_usd{venue="grvt"}[24h]) + success: avg_over_time(perp_venue_health{venue="grvt"}[24h]) + sample_size: count_over_time(perp_venue_volume_24h_usd{venue="grvt"}[24h]) + series: perp_venue_volume_24h_usd{venue="grvt"} - - slug: gmx - name: GMX v2 - tag: Synthetics on Arbitrum, oracle-priced - formula: "positionVolume aggregated per day from the GMX synthetics-arbitrum subgraph, rolled into a 30 day window, headline is the 24h average." + - slug: extended + name: Extended + tag: StarkEx-based perps venue + formula: "24h volume from Extended public markets API per instrument, summed in USD; headline is the 24h time average." queries: - p50: avg_over_time(perp_venue_volume_30d_usd{venue="gmx-v2"}[24h]) - p90: quantile_over_time(0.90, perp_venue_volume_30d_usd{venue="gmx-v2"}[24h]) - p99: quantile_over_time(0.99, perp_venue_volume_30d_usd{venue="gmx-v2"}[24h]) - mean: avg_over_time(perp_venue_volume_30d_usd{venue="gmx-v2"}[24h]) - success: avg_over_time(perp_venue_health{venue="gmx-v2"}[24h]) - sample_size: count_over_time(perp_venue_volume_30d_usd{venue="gmx-v2"}[24h]) - series: perp_venue_volume_30d_usd{venue="gmx-v2"} - - - slug: gains - name: gains.trade - tag: Synthetic perps on Base - formula: "Taker notional aggregated per day from the Gains v8 onchain indexer, rolled into a 30 day window, headline is the 24h average." + p50: avg_over_time(perp_venue_volume_24h_usd{venue="extended"}[24h]) + p90: quantile_over_time(0.90, perp_venue_volume_24h_usd{venue="extended"}[24h]) + p99: quantile_over_time(0.99, perp_venue_volume_24h_usd{venue="extended"}[24h]) + mean: avg_over_time(perp_venue_volume_24h_usd{venue="extended"}[24h]) + success: avg_over_time(perp_venue_health{venue="extended"}[24h]) + sample_size: count_over_time(perp_venue_volume_24h_usd{venue="extended"}[24h]) + series: perp_venue_volume_24h_usd{venue="extended"} + + - slug: pacifica + name: Pacifica + tag: Solana-native onchain perps + formula: "24h taker notional read onchain from the Pacifica Solana program state, summed in USD; headline is the 24h time average." + queries: + p50: avg_over_time(perp_venue_volume_24h_usd{venue="pacifica"}[24h]) + p90: quantile_over_time(0.90, perp_venue_volume_24h_usd{venue="pacifica"}[24h]) + p99: quantile_over_time(0.99, perp_venue_volume_24h_usd{venue="pacifica"}[24h]) + mean: avg_over_time(perp_venue_volume_24h_usd{venue="pacifica"}[24h]) + success: avg_over_time(perp_venue_health{venue="pacifica"}[24h]) + sample_size: count_over_time(perp_venue_volume_24h_usd{venue="pacifica"}[24h]) + series: perp_venue_volume_24h_usd{venue="pacifica"} + + - slug: vertex + name: Vertex + tag: Arbitrum orderbook plus AMM hybrid + formula: "24h volume from the Vertex public indexer per product, summed across all perps in USD; headline is the 24h time average." + queries: + p50: avg_over_time(perp_venue_volume_24h_usd{venue="vertex"}[24h]) + p90: quantile_over_time(0.90, perp_venue_volume_24h_usd{venue="vertex"}[24h]) + p99: quantile_over_time(0.99, perp_venue_volume_24h_usd{venue="vertex"}[24h]) + mean: avg_over_time(perp_venue_volume_24h_usd{venue="vertex"}[24h]) + success: avg_over_time(perp_venue_health{venue="vertex"}[24h]) + sample_size: count_over_time(perp_venue_volume_24h_usd{venue="vertex"}[24h]) + series: perp_venue_volume_24h_usd{venue="vertex"} + + - slug: dydx + name: dYdX v4 + tag: Cosmos appchain orderbook perps + formula: "volume24H summed across markets from the dYdX v4 indexer perpetualMarkets endpoint, in USD; headline is the 24h time average." + queries: + p50: avg_over_time(perp_venue_volume_24h_usd{venue="dydx"}[24h]) + p90: quantile_over_time(0.90, perp_venue_volume_24h_usd{venue="dydx"}[24h]) + p99: quantile_over_time(0.99, perp_venue_volume_24h_usd{venue="dydx"}[24h]) + mean: avg_over_time(perp_venue_volume_24h_usd{venue="dydx"}[24h]) + success: avg_over_time(perp_venue_health{venue="dydx"}[24h]) + sample_size: count_over_time(perp_venue_volume_24h_usd{venue="dydx"}[24h]) + series: perp_venue_volume_24h_usd{venue="dydx"} + + - slug: aevo + name: Aevo + tag: Optimism L2 orderbook perps, options-focused history + formula: "24h volume from the Aevo public markets endpoint per instrument, summed in USD; headline is the 24h time average." + queries: + p50: avg_over_time(perp_venue_volume_24h_usd{venue="aevo"}[24h]) + p90: quantile_over_time(0.90, perp_venue_volume_24h_usd{venue="aevo"}[24h]) + p99: quantile_over_time(0.99, perp_venue_volume_24h_usd{venue="aevo"}[24h]) + mean: avg_over_time(perp_venue_volume_24h_usd{venue="aevo"}[24h]) + success: avg_over_time(perp_venue_health{venue="aevo"}[24h]) + sample_size: count_over_time(perp_venue_volume_24h_usd{venue="aevo"}[24h]) + series: perp_venue_volume_24h_usd{venue="aevo"} + + - slug: paradex + name: Paradex + tag: Starknet L2 perps + formula: "24h volume from the Paradex markets summary endpoint on Starknet L2, summed across perps in USD; headline is the 24h time average." queries: - p50: avg_over_time(perp_venue_volume_30d_usd{venue="gains"}[24h]) - p90: quantile_over_time(0.90, perp_venue_volume_30d_usd{venue="gains"}[24h]) - p99: quantile_over_time(0.99, perp_venue_volume_30d_usd{venue="gains"}[24h]) - mean: avg_over_time(perp_venue_volume_30d_usd{venue="gains"}[24h]) - success: avg_over_time(perp_venue_health{venue="gains"}[24h]) - sample_size: count_over_time(perp_venue_volume_30d_usd{venue="gains"}[24h]) - series: perp_venue_volume_30d_usd{venue="gains"} + p50: avg_over_time(perp_venue_volume_24h_usd{venue="paradex"}[24h]) + p90: quantile_over_time(0.90, perp_venue_volume_24h_usd{venue="paradex"}[24h]) + p99: quantile_over_time(0.99, perp_venue_volume_24h_usd{venue="paradex"}[24h]) + mean: avg_over_time(perp_venue_volume_24h_usd{venue="paradex"}[24h]) + success: avg_over_time(perp_venue_health{venue="paradex"}[24h]) + sample_size: count_over_time(perp_venue_volume_24h_usd{venue="paradex"}[24h]) + series: perp_venue_volume_24h_usd{venue="paradex"} diff --git a/benchmarks/pm-api-latency.yml b/benchmarks/pm-api-latency.yml index 55f3edee2..394efef7a 100644 --- a/benchmarks/pm-api-latency.yml +++ b/benchmarks/pm-api-latency.yml @@ -3,8 +3,8 @@ slug: pm-api-latency number: "038" title: Best prediction market API, ranked by live latency and uptime -seo_title: "Best prediction market API 2026: Polymarket, Kalshi, Limitless, Manifold latency and uptime, live" -seo_description: "Is the Polymarket API up right now? Live uptime and price endpoint latency for the Polymarket, Kalshi, Limitless, Manifold and Myriad APIs, probed every 5 seconds from three regions, with per venue status pages and outage history." +seo_title: "Best prediction market API 2026" +seo_description: "Is the Polymarket API up? Live uptime and latency for Polymarket, Kalshi, Limitless, Manifold, Myriad price endpoints." subtitle: Warm price endpoint latency of five prediction market venue APIs from three regions, plus a live uptime panel that answers whether each API is up right now. category: Trading @@ -87,22 +87,22 @@ faq: per_chain_explainer: - slug: polymarket - h2: Polymarket API latency and status + h2: Polymarket API latency body: "Polymarket's CLOB answers its midpoint endpoint at {{p50:polymarket}} p50 over warm connections (24h). The API fronts Cloudflare, so brief origin trouble often appears here as a latency spike before it ever becomes an error, which makes the latency series a useful early status signal. The uptime panel above is a live Polymarket API status check, computed from probes fired every 5 seconds from three regions, so it reacts within seconds of a real outage instead of waiting for user reports. For how the CLOB behaves under bursts, see the pm-rate-limits bench." - slug: kalshi - h2: Kalshi API latency and status + h2: Kalshi API latency body: "Kalshi's public single market endpoint goes to origin on every request and currently answers at {{p50:kalshi}} p50 warm (24h). That makes its uptime figure here a clean origin measurement, unlike its CDN cached market list. Kalshi is the regulated US venue and the only one in the cohort with a documented API contract, so an unexplained dip in its health gauge is more notable than elsewhere. If Kalshi's API seems down for you, compare the per region figures: a single region dip usually means a network issue, not a venue outage." - slug: limitless - h2: Limitless API latency and status + h2: Limitless API latency body: "Limitless answers its market endpoint at {{p50:limitless}} p50 warm (24h). One measured behaviour matters for anyone checking whether the Limitless API is down: its CDN caches error responses for four hours, so a request for an expired or resolved market keeps returning the same 400 long after the fact. A client seeing persistent errors on one market while this page shows healthy uptime is almost certainly holding a stale market reference, not witnessing an outage. Our harness re pins immediately and excludes those samples." - slug: manifold - h2: Manifold API latency and status + h2: Manifold API latency body: "Manifold's API sits entirely behind a CDN cache (max-age=5 plus stale-while-revalidate=10), which complicates both latency and status measurement: a cached 200 can mask a struggling origin for a few seconds. Our probes space to 7 seconds, label every residual cache hit, and the {{p50:manifold}} p50 shown here counts origin responses only. For uptime this cuts the other way, the cache absorbs brief origin blips, so Manifold's measured availability is high and its real failure mode is stale data rather than errors." - slug: myriad - h2: Myriad API latency and status + h2: Myriad API latency body: "Myriad's API is a single region Heroku deployment in US East with no order book endpoint and no WebSocket. Its cohort worst {{p50:myriad}} p50 (24h, all regions) is mostly geography: from eu-west and Singapore every request crosses an ocean first. When checking whether the Myriad API is down, look at the us-east region figure, since a global latency rise that spares us-east is network weather, not a venue incident. Myriad also runs the tightest keyless request budget in the cohort, covered in the pm-rate-limits bench." -source: https://github.com/MobulaFi/mobula-monorepo/tree/main/miniapps/pm-rate-limits +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/pm-rate-limits prometheus: window: 24h diff --git a/benchmarks/pm-data-freshness.yml b/benchmarks/pm-data-freshness.yml index 45338cd3f..ffbb76029 100644 --- a/benchmarks/pm-data-freshness.yml +++ b/benchmarks/pm-data-freshness.yml @@ -3,7 +3,7 @@ slug: pm-data-freshness number: "032" title: Fastest prediction market data API, live freshness across venues -seo_title: "Fastest prediction market data API 2026: Polymarket + Kalshi freshness" +seo_title: "Fastest prediction market data API 2026" seo_description: "Fastest prediction market data API ranked live across Polymarket and Kalshi. Milliseconds Mobula and Codex lag the venue gateway publish on top markets." subtitle: Per event delay between provider arrival and the venue gateway publish, measured every minute on the top markets by 24 hour volume across Polymarket and Kalshi. @@ -78,7 +78,7 @@ faq: - q: "How does OpenChainBench measure freshness?" a: "Three WebSocket clients run in parallel inside the harness, all subscribed to the same basket of top volume markets on the active venue. Every 5 minutes we refresh the basket from the venue's own markets API. For each trade event, we compute a signature based on market id, price, size and a 5 second time bucket, and record the wall clock receive time on each provider. The freshness delta is `recv_provider - recv_venue` for the same signature. We export the histogram to Prometheus, the leaderboard reads the 24h p50." -source: https://github.com/MobulaFi/mobula-monorepo/tree/main/miniapps/pm-freshness-bench +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/pm-freshness-bench prometheus: window: 24h diff --git a/benchmarks/pm-rate-limits.yml b/benchmarks/pm-rate-limits.yml index 227923d58..09cff9a4d 100644 --- a/benchmarks/pm-rate-limits.yml +++ b/benchmarks/pm-rate-limits.yml @@ -3,8 +3,8 @@ slug: pm-rate-limits number: "037" title: Prediction market API rate limits, tested with a daily ramp -seo_title: "Prediction market API rate limits 2026: Polymarket, Kalshi, Limitless, Manifold tested daily" -seo_description: "Prediction market API rate limits measured live. A daily request ramp against Polymarket, Kalshi, Limitless, Manifold and Myriad records added latency, 429 onset and queueing behaviour at each tier, plus warm and cold endpoint latency from three regions." +seo_title: "Prediction market API rate limits 2026" +seo_description: "Prediction market API rate limits measured live: daily request ramp against Polymarket, Kalshi, Limitless, Manifold, Myriad." subtitle: Warm latency on book, price and list endpoints of five prediction market venue APIs, plus a daily rate limit ramp that records added latency and throttle onset per request tier. category: Trading @@ -91,19 +91,19 @@ faq: per_chain_explainer: - slug: polymarket - h2: Polymarket API rate limits and latency + h2: Polymarket API rate limits body: "Polymarket's CLOB API documents per endpoint budgets (around 1500 requests per 10s for the book endpoint) and sits behind Cloudflare. At our ramp tiers it queues instead of returning 429, so the honest throttle signal is added latency, shown in the ramp panel. The book endpoint embeds a millisecond timestamp, making Polymarket one of only two venues here whose data age is verifiable. Warm book p50: {{p50:polymarket}}." - slug: kalshi - h2: Kalshi API rate limits and latency + h2: Kalshi API rate limits body: "Kalshi documents a token bucket per access tier, around 20 requests per second for basic read access. Its order book and single market endpoints go to origin on every request, but the market list is served from CloudFront with max-age=15, so list reads measure the edge. The WebSocket requires authentication, so Kalshi is absent from the WS panel. Our ramp stops at the first 429. Warm book p50: {{p50:kalshi}}." - slug: limitless - h2: Limitless API rate limits and latency + h2: Limitless API rate limits body: "Limitless documents no rate limits, so it gets the most conservative ramp in the cohort (10/20/40 per 10s, auto abort). Measured quirk: error responses are CDN cached for four hours, so one expired market keeps answering with the same 400 long after resolution. The harness treats those as probe_invalid and re pins rather than counting them against the venue. Warm book p50: {{p50:limitless}}." - slug: manifold - h2: Manifold API rate limits and latency + h2: Manifold API rate limits body: "Manifold documents 500 requests per minute per IP and explicitly welcomes bots on a single IP. The entire API is served behind max-age=5 plus stale-while-revalidate=10, so any client polling faster than every 6 seconds mostly reads cache; our probes space to 7 seconds and label residual cache hits. Manifold is an AMM, so the book class probes the bets feed, and lastUpdatedTime makes its data age verifiable. Warm p50: {{p50:manifold}}." - slug: myriad - h2: Myriad API rate limits and latency + h2: Myriad API rate limits body: "Myriad's keyless budget is 30 requests per 10 seconds, the tightest in the cohort, which is why it is excluded from the ramp: ramping it would measure our own quota, not the API. There is no order book endpoint and no WebSocket. The origin is a single region Heroku deployment in US East, so latency from Europe and Asia reflects geography, reported as measured. Warm price endpoint p50: {{p50:myriad}}." source: https://github.com/MobulaFi/mobula-monorepo/tree/main/miniapps/pm-rate-limits diff --git a/benchmarks/polymarket-resolution-delay.yml b/benchmarks/polymarket-resolution-delay.yml index 07ac6bad4..432c2e98a 100644 --- a/benchmarks/polymarket-resolution-delay.yml +++ b/benchmarks/polymarket-resolution-delay.yml @@ -3,8 +3,8 @@ slug: polymarket-resolution-delay number: "039" title: Polymarket resolution time, measured onchain -seo_title: "How long does Polymarket take to resolve and pay out? Live onchain data 2026" -seo_description: "Polymarket resolution time measured live from Polygon: median delay from outcome proposal to onchain resolution, by category (sports, politics, crypto), dispute rate and the pending backlog. The circulating claim that 93 percent of markets resolve within 2 hours is not supported by the data we measure." +seo_title: "Polymarket resolution delay live 2026" +seo_description: "Polymarket resolution time live from Polygon: median delay from outcome proposal to onchain payout, sports vs politics vs crypto." subtitle: Seconds from the first onchain outcome proposal to UMA resolution, measured per category from Polygon logs, with dispute rate and the live backlog of unresolved markets. category: Trading @@ -80,16 +80,16 @@ faq: per_chain_explainer: - slug: sports - h2: Polymarket sports resolution time + h2: Polymarket sports resolution body: "Sports markets dominate Polymarket's resolution volume and currently resolve at {{p50:sports}} median from first proposal. They are also where nearly all disputes happen: contested endings and stat corrections make sports the category where the oracle's verification window earns its keep. If you bet game markets, the practical wait is this row plus however long the league takes to make the result official, which happens before the onchain clock starts." - slug: politics - h2: Polymarket politics resolution time + h2: Polymarket politics resolution body: "Politics markets resolve at {{p50:politics}} median from first proposal, the slow end of the cohort. Political outcomes often need an authoritative source to publish before anyone proposes, and verification windows are conservative because these markets carry the largest open interest. The headline 'Polymarket resolves in minutes' claims circulating online are sports and crypto numbers; this row is the honest expectation for election style markets." - slug: crypto - h2: Polymarket crypto resolution time + h2: Polymarket crypto resolution body: "Crypto price markets resolve at {{p50:crypto}} median, the fastest category. The outcome is machine checkable against price feeds the moment the window closes, so proposals arrive promptly and short verification windows suffice. High frequency up or down markets recycle continuously, which is why crypto contributes a large share of total resolutions despite modest open interest per market." -source: https://github.com/MobulaFi/mobula-monorepo/tree/main/miniapps/pm-resolution-delay +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/pm-resolution-delay prometheus: window: 24h @@ -129,62 +129,62 @@ providers: tag: Every resolved market across categories formula: "Median seconds from first OO ProposePrice block to QuestionResolved block, cumulative histogram over the listener's window. The success column is the share resolved within 2 hours of proposal, the number the 93 percent claim pretends to be." queries: - p50: histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket)) - p90: histogram_quantile(0.90, sum by (le) (pmres_resolution_delay_seconds_bucket)) - p99: histogram_quantile(0.99, sum by (le) (pmres_resolution_delay_seconds_bucket)) + p50: 1000 * histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket)) + p90: 1000 * histogram_quantile(0.90, sum by (le) (pmres_resolution_delay_seconds_bucket)) + p99: 1000 * histogram_quantile(0.99, sum by (le) (pmres_resolution_delay_seconds_bucket)) mean: sum(pmres_resolution_delay_seconds_sum) / sum(pmres_resolution_delay_seconds_count) success: clamp_max(sum(pmres_resolution_delay_seconds_bucket{le="7200"}) / sum(pmres_resolution_delay_seconds_count), 1) sample_size: sum(pmres_resolutions_total) - series: histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket)) + series: 1000 * histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket)) - slug: sports name: Sports tag: Game and match markets, the volume majority formula: "Median seconds from first proposal to resolution for sports markets. Success column is the share within 2 hours of proposal." queries: - p50: histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="sports"})) - p90: histogram_quantile(0.90, sum by (le) (pmres_resolution_delay_seconds_bucket{category="sports"})) - p99: histogram_quantile(0.99, sum by (le) (pmres_resolution_delay_seconds_bucket{category="sports"})) + p50: 1000 * histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="sports"})) + p90: 1000 * histogram_quantile(0.90, sum by (le) (pmres_resolution_delay_seconds_bucket{category="sports"})) + p99: 1000 * histogram_quantile(0.99, sum by (le) (pmres_resolution_delay_seconds_bucket{category="sports"})) mean: sum(pmres_resolution_delay_seconds_sum{category="sports"}) / sum(pmres_resolution_delay_seconds_count{category="sports"}) success: clamp_max(sum(pmres_resolution_delay_seconds_bucket{le="7200",category="sports"}) / sum(pmres_resolution_delay_seconds_count{category="sports"}), 1) sample_size: sum(pmres_resolutions_total{category="sports"}) - series: histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="sports"})) + series: 1000 * histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="sports"})) - slug: crypto name: Crypto tag: Price markets, machine checkable outcomes formula: "Median seconds from first proposal to resolution for crypto price markets. Success column is the share within 2 hours of proposal." queries: - p50: histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="crypto"})) - p90: histogram_quantile(0.90, sum by (le) (pmres_resolution_delay_seconds_bucket{category="crypto"})) - p99: histogram_quantile(0.99, sum by (le) (pmres_resolution_delay_seconds_bucket{category="crypto"})) + p50: 1000 * histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="crypto"})) + p90: 1000 * histogram_quantile(0.90, sum by (le) (pmres_resolution_delay_seconds_bucket{category="crypto"})) + p99: 1000 * histogram_quantile(0.99, sum by (le) (pmres_resolution_delay_seconds_bucket{category="crypto"})) mean: sum(pmres_resolution_delay_seconds_sum{category="crypto"}) / sum(pmres_resolution_delay_seconds_count{category="crypto"}) success: clamp_max(sum(pmres_resolution_delay_seconds_bucket{le="7200",category="crypto"}) / sum(pmres_resolution_delay_seconds_count{category="crypto"}), 1) sample_size: sum(pmres_resolutions_total{category="crypto"}) - series: histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="crypto"})) + series: 1000 * histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="crypto"})) - slug: politics name: Politics tag: Election and policy markets, conservative windows formula: "Median seconds from first proposal to resolution for politics markets. Success column is the share within 2 hours of proposal." queries: - p50: histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="politics"})) - p90: histogram_quantile(0.90, sum by (le) (pmres_resolution_delay_seconds_bucket{category="politics"})) - p99: histogram_quantile(0.99, sum by (le) (pmres_resolution_delay_seconds_bucket{category="politics"})) + p50: 1000 * histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="politics"})) + p90: 1000 * histogram_quantile(0.90, sum by (le) (pmres_resolution_delay_seconds_bucket{category="politics"})) + p99: 1000 * histogram_quantile(0.99, sum by (le) (pmres_resolution_delay_seconds_bucket{category="politics"})) mean: sum(pmres_resolution_delay_seconds_sum{category="politics"}) / sum(pmres_resolution_delay_seconds_count{category="politics"}) success: clamp_max(sum(pmres_resolution_delay_seconds_bucket{le="7200",category="politics"}) / sum(pmres_resolution_delay_seconds_count{category="politics"}), 1) sample_size: sum(pmres_resolutions_total{category="politics"}) - series: histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="politics"})) + series: 1000 * histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="politics"})) - slug: other name: Other tag: Culture, weather, science and everything else formula: "Median seconds from first proposal to resolution for uncategorized markets. Success column is the share within 2 hours of proposal." queries: - p50: histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="other"})) - p90: histogram_quantile(0.90, sum by (le) (pmres_resolution_delay_seconds_bucket{category="other"})) - p99: histogram_quantile(0.99, sum by (le) (pmres_resolution_delay_seconds_bucket{category="other"})) + p50: 1000 * histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="other"})) + p90: 1000 * histogram_quantile(0.90, sum by (le) (pmres_resolution_delay_seconds_bucket{category="other"})) + p99: 1000 * histogram_quantile(0.99, sum by (le) (pmres_resolution_delay_seconds_bucket{category="other"})) mean: sum(pmres_resolution_delay_seconds_sum{category="other"}) / sum(pmres_resolution_delay_seconds_count{category="other"}) success: clamp_max(sum(pmres_resolution_delay_seconds_bucket{le="7200",category="other"}) / sum(pmres_resolution_delay_seconds_count{category="other"}), 1) sample_size: sum(pmres_resolutions_total{category="other"}) - series: histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="other"})) + series: 1000 * histogram_quantile(0.50, sum by (le) (pmres_resolution_delay_seconds_bucket{category="other"})) diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml index 1457d3d30..afbadc332 100644 --- a/benchmarks/rpc-capabilities.yml +++ b/benchmarks/rpc-capabilities.yml @@ -3,8 +3,8 @@ slug: rpc-capabilities number: "010" title: Fastest free public RPC for Ethereum, BNB, Polygon and 7 more EVM chains -seo_title: "Fastest free public RPC 2026: dRPC, PublicNode, 1RPC, Tenderly" -seo_description: "{{best_name}} leads fastest free public RPC at {{best_p50}} (eth_blockNumber p50, 24h). dRPC, PublicNode, 1RPC, Tenderly, Nodies and 10 more across 10 EVM chains." +seo_title: "Fastest free public RPC 2026" +seo_description: "Fastest free public RPC live on eth_blockNumber p50. dRPC, PublicNode, 1RPC, Tenderly, Nodies, Lava ranked." subtitle: HTTP round-trip latency for eth_blockNumber against free, no-key public RPC endpoints across 10 EVM chains, audited every 15 seconds. category: RPCs status: live diff --git a/benchmarks/solana-dex-quote-latency.yml b/benchmarks/solana-dex-quote-latency.yml new file mode 100644 index 000000000..34541ae32 --- /dev/null +++ b/benchmarks/solana-dex-quote-latency.yml @@ -0,0 +1,192 @@ +# OpenChainBench. Bench № 029 + +slug: solana-dex-quote-latency +number: "029" +title: Fastest Solana DEX quote API +seo_title: "Fastest Solana DEX quote API 2026" +seo_description: "Live ms latency benchmark for Solana DEX aggregator quote APIs: Jupiter, Mobula, OpenOcean, Raydium ranked." +subtitle: Wallclock milliseconds from quote request to usable quote response. +category: Aggregators +status: live +metric: Quote latency +unit: ms + +seo_intro: | + Solana DEX aggregators sit on the hot path of every swap a wallet, + trading UI or routing front-end builds. A quote API that takes 600 ms + pushes a perceptible delay onto the user; a quote API at 80 ms feels + instant. This benchmark measures the wall-clock latency of the + request-response round trip on each major Solana DEX aggregator's + quote endpoint, against the tokens that are *actually trending right + now* on Solana. Every 60 seconds the harness picks a fresh bonded + token from Mobula Pulse V2's live WebSocket feed (post-bonding-curve + graduates from Pump.fun, Meteora, Raydium LaunchLab and friends) and + asks each provider for a 100 USDC → tokenOut quote at 1% slippage. + Rotating against newly-trending tokens defeats every per-pair edge + cache and forces each provider to actually search a route, so the + recorded number reflects routing-search cost rather than a CDN hit. + Liquidity-gap failures (a provider that can't route the picked token + at all) land on their own counter and are excluded from the latency + histogram, so a provider that fails fast on long-tail coverage isn't + penalised on the percentiles. + +abstract: | + We measure the wallclock latency of the Solana DEX aggregator quote APIs + on freshly-trending tokens. Every 60 seconds the harness picks one token + from a sliding 30-minute window of Mobula Pulse V2 bonded events on + Solana, then sends an identical 100 USDC → tokenOut quote (1% slippage, + no fee, no referrer) to each provider from three Railway regions and + records the time from request dispatch to the first byte of a response + that contains a usable quoted output amount. + +methodology: + - "Providers measured: Jupiter, Mobula, OpenOcean, Raydium. (DFlow added when partnership API key is available.)" + - "Canonical request: 100 USDC → tokenOut at 1% slippage. The tokenOut rotates every tick (see below)." + - "Token rotation (Pulse-fed). A persistent WebSocket to Mobula Pulse V2 (bonded view, Solana) maintains a 30-min sliding pool of trending bonded tokens, typically 50-300 active mints. Each tick picks one at random. A REST snapshot fallback (`market/query` volume ranks 6-55) refreshes every 10 min for Pulse outages. In steady state every tick is sourced from the WS feed." + - "Cadence: one quote per provider per region every 60 seconds. 1,440 samples per provider per region per day." + - "Regions: us-east, eu-west, sgp." + - "Latency definition: wallclock from HTTP request dispatch to the first byte of a response body that includes the quoted output amount (Jupiter `outAmount`, Mobula `data.amountOutTokens`, OpenOcean `data.outAmount`, Raydium `data.outputAmount`). The HTTP client reuses TCP + TLS connections across ticks (warm-path), matching how a long-running backend integration actually consumes the API. The bench measures steady-state round-trip cost, not the first-request cold-start penalty." + - "Failure classes. Each non-success outcome lands on exactly one counter and is excluded from the latency histogram: `solana_quote_throttled_total` (HTTP 429), `solana_quote_auth_error_total` (401/403), `solana_quote_no_route_total` (provider can't route the picked token), `solana_quote_other_error_total` (network/timeout/parse). Each provider's no-route signal is verified: Jupiter NO_ROUTES_FOUND, Mobula 'No route found', Raydium INSUFFICIENT_LIQUIDITY, OpenOcean payload-level." + - "Raydium scope. Raydium's compute API is single-venue (Raydium AMM v4 / CPMM / CLMM only) and does not multi-hop via SOL like Jupiter or Mobula. On Pulse-fed bonded tokens (mostly Pump.fun graduates living on PumpSwap, plus Meteora and Orca pools) it returns no-route a large and variable share of the time (30% to 100% depending on the rotation set). That's an honest coverage signal and surfaces on the success-rate column, not as latency outliers." + - "Direct egress from Railway, no residential proxy. The bench measures backend-to-backend latency as a real integration would see it." + - "Jupiter serves quotes from regional pods behind CloudFront (observed `x-region: eu-central-1` from EU, `x-pod-name: jupiter-core-*`), so each probe region reaches a nearby replica and its cross-region spread is flat by infrastructure, not by caching. Repeated identical requests return `x-cache: Miss` every time; CloudFront does not cache this endpoint. The other providers answer from a single origin, which is why their per-region tabs spread with geography." + - "Histogram buckets: 10, 25, 50, 100, 200, 500, 1000, 2000, 5000 ms." + +findings: + - "{{best_name}} leads at {{best_p50}} (p50, 24 h) across {{count}} measured Solana DEX quote APIs. The headline is the cross-region median across a rotating long-tail token target; the per-region tabs below show where each provider's infrastructure actually sits." + - "{{name:jupiter}} returns quotes in {{p50:jupiter}} (p50, 24 h). The number looks impossible until you see the response headers: Jupiter runs regional pods behind CloudFront (`x-region` matches the probe region), so a Railway us-east probe reaches a us-east replica in single-digit RTT and the in-memory route engine adds a few ms. It is a real routing search on every tick (`x-cache: Miss` on repeats), served close." + - "{{name:mobula}} returns quotes in {{p50:mobula}} (p50, 24 h). Mobula's quote API runs a live multi-DEX routing search per request, no per-pair body caching, so the gap to providers that historically cached the canonical 1 SOL → USDC pair closes once the bench rotates targets." + - "{{name:openocean}} returns quotes in {{p50:openocean}} (p50, 24 h). OpenOcean's v4 Solana endpoint runs a separate multi-DEX path engine; the routing search dominates the latency curve on long-tail tokens." + - "{{name:raydium}} is a single-venue compute endpoint (Raydium pools only). It cannot multi-hop via SOL, so on the Pulse-fed rotation it no-routes nearly everything (thousands of no-routes per day against a handful of successes). Below 50 successful quotes in 24h its latency cells go blank rather than reporting a percentile computed on single-digit samples; the success-rate column is its honest score." + - "p99 is the integration-grade number. {{p99:jupiter}} / {{p99:mobula}} / {{p99:openocean}} / {{p99:raydium}} is what a swap UI feels when a region spikes or a provider's route engine churns." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-quote-latency + +prometheus: + window: 24h + freshness_metric: solana_quote_latency_ms_count + +# Region selector, tabs at the top of the page. Server-side injects +# `region="X"` into every PromQL query for the active tab. The special +# value `all` skips the filter and aggregates across every region. +# Values match the raw harness labels emitted by the MONITOR_REGION env +# var on each Railway service. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +# Exact per-region rankings for scoped badges. One query, one sample per +# (provider, region) cell. +rank_matrix_query: avg by (provider, region) (ocb:solana_quote_latency_ms:p50_24h) + +faq: + - q: "Which Solana DEX has the fastest quote API right now?" + a: "{{best_name}} currently returns quotes the fastest at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The leaderboard re-sorts every 60 seconds against fresh samples and rotates the target token each tick so no provider can serve from edge cache. The answer reflects 24 hours of measured latency across us-east, eu-west and sgp, not a marketing-page claim." + - q: "What is quote latency on a DEX aggregator?" + a: "Quote latency is the wallclock time between a swap UI asking a DEX aggregator 'what would I get if I traded 100 USDC for this token right now?' and the aggregator answering with a usable routed price. A quote API at 80 ms feels instant to the user; a quote API at 600 ms introduces a visible delay between input change and updated output. The number sets the floor on how live a swap UI can feel before any other latency (RPC, signing, broadcast) is added." + - q: "Why does the bench rotate the target token instead of always quoting SOL → USDC?" + a: "SOL → USDC is the most-cached pair on Solana: Jupiter's lite endpoint serves it from a CloudFront edge in 30-50 ms, but that measures cache hit, not routing. Rotating the target every tick, and rotating against the tokens that are *actually trending right now* via Mobula Pulse V2's bonded WebSocket feed, defeats every per-pair edge cache and forces each provider to actually run a routing search. The metric becomes a fair comparison of routing engines, not CDN configurations." + - q: "How is quote latency measured on OpenChainBench?" + a: "A harness in each of three Railway regions (us-east, eu-west, sgp) ticks every 60 seconds: it picks one Solana token from a sliding 30-minute window of bonded tokens emitted by Mobula Pulse V2 over WebSocket, then asks each provider for a 100 USDC → tokenOut quote at 1% slippage in parallel. The HTTP client reuses TCP and TLS connections across ticks, so the recorded number is the steady-state round-trip a long-lived backend integration sees, not the one-off cold-start handshake. The wallclock from request dispatch to the first byte containing a usable quoted output amount is the recorded latency. p50, p90 and p99 are computed per region via Prometheus `histogram_quantile` over 24 hours (precomputed recording rules), then averaged equal-weight across the 3 regions." + - q: "What if a provider can't quote a particular long-tail token?" + a: "It's counted as a no-route, not as a slow quote. Each provider has a recognisable 'I have no path for this pair' signal (Jupiter `NO_ROUTES_FOUND` / `TOKEN_NOT_TRADABLE`, Mobula `No route found` / `Token not found`, Raydium `INSUFFICIENT_LIQUIDITY` / `ROUTE_NOT_FOUND`, OpenOcean payload-level signal). When the harness sees it, the tick lands on `solana_quote_no_route_total` and is excluded from the latency histogram entirely, so providers that fail fast on coverage gaps aren't penalised on the percentiles. The success-rate column reflects the no-route rate honestly." + - q: "Why does Raydium have a much lower success rate than the aggregators?" + a: "Raydium's compute endpoint is single-venue: it only routes against Raydium's own AMM v4 / CPMM / CLMM pools and does not multi-hop via SOL the way Jupiter or Mobula do. Because the rotation now pulls from Mobula Pulse V2's bonded feed (most of which are Pump.fun graduates living on PumpSwap, plus Meteora and Orca pools), Raydium returns INSUFFICIENT_LIQUIDITY or ROUTE_NOT_FOUND on roughly 80% of picks. That's accurate: Raydium's API is a Raydium-pool route engine, not an aggregator. The latency column for Raydium is the conditional p50 over the small subset of bonded tokens that happen to have a Raydium pool." + - q: "Are authentication errors and rate limits counted as 'slow'?" + a: "No. HTTP 401/403/429 responses are excluded from the latency histogram entirely. They are counted in separate counters (`solana_quote_auth_error_total`, `solana_quote_throttled_total`) which show up on the success rate column. A provider that's fast but rate-limits us at the configured cadence loses points on success rate, not on latency." + +providers: + - slug: jupiter + name: Jupiter + tag: DEX aggregator (REST) + formula: "Avg of the 3 regional 24h median wallclock ms on Jupiter's `lite-api.jup.ag/swap/v1/quote` for 100 USDC → at 1% slippage. Recorded only on 200 + non-empty `outAmount`. No-route ticks excluded." + queries: + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="jupiter"}) + p90: avg(ocb:solana_quote_latency_ms:p90_24h{provider="jupiter"}) + p99: avg(ocb:solana_quote_latency_ms:p99_24h{provider="jupiter"}) + mean: sum(ocb:solana_quote_latency_ms:sum_rate_24h{provider="jupiter"}) / sum(ocb:solana_quote_latency_ms:count_rate_24h{provider="jupiter"}) + success: avg(ocb:solana_quote_success:avg_24h{provider="jupiter"}) + sample_size: sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="jupiter"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter"}[1h]))) + regions: + - region: us-east + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="jupiter", region="us-east"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter", region="us-east"}[1h]))) + - region: eu-west + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="jupiter", region="eu-west"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter", region="eu-west"}[1h]))) + - region: sgp + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="jupiter", region="sgp"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter", region="sgp"}[1h]))) + + - slug: mobula + name: Mobula + tag: DEX aggregator (REST) + formula: "Avg of the 3 regional 24h median wallclock ms on Mobula's `api.mobula.io/api/2/swap/quoting` for 100 USDC → at 1% slippage. Recorded only on 200 + non-empty `data.amountOutTokens`. No-route ticks excluded." + queries: + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="mobula"}) + p90: avg(ocb:solana_quote_latency_ms:p90_24h{provider="mobula"}) + p99: avg(ocb:solana_quote_latency_ms:p99_24h{provider="mobula"}) + mean: sum(ocb:solana_quote_latency_ms:sum_rate_24h{provider="mobula"}) / sum(ocb:solana_quote_latency_ms:count_rate_24h{provider="mobula"}) + success: avg(ocb:solana_quote_success:avg_24h{provider="mobula"}) + sample_size: sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="mobula"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula"}[1h]))) + regions: + - region: us-east + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="mobula", region="us-east"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula", region="us-east"}[1h]))) + - region: eu-west + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="mobula", region="eu-west"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula", region="eu-west"}[1h]))) + - region: sgp + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="mobula", region="sgp"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula", region="sgp"}[1h]))) + + - slug: openocean + name: OpenOcean + tag: DEX aggregator (REST) + formula: "3-region avg of 24h median wallclock ms on OpenOcean's `open-api.openocean.finance/v4/solana/quote` for 100 USDC → at 1% slippage. Recorded only when `data.outAmount > 0` and `dexId >= 0`. No-route ticks excluded." + queries: + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="openocean"}) + p90: avg(ocb:solana_quote_latency_ms:p90_24h{provider="openocean"}) + p99: avg(ocb:solana_quote_latency_ms:p99_24h{provider="openocean"}) + mean: sum(ocb:solana_quote_latency_ms:sum_rate_24h{provider="openocean"}) / sum(ocb:solana_quote_latency_ms:count_rate_24h{provider="openocean"}) + success: avg(ocb:solana_quote_success:avg_24h{provider="openocean"}) + sample_size: sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="openocean"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean"}[1h]))) + regions: + - region: us-east + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="openocean", region="us-east"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean", region="us-east"}[1h]))) + - region: eu-west + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="openocean", region="eu-west"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean", region="eu-west"}[1h]))) + - region: sgp + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="openocean", region="sgp"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean", region="sgp"}[1h]))) + + - slug: raydium + name: Raydium + tag: AMM (single-venue Trade API) + formula: "Avg of the 3 regional 24h median wallclock ms on Raydium's compute/swap-base-in for 100 USDC → , recorded only on success. Single-venue; no-route rate varies with the rotation, up to 100% (then it drops off the board)." + queries: + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="raydium"}) and on() (sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="raydium"}) > 50) + p90: avg(ocb:solana_quote_latency_ms:p90_24h{provider="raydium"}) and on() (sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="raydium"}) > 50) + p99: avg(ocb:solana_quote_latency_ms:p99_24h{provider="raydium"}) and on() (sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="raydium"}) > 50) + mean: (sum(ocb:solana_quote_latency_ms:sum_rate_24h{provider="raydium"}) / sum(ocb:solana_quote_latency_ms:count_rate_24h{provider="raydium"})) and on() (sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="raydium"}) > 50) + success: avg(ocb:solana_quote_success:avg_24h{provider="raydium"}) + sample_size: sum(ocb:solana_quote_latency_ms:count_increase_24h{provider="raydium"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium"}[1h]))) + regions: + - region: us-east + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="raydium", region="us-east"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium", region="us-east"}[1h]))) + - region: eu-west + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="raydium", region="eu-west"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium", region="eu-west"}[1h]))) + - region: sgp + p50: avg(ocb:solana_quote_latency_ms:p50_24h{provider="raydium", region="sgp"}) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium", region="sgp"}[1h]))) diff --git a/benchmarks/solana-tx-landing-latency.yml b/benchmarks/solana-tx-landing-latency.yml new file mode 100644 index 000000000..b09c765e2 --- /dev/null +++ b/benchmarks/solana-tx-landing-latency.yml @@ -0,0 +1,221 @@ +# OpenChainBench. Bench № 027 + +slug: solana-tx-landing-latency +number: "027" +title: Fastest Solana RPC for tx landing, live slot delta benchmark +seo_title: "Fastest Solana RPC 2026: slot delta" +seo_description: "Fastest Solana RPC for tx landing live: Helius, Jito, Astralane, Nozomi p50 slot delta ranked." +subtitle: How fast each landing service gets a signed mainnet tx confirmed. Slot delta = number of Solana slots between submit and confirmed (1 slot is roughly 400 ms). Active probing every hour from us-east. +category: Trading +status: live +metric: p50 slot delta to confirmed (7-day window) +unit: slots +higher_is_better: false + +disclaimer: | + Six caveats. (1) us-east only, sgp / eu-west arrive in V2. (2) One pre-registered tip per service. (3) Synthetic payload (1-lamport + memo); real swaps may land differently. (4) Helius / Astralane / Nozomi fan out to Jito internally; Jito control probe runs each cycle. (5) Confirmation = `confirmed`. (6) Slot delta is canonical; ms is derived (≈ slot_delta × 400 ms + RTT). Pair with /benchmarks/solana-tx-landing. + +seo_intro: | + This benchmark answers the only question that matters to a + Solana trader picking a landing service. how many slots does + your signed mainnet transaction take to reach the confirmed + state on chain. Every hour from a us-east probe, the harness + submits an identical signed tx through each of 5 services in + parallel, captures the submit slot before send and the land + slot from the signatureSubscribe WebSocket notification at + commitment=confirmed, and increments per-service Prometheus + histograms. Headline numbers shown are p50 and p99 slot delta + over a rolling 7-day window. Wall-clock milliseconds are + published alongside for intuition (one Solana slot is ~400 ms, + so a p50 of 1 slot is ~400 ms wall-clock plus submission RTT) + but slot delta is the canonical, sponsor-proof on-chain + measurement. + Why slot delta is the right metric. Solana confirmation is a + slot-level event. when a slot reaches supermajority vote, every + transaction in it becomes confirmed simultaneously. Wall-clock + ms conflates HTTP submission time, our RPC's polling lag, and + network RTT to the public WebSocket - all of which are + measurement artifacts unrelated to the landing service's actual + routing quality. Slot delta is what the chain itself records. + Coverage. 5 services probed in V0-Lean. Jito Block Engine (the + control / baseline because Helius, Astralane, Nozomi all + internally route some flow through it). Helius Sender in + `swqos_only=true` mode (isolates the Helius own-path from the + Jito leg). Astralane Iris (tip-refund mechanism). Nozomi by + Temporal Labs (premium tier, hard 1M lamport tip floor). + 0slot.trade (premium tier). NextBlock, bloXroute and + SolanaVibeStation arrive in the next tier (V1) once the first + sponsors land. Companion bench. /benchmarks/solana-tx-landing + measures market share via on-chain tip-wallet attribution - + who carries the flow today, regardless of speed. + +abstract: | + We probe 5 Solana transaction landing services from a single + Railway us-east region, once per hour, by submitting an + identical signed mainnet transaction to each. The payload is + the minimal valid Solana tx, compute-budget instructions + (50k CU limit, 50k micro-lamport/CU price), a 1-lamport + self-transfer, the per-service tip transfer to the service's + documented tip wallet, and an OCB-prefixed memo for forensic + traceability. All five services are submitted in parallel + goroutines within a single cycle so they sample the same chain + congestion window. The headline measurement is slot delta, + land_slot minus submit_slot, captured from the + signatureSubscribe WebSocket notification's context.slot field + at commitment=confirmed. Wall-clock ms is reported alongside + but is a derived approximation, slot_delta × ~400 ms plus + submission RTT and goroutine startup variance. A 60 s no- + confirmation deadline classifies the probe as + dropped{reason=timeout}; structured RPC errors classify as + invalid; transport failures as network_error; HTTP 419 / 429 / + "rate limit" errors classify as rate_limited (a separate label + so quota issues don't bias the bench against the throttled + service). Cost. ~$159/mo at SOL=$86, 86 % of which goes to the + four ≥1M-lamport-floor services (Nozomi, 0slot, bloXroute, + NextBlock, only two of these in V0-Lean). Sponsor SOL credits + covering a service's own probes are explicitly allowed per the + sponsor-proof framework. Limitations. (a) Single us-east + region, sgp / eu-west arrive in V2 once sponsors fund + geographic-edge story. (b) 1-hour cadence, 168 probes per + service per 7-day window, enough for stable p50 / p99 over the + publication window, not enough for intra-hour resolution + (V0.5 / V1 upgrade if needed). (c) Fan-out, Helius probed in + `swqos_only` mode only in V0-Lean to keep wire shape simple; + dual-mode arrives in v1.0.1 methodology PR. + +methodology: + - "Source endpoints (us-east Railway, base64 JSON-RPC sendTransaction unless noted). Jito `ny.mainnet.block-engine.jito.wtf/api/v1/transactions`. Helius Sender `ewr-sender.helius-rpc.com/fast?swqos_only=true` (skipPreflight + maxRetries=0). Nozomi `http://edge.nozomi.temporal.xyz/api/sendBatch?c=` (binary `[u16_BE_len][tx_bytes]`, HTTP per Temporal Labs). Astralane `ny.gateway.astralane.io/iris?api-key=` (3-elem params, mevProtect). 0slot `ny.0slot.trade?api-key=`." + - "Probe payload. 5 instructions in this exact order: SetComputeUnitLimit(50,000) + SetComputeUnitPrice(50,000 micro-lamports/CU) + SystemProgram.Transfer(payer→payer, 1 lamport) + SystemProgram.Transfer(payer→service tip wallet, floor lamports) + Memo(`ocb---`). cycle_id is an 8-byte random hex shared across the five parallel probes of one cycle, so the on-chain memos correlate." + - "Tip floors (pre-registered, methodology PR + 14-day window to change). Jito 10,000 lamports. Helius Sender 10,000. Astralane 500,000 net of refunds. Nozomi 1,000,000. 0slot 1,000,000." + - "Submission flow. One getLatestBlockhash(processed) shared across all five probes. One getSlot(processed) as submit_slot. For each service we subscribe to the signature via signatureSubscribe on the public WS BEFORE submission (otherwise a fast confirm could fire before we listen). Probes then fire in parallel goroutines, sign, POST. We block on the signatureNotification at commitment=confirmed; context.slot is land_slot; slot_delta = land_slot - submit_slot." + - "Why slot delta is canonical. Solana confirmation is slot-level. when a slot reaches supermajority, every tx in it becomes confirmed simultaneously. The WS pushes notifications for all subscribed sigs in that slot at the same instant. So sub-400 ms wallclock diffs between services in the same slot are artifacts (goroutine startup, RTT), not routing quality. slot_delta is what the chain records, what to cite in audits." + - "Wall-clock ms is a derived approximation. ms ≈ slot_delta × ~400 ms + HTTP submission RTT + variance. We publish it for intuition because traders think in seconds, not slots, but it should not be the sole metric in a sponsor pitch or audit. If a service argues 'your ms numbers are biased by your RTT', the answer is the slot delta column, which is RTT-independent." + - "Drop classification. timeout = no confirmation within 60 s. invalid = RPC error, on-chain Err, or BlockhashNotFound. network_error = transport-level (timeout, DNS, EOF, connection refused). rate_limited = HTTP 419 / 429 / 'rate limit' / 'too many requests'. landing_rate is published as success / (success + timeout), rate_limited and network_error are excluded so quota / transport issues don't bias the bench against a throttled service." + - "Jito control probe. Helius (default), Astralane, Nozomi route a portion of flow through Jito internally, conflating own-path vs Jito-caught-it. Jito is in the V0-Lean set so its control fires in the same cycle. Same slot_delta as Jito = no measurable own-path value. Suspect ahead by 1+ slot = real routing advantage." + - "Reproducibility. The full harness source is at github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing. Anyone with a funded Solana keypair (~1 SOL) can clone, set SOLANA_PROBE_KEYPAIR_BASE58, run the binary, and reproduce these metrics. The bench does not rely on any private or internal service for measurement, the only RPC dependency is the public `api.mainnet-beta.solana.com` HTTP + WebSocket endpoints." + - "Methodology v1.3 pre-registered at github.com/ChainBench/OpenChainBench/blob/main/docs/methodology/solana-tx-landing-active.md. Any change (tip floor, probe payload, cadence, region, metric definitions) ships as a public PR with a 14-day comment window. Major version bumps run a 30-day shadow period publishing old and new metrics in parallel." + +findings: + - "{{best_name}} leads the V0-Lean probe set at p50 = {{best_p50}} slot delta over the rolling 7-day window. Lower = fewer Solana slots between submission and confirmation. The gap between fastest and slowest is the operational signal, every service claims '99 %+ landing rate' in marketing copy, but the chain doesn't lie about which slot included your tx. A 1-slot difference is ~400 ms, enough for a MEV bot to front-run a competitor." + - "{{name:jito}} is the baseline / control. Helius (default mode), Astralane, and Nozomi all internally fan out to Jito, so the Jito p50 is the floor any premium service must beat. Same slot_delta as Jito on a given cycle = the service is essentially using Jito as its inclusion path. {{name:jito}} sits at p50 = {{p50:jito}} slot delta." + - "{{name:helius-sender}} in `swqos_only` mode isolates Helius's own routing path from the Jito leg. p50 = {{p50:helius-sender}} slot delta. A v1.0.1 methodology update will publish Helius default mode (with Jito fan-out) side-by-side for direct comparison." + - "{{name:nozomi}} premium pricing (1M lamport hard floor, ~10 × Jito's competitive level) only makes economic sense if the slot_delta advantage is meaningful. p50 = {{p50:nozomi}} slot delta. The gap vs Jito quantifies whether the tip premium buys real slot priority." + - "{{worst_name}} trails at p50 = {{worst_p50}} slot delta. The worst slot delta in the V0-Lean set is not necessarily a bad service, it may be a service whose strength is in dimensions this bench doesn't measure (anti-MEV protection, durable nonce, fee-refund mechanics). Latency is one variable, not the whole product." + +faq: + - q: "Why is slot delta the headline metric instead of wall-clock latency?" + a: "Solana confirmation is a slot-level event. when a slot reaches supermajority vote (~2/3 of stake), every transaction in that slot becomes confirmed simultaneously. The WebSocket pushes notifications for all subscribed signatures in that slot at the same instant. So if 3 services delivered txs that all landed in the same slot, our wallclock measurement records the same time for all 3, the only differentiation is whether the next service's tx landed in slot N or N+1. slot_delta captures that directly. Wall-clock ms is derived (slot_delta × ~400 ms + RTT + variance) and conflates routing quality with measurement artifacts like HTTP submission speed and our public RPC's network latency. We publish wall-clock ms because traders think in seconds, but slot_delta is what you should cite in an audit or methodology dispute. It's RTT-independent and reads directly from the chain." + - q: "What does '1 slot' actually mean in time?" + a: "Solana slots are ~400 ms in practice (~625 ms target with leader skips and forks averaging it down). A p50 slot_delta of 1 means your tx typically lands in the slot immediately following your submission, ~400 ms after sendTransaction return. p50 of 2 means typically one slot later, ~800 ms. The gap between p50 = 1 and p50 = 2 is the operational signal, a service that consistently lands 1 slot earlier than its competitors is ~400 ms ahead, which is the difference between catching an arbitrage and missing it." + - q: "Why an active bench when /benchmarks/solana-tx-landing already exists?" + a: "/benchmarks/solana-tx-landing is observational, it watches the chain and counts who carries the flow. It cannot answer 'how fast does my tx land if I send it now', because it doesn't send anything. This bench (active probing) answers that, at the cost of running 24 / 7 with real SOL ($159 / month at the V0-Lean cadence). The two benches answer different product questions. Read both." + - q: "Why only 5 services, not the 8 you measure observationally?" + a: "NextBlock, bloXroute Trader, and SolanaVibeStation all require paid plans or sales-call onboarding before they issue an API key. We're shipping V0-Lean today with the 5 services that have a clear self-serve or contact-based path. The other 3 will be added as the bench scales. The observational bench at /benchmarks/solana-tx-landing already covers all 8 because it doesn't need API keys." + - q: "Why us-east only?" + a: "V0-Lean. us-east is the de-facto Solana baseline (Jito, NextBlock, bloXroute, Helius all anchor their best-connected POPs there) and is where most Solana bots deploy by default. Adding eu-west and sgp triples the bench cost and answers a different question ('does the ranking change by geography?'), which is a planned V2 scope expansion." + - q: "What's the probe payload?" + a: "Five instructions in this exact order, locked by methodology §3. (1) SetComputeUnitLimit(50,000). (2) SetComputeUnitPrice(50,000 micro-lamports/CU), together a 2,500-lamport priority fee. (3) SystemProgram.Transfer of 1 lamport from the prober keypair to itself, the minimal valid state-touching tx. (4) SystemProgram.Transfer to the service's documented tip wallet at the pre-registered floor. (5) Memo program write with the cycle ID, service name, and probe mode. Total weight: ~600 bytes, well under the 1,232-byte tx limit." + - q: "How is fan-out handled?" + a: "Helius (default mode), Astralane, and Nozomi route a portion of flow through Jito internally. The Jito control probe, Jito is part of the V0-Lean probe set, fires in the same cycle as the suspect services with the same blockhash and a comparable tip. The slot_delta column tells you immediately whether a suspect service is adding value beyond a Jito wrapper. Same slot_delta as Jito = same inclusion slot = Jito caught it. Suspect ahead by 1+ slot = real own-path routing. Helius is additionally probed in `?swqos_only=true` mode to fully isolate its own routing path." + - q: "Can a service detect and prioritise our probes?" + a: "Yes, in principle. The memo prefix `ocb-` is deterministic and the keypair is constant per region. Anti-fingerprinting (memo randomisation, sub-account rotation, tip jitter within the floor band) ships in v1.0.2 methodology PR. We disclose this risk openly; the trade-off is that announcing the bench publicly to providers gives them a chance to fix real performance issues before we publish, which is a good outcome. We do NOT accept private deals to alter the probe surface for any specific service." + - q: "Why is sample_size on the dashboard ~168 per service?" + a: "V0-Lean cadence = 1 probe per service per hour from 1 region. 168 = 24 hours × 7 days. The 7-day publication window is the trade-off between statistical resolution (sample size grows with window) and freshness (shorter window reflects current chain conditions). At ~168 samples per cell, p50 is stable to within ±5 % and p99 to within ±15 %. Lower confidence intervals are unlocked at V0.5 cadence (1 / 10 min, ~$760 / mo) and above." + - q: "How is the confirmation observed?" + a: "Via `signatureSubscribe` on the public mainnet WebSocket (`wss://api.mainnet-beta.solana.com`). The subscription is registered BEFORE submission so a fast-confirming tx cannot complete before we are listening (otherwise we'd miss the notification and timeout spuriously). The RPC pushes the notification at the instant the commitment level is reached, so observation resolution is RTT-bounded (~30-50 ms us-east → mainnet-beta) and slot_delta is read directly from the notification's context.slot field. HTTP polling at 200 ms is an automatic fallback if the WebSocket fails to connect for a given cycle." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing + +prometheus: + window: 7d + expected_freshness_seconds: 7200 + +# Real metrics emitted by the active prober in solana-tx-landing harness: +# solana_landing_probe_success_total{service, mode, region} counter +# solana_landing_probe_dropped_total{service, mode, region, reason} counter +# solana_landing_probe_latency_ms{service, mode, region} gauge (set every cycle) +# solana_landing_probe_latency_slots{service, mode, region} gauge (set every cycle) +# solana_landing_probe_latency_slots_histogram{service, mode, region} histogram (debug) +# solana_landing_probe_latency_ms_histogram{service, mode, region} histogram (debug) +# solana_landing_probe_keypair_balance_sol{region} gauge +# solana_landing_probe_cycle_total{region} counter +# solana_landing_probe_enabled{region} gauge +# +# Headline metric (canonical) = slot_p50 / slot_p99 read from the gauge. +# Wall-clock ms is published alongside via the standard p50/p90/p99 fields +# for reader intuition but is derived (slot_delta × ~400 ms + RTT + variance). +# Mode label is `swqos_only` for helius-sender, `default` for the rest. +# +# Why quantile_over_time(gauge) instead of histogram_quantile(histogram)? +# At V0-Lean cadence (1 probe / hour) we have ~168 samples per cell over 7d. +# Histogram buckets {100, 250, 500, 1000, 2000, 5000, 10000, 30000, 60000} ms +# have ~3 buckets in the 1-5s zone where probes actually land, so +# histogram_quantile collapses to bucket midpoints (1500, 3500 ms) and the +# series looks flat. quantile_over_time on the gauge takes the real sample +# at the 50th percentile, which is the accurate published number. + +providers: + - slug: jito + name: Jito + tag: Baseline + control probe; atomic bundles + tip auction since 2022 + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed mainnet probes submitted to Jito's `ny.mainnet.block-engine.jito.wtf` from us-east." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="jito",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="jito",region="us-east"} + + - slug: helius-sender + name: Helius + tag: Isolated Helius own-path (no Jito fan-out); anycast + 7 POPs + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted to Helius Sender in `swqos_only=true` mode from us-east, isolating its own-path." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="helius-sender",mode="swqos_only",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"} + + - slug: astralane + name: Astralane + tag: Tip-refund mechanism, sendBundle / sendIdeal modes, FRA + NY POPs + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted with a 500k-lamport net tip to Astralane Iris's NY gateway from us-east." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="astralane",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="astralane",region="us-east"} + + - slug: nozomi + name: Nozomi + tag: Temporal Labs, direct-to-leader, premium 1M-lamport hard floor + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted with a 1M-lamport tip to Nozomi's `edge.nozomi.temporal.xyz` from us-east." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="nozomi",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="nozomi",region="us-east"} + + - slug: mobula + name: Mobula + tag: Multi-RPC fan-out aggregator (relays via Jito / Nozomi / zeroslot) + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly probes submitted via Mobula's `api.mobula.io/api/2/swap/send` multi-RPC fan-out from us-east, using a Jito tip wallet." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="mobula",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="mobula",region="us-east"} diff --git a/benchmarks/solana-tx-landing.yml b/benchmarks/solana-tx-landing.yml new file mode 100644 index 000000000..9872f99c9 --- /dev/null +++ b/benchmarks/solana-tx-landing.yml @@ -0,0 +1,218 @@ +# OpenChainBench. Bench № 016 + +slug: solana-tx-landing +number: "016" +title: Solana transaction landing services market share +seo_title: "Solana tx landing services benchmark 2026" +seo_description: "Live observational benchmark of Solana tx landing services. Market share by tx count, attributed via tip wallets." +subtitle: Share of tipped Solana transactions attributed to each landing service, counted via known on-chain tip wallets. Observational, no transactions sent. +category: Trading +status: live +metric: Tx count (24h) +unit: count +higher_is_better: true + +disclaimer: | + Three caveats. (1) Market share, NOT landing rate, measuring success rates would require sending controlled tx, which costs SOL. (2) Jito is over-counted, other services (Helius Sender, Astralane, bloXroute) fan-out tips to Jito alongside their own wallet, inflating Jito's counter. (3) High share ≠ best, Nozomi has tiny share but charges 10x higher tips for guaranteed inclusion. Pick on latency / cost / reliability fit, not leaderboard position. + +seo_intro: | + This benchmark answers the question every Solana dev choosing a + transaction landing service asks. who actually carries the flow, + what does it cost, and is it growing or shrinking. Marketing + pages quote self-reported "99% landing rate" without methodology; + this page measures the reality on-chain by observing the known + tip wallets each service publishes in its docs. Every confirmed + Solana transaction that pays a tip to one of ~72 known landing- + service wallets is attributed to that service. Zero on-chain + footprint, we don't send tx ourselves, we watch the chain. + Coverage. 8 services with cleanly attributable tip wallets. + Jito Block Engine (the OG, 8 wallets), Helius Sender (10 wallets, + disjoint from Jito's pool), Nozomi by Temporal Labs (17 wallets + with `noz` vanity prefix), bloXroute Trader API (17 `bLx` wallets), + 0slot.trade (10 wallets), NextBlock (8 wallets), Astralane Iris, + SolanaVibeStation Lightspeed. Blind spots. Syncro Sender (per- + customer tip wallets, undisclosed), Slipstream (pure router whose + tx land via underlying senders' wallets and get attributed to + those), and any direct-RPC tx that doesn't pay a tip (a large + share of total Solana traffic, but not what this bench measures). + +abstract: | + We attribute Solana transactions to their landing service by + watching the on-chain tip wallets each service documents publicly. + A single WebSocket connection to mainnet-beta subscribes via + `logsSubscribe(mentions=[tip_wallet], commitment=confirmed)` for + each of ~72 tip wallets across 8 services. Every confirmed + notification increments a per-service counter; signatures are + deduplicated against a 50k-entry LRU so reconnect replays don't + double-count. Failed transactions (Err != null) are excluded - + they didn't actually land. The headline metric is total + landed-and-tipped tx attributed to each service in the last 24 + hours, surfaced as a leaderboard sorted by volume. This is the + observational counterpart to a controlled landing-rate benchmark. + it costs nothing to run (no tx sent, no SOL spent), provides + market-share signal that the controlled approach cannot (real + user behaviour, not synthetic probes), and stays neutral + (services can't fingerprint our probes, there are no probes). + Limitations. (a) Helius Sender fans some tx to Jito under the + hood; if Helius's own tip wallet is paid, attribution is clean, + but Helius's "dual-path" fallback to Jito-only payment would be + miscounted as Jito. (b) Services rotating tip wallets without + doc updates introduce silent under-counting until the new + addresses are added to the harness. The harness logs every + unattributable tip-pattern tx so the gap surfaces in operator + metrics. + +methodology: + - "Source: `wss://api.mainnet-beta.solana.com` public WebSocket, no API key. One `logsSubscribe({mentions:[wallet]}, {commitment:confirmed})` call per known tip wallet. Server-side filtered, bandwidth scales with matched traffic, not full firehose." + - "Attribution: 72 tip wallets across 8 services. Jito (8), Helius Sender (10), Nozomi (17), bloXroute (17), 0slot (10), NextBlock (8), Astralane (1), SolanaVibeStation (1). Every signature carries to exactly one service because the wallet sets are disjoint." + - "Dedup: 50,000-entry LRU keyed by signature. Reconnects can replay a few seconds of notifications, without dedup we'd double-count attribution. Memory cost ~4 MB." + - "Failed tx filter: notifications with `err != null` are dropped. The bench measures LANDED tx, not submission attempts. Failed-tx flow is its own measurement and lives outside this bench." + - "Reconnect: WebSocket disconnects (mainnet-beta is best-effort and kicks idle / overloaded clients) trigger exponential backoff 2s → 60s cap. Every (service, subscription) is re-established on reconnect; `solana_landing_reconnects_total` surfaces stability over the day." + - "Blind spots. Syncro Sender (P2P.org) uses per-customer tip wallets not publicly documented, invisible. Slipstream is a router that routes through underlying senders, so its tx are attributed to the underlying service (Jito/Nozomi/etc.). Direct-RPC tx that pay only a priority fee (no tip to a landing service) are not measured, they constitute the majority of Solana volume but they're not the subject of this bench." + - "Excluded by design: time-to-land, success rate per service, geographic latency. Those would require sending controlled tx (active probing) which costs SOL and risks services fingerprinting our probes. This bench is purely observational." + +findings: + - "{{best_name}} currently carries {{best_p50}} of attributed landed tx over the last 24 h, across {{count}} measured services. The current leader's position reflects default integrations: most Solana wallets and trading bots ship with one landing service preconfigured, so flow concentrates on whoever won the integration race rather than on per-tx merit." + - "{{name:helius-sender}} ships {{p50:helius-sender}}. Helius Sender's strength is the dual-path fan-out (Jito + SWQoS staked connections) which gives it second-place reach without owning the volume Jito does." + - "{{name:nozomi}} carries {{p50:nozomi}}. Nozomi has tiny share by volume but historically charges 10x higher tips per tx, used by serious traders who treat the higher tip as guaranteed-inclusion insurance rather than discretionary spend." + - "{{name:bloxroute}} sits at {{p50:bloxroute}}. bloXroute Trader API serves institutional flow with multi-path BDN propagation. Volume share is smaller than its revenue share because individual tips are higher." + - "{{worst_name}} trails at {{worst_p50}}. The long tail (NextBlock, Astralane, SolanaVibeStation, 0slot) collectively carries a few percent of attributed flow. These services compete on specific edges (regional co-location, validator stake size, tip floor) rather than universal coverage." + +faq: + - q: "Why are the p50 / p90 / p99 columns identical?" + a: "This bench has no latency dimension, so the percentile slots are repurposed: every aggregate column carries the same 24h attributed-tx count, and the Success column reports the harness's chain-subscription health rather than any per-service delivery rate. The ranking signal is the count itself (market share); the columns exist because the ledger layout is shared with latency benches." + - q: "What is a Solana landing service?" + a: "Solana has no traditional mempool. Transactions go directly to the current and next slot leader. During congestion, leaders drop transactions they can't process, they're simply lost, not queued. Landing services help your tx avoid being dropped via various tricks. direct connections to leaders (Nozomi, 0slot), stake-weighted QoS pools (Helius Sender, Triton Cascade), tip-based bundle auctions (Jito), multi-path propagation (bloXroute, Astralane). This bench measures which services actually carry the on-chain flow today." + - q: "Why observational? Why not measure landing rate directly?" + a: "Sending controlled tx through each service to measure landing rate costs real SOL (every tx = base fee + tip + compute units, all paid to validators, none recoverable). At a 30 s cadence across 8 services and 3 regions, that's ~$11,700/month. The observational approach costs $0 because we don't send anything, we watch the chain. Trade-off. observational tells you 'who is used' (market share, tip economics, growth); controlled tells you 'who lands best' (per-service success rate). Different questions, both useful. This is the observational answer." + - q: "How is each tx attributed to a service?" + a: "Each landing service publishes a list of known tip wallet addresses in its docs (Jito has 8, Nozomi has 17, etc.). When a user routes a tx through a service, the tx includes a transfer to that service's tip wallet. We watch all 72 tip wallets via `logsSubscribe(mentions=[wallet])` and attribute each notification to the owning service. Wallet sets are disjoint, no overlap between services, so attribution is unambiguous. The pattern is verified against real mainnet blocks: 95-97% of tip-paying tx attribute cleanly, zero multi-service overlaps observed." + - q: "Why does Jito have such dominant share?" + a: "Three reasons. (1) First-mover. Jito launched the tip+bundle pattern in 2022 and got every major wallet (Phantom, Backpack) + every major trading bot to integrate first. (2) Lowest tip floor, Jito accepts tips as low as 1,000 lamports versus 0.001 SOL minimums on most competitors. (3) Atomic bundles, only Jito offers the 5-tx atomic group that DEX aggregators (Jupiter, Raydium) use for sandwich-proof swaps. The result is structural: Jito carries the default flow even when competitors are technically faster on specific paths." + - q: "Why does Nozomi look small here?" + a: "Nozomi's share by tx count is small but its share by tip revenue is much larger. Nozomi targets serious traders who pay 0.001+ SOL per tx as guaranteed-inclusion insurance, while Jito averages ~0.0001 SOL per tx as casual usage. Blockworks Research's 'Solana Block Building Wars' (Feb 2026) documented Nozomi at ~$1M tips on 2M swaps vs Jito's $300k on 24M swaps, a 130x premium per swap. For 'who do serious traders use', track Nozomi. For 'who handles default flow', track Jito." + - q: "What about Syncro Sender and Slipstream?" + a: "Syncro Sender (P2P.org) uses per-customer tip wallets that aren't publicly documented, every customer gets a private address. We can't enumerate them, so Syncro flow is invisible to this bench. Estimated share: small (Syncro launched in 2025 and hasn't disclosed adoption metrics). Slipstream is a router that calls the underlying senders' SDKs, its tx land via Jito/Nozomi/0slot tip wallets and are attributed to those services. So Slipstream-originated flow shows up correctly counted, just under the underlying service's name. If you specifically want to know 'what % uses Slipstream as the router', this bench can't answer that, Slipstream itself doesn't add an identifying memo." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing + +prometheus: + window: 24h + +# Real metrics emitted by the solana-tx-landing harness: +# solana_landing_tx_total{service} counter (attributed landed tx) +# solana_landing_subscription_health{service} gauge (0|1) +# solana_landing_reconnects_total counter +# solana_landing_last_slot{service} gauge +# +# Each "provider" below is one landing service. The metric is +# observed throughput (tx/24h), so `higher_is_better: true` - +# the leader is the one carrying the most flow. p50/p90/p99 +# columns are filled with the 24h increase (single scalar) for +# consistency with the OCB renderer; they're not statistical +# distributions because each bench probe is a single counter. + +providers: + - slug: jito + name: Jito + tag: Block Engine, atomic bundles + tip auction, OG since 2022 + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of Jito's 8 documented tip wallets, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="jito"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="jito"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="jito"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="jito"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="jito"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="jito"}[24h])) + series: sum(rate(solana_landing_tx_total{service="jito"}[5m])) + + - slug: helius-sender + name: Helius + tag: Dual-path fan-out (Jito + SWQoS staked connections), 7 regions + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of Helius Sender's 10 documented tip wallets, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="helius-sender"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) + series: sum(rate(solana_landing_tx_total{service="helius-sender"}[5m])) + + - slug: nozomi + name: Nozomi + tag: Temporal Labs, direct-to-leader, 9 co-located regions, premium tips + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of Nozomi's 17 `noz`-prefixed tip wallets, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="nozomi"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) + series: sum(rate(solana_landing_tx_total{service="nozomi"}[5m])) + + - slug: bloxroute + name: bloXroute + tag: Multi-path BDN propagation, institutional flow + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of bloXroute's 17 `bLx`-prefixed tip wallets, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="bloxroute"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) + series: sum(rate(solana_landing_tx_total{service="bloxroute"}[5m])) + + - slug: 0slot + name: 0slot + tag: Premium QUIC leader-direct sender + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of 0slot.trade's 10 documented tip wallets, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="0slot"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) + series: sum(rate(solana_landing_tx_total{service="0slot"}[5m])) + + - slug: nextblock + name: NextBlock + tag: SWQoS sender, TX Stream API + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of NextBlock's 8 documented tip wallets, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="nextblock"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) + series: sum(rate(solana_landing_tx_total{service="nextblock"}[5m])) + + - slug: astralane + name: Astralane + tag: Validator co-located, leader-schedule-aware routing + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to Astralane Iris's documented tip wallet, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="astralane"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) + series: sum(rate(solana_landing_tx_total{service="astralane"}[5m])) + + - slug: solanavibestation + name: SolanaVibeStation + tag: Lightspeed validator-pool tip-based sender + formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to SolanaVibeStation's documented Lightspeed tip wallet, deduped via 50k-entry LRU." + queries: + p50: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) + p90: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) + p99: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) + mean: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) + success: avg_over_time(solana_landing_subscription_health{service="solanavibestation"}[24h]) + sample_size: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) + series: sum(rate(solana_landing_tx_total{service="solanavibestation"}[5m])) diff --git a/benchmarks/stablecoin-peg-usdt-anchored.yml b/benchmarks/stablecoin-peg-usdt-anchored.yml index 758de8af7..a56f13151 100644 --- a/benchmarks/stablecoin-peg-usdt-anchored.yml +++ b/benchmarks/stablecoin-peg-usdt-anchored.yml @@ -3,7 +3,7 @@ slug: stablecoin-peg-usdt-anchored number: "015" title: Cheapest USDT stablecoin swap on Binance, live USDC, FDUSD, USDe spread -seo_title: "Cheapest stablecoin swap Binance 2026: USDC, FDUSD, USDe spread" +seo_title: "Cheapest stablecoin swap Binance 2026" seo_description: "Cheapest stablecoin swap on Binance ranked live. Basis points eaten on USDC/USDT, FDUSD/USDT, USDE/USDT pairs. p99 over 24h, audited every 5 seconds." subtitle: Basis points you eat swapping inventory between stables on Binance. Spread of each X/USDT mid-price from 1.0000 USDT, audited every 5 seconds. category: Trading diff --git a/benchmarks/stablecoin-peg.yml b/benchmarks/stablecoin-peg.yml index 9b1a0cf1d..a16ea70b9 100644 --- a/benchmarks/stablecoin-peg.yml +++ b/benchmarks/stablecoin-peg.yml @@ -3,8 +3,8 @@ slug: stablecoin-peg number: "014" title: Most stable stablecoin, live peg deviation across USDC, USDT and DAI -seo_title: "Most stable stablecoin 2026: USDC, USDT, DAI live peg deviation" -seo_description: "Most stable USD stablecoin ranked live by peg deviation. USDC, USDT, DAI via liquidity-weighted median across Binance, Kraken, Bitstamp, Curve. p99 over 24h." +seo_title: "Most stable stablecoin 2026: peg deviation" +seo_description: "Most stable USD stablecoin live by peg deviation: USDC, USDT, DAI via liquidity-weighted median across venues." subtitle: Live peg deviation in basis points across USDC, USDT and DAI, computed every minute from a liquidity-weighted median across Binance, Kraken, Bitstamp and Curve. category: Trading status: live diff --git a/benchmarks/token-deployment-cost.yml b/benchmarks/token-deployment-cost.yml index 167c1e5be..6c00a430e 100644 --- a/benchmarks/token-deployment-cost.yml +++ b/benchmarks/token-deployment-cost.yml @@ -2,33 +2,29 @@ slug: token-deployment-cost number: "034" -title: Cheapest blockchain to deploy a token, live USD cost across 21 L1 and L2 chains -seo_title: "Token deployment cost 2026: cheapest chain to launch ERC20, SPL, Cosmos denom, live USD" -seo_description: "Live USD cost to create a fungible token on 21 chains. Same canonical OZ v5 ERC20 via eth_estimateGas on every EVM chain, SPL mint plus Metaplex metadata on Solana, Move publish on Sui and Aptos, TokenFactory on Cosmos. Refreshed every 5 minutes." -subtitle: "Live USD cost to bring a fungible token into existence on each chain, using that chain's canonical method. 21 chains, 5 minute refresh, no transactions broadcast." +title: Cheapest blockchain to deploy a token, live USD cost across 7 non-EVM L1 chains +seo_title: "Token deployment cost 2026: cheapest chain" +seo_description: "Live USD cost to create a fungible token on 7 non-EVM L1 chains via each chain's canonical method (SPL, Move, TokenFactory, native asset, Stellar 2-account)." +subtitle: "Live USD cost to bring a fungible token into existence on each chain, using that chain's canonical method. 7 non-EVM L1 chains, 5 minute refresh, no transactions broadcast." seo_intro: | - This page answers a question every memecoin founder, RWA issuer and L2 marketing team asks. How much does it actually cost in dollars to create a token on each blockchain right now. We track 21 chains in parallel and refresh the number every 5 minutes. On the 13 EVM chains (Ethereum, BNB Chain, Avalanche, Polygon, Optimism, Base, Blast, Mantle, opBNB, Celo, Arbitrum, Scroll, Linea) we deploy the exact same canonical OpenZeppelin v5 ERC20 bytecode against eth_estimateGas, then multiply gas by the live gas price and the native token USD price from Mobula. OP Stack rollups also call OVM_GasPriceOracle.getL1Fee for the L1 data posting component. Non EVM chains use their canonical equivalent. Solana SPL mint plus the Metaplex metadata account so the comparison is fair against ERC20 which embeds name and symbol natively. Sui and Aptos use Move publish gas budgets. Cardano reads coins_per_utxo_size from koios and applies the Conway minUTxO formula. Stellar uses the canonical issuer plus distribution plus trustline two account flow. Cosmos TokenFactory chains (Osmosis, Injective, Neutron) read denom_creation_fee from the LCD. Every number is reproducible from source. No transactions are broadcast. + This page answers a question every memecoin founder, RWA issuer and non-EVM L1 marketing team asks. How much does it actually cost in dollars to create a token on each non-EVM chain right now. We track 7 non-EVM L1 chains in parallel and refresh the number every 5 minutes. Solana uses the SPL mint plus the Associated Token Account plus the Metaplex metadata account so the comparison is fair against ERC20 which embeds name and symbol natively. Sui and Aptos use Move publish gas budgets. Cardano reads coins_per_utxo_size from koios and applies the Conway minUTxO formula. Stellar uses the canonical issuer plus distribution plus trustline two account flow. Cosmos TokenFactory chains (Osmosis, Injective) read denom_creation_fee from the LCD. Every number is reproducible from source. No transactions are broadcast. EVM chains are excluded from this leaderboard for now while we finalise the canonical OpenZeppelin v5 ERC20 artifact bytecode used in eth_estimateGas. faq: - q: "What does this benchmark measure?" - a: "The live USD cost to create a fungible token on each of the 21 tracked chains, refreshed every 5 minutes, using each chain's canonical and most used method. ERC20 contract deployment for EVM chains, SPL mint plus ATA plus Metaplex metadata account for Solana, Move publish for Sui and Aptos, TokenFactory MsgCreateDenom for Osmosis Injective Neutron, native asset minting policy for Cardano, two account asset issuance for Stellar." + a: "The live USD cost to create a fungible token on each of the 7 tracked non-EVM L1 chains, refreshed every 5 minutes, using each chain's canonical and most used method. SPL mint plus ATA plus Metaplex metadata account for Solana, Move publish for Sui and Aptos, TokenFactory MsgCreateDenom for Osmosis and Injective, native asset minting policy for Cardano, two account asset issuance for Stellar." - q: "Which chains are tracked?" - a: "13 EVM chains (Ethereum, BNB Chain, Avalanche, Polygon, Optimism, Base, Blast, Mantle, opBNB, Celo, Arbitrum, Scroll, Linea) plus 8 non EVM chains (Solana, Sui, Aptos, Osmosis, Injective, Neutron, Cardano, Stellar). Twenty one in total." + a: "7 non-EVM L1 chains: Solana, Sui, Aptos, Osmosis, Injective, Cardano, Stellar." + - q: "Why are EVM chains not tracked here?" + a: "The harness path for EVM chains currently ships a placeholder ERC20 init bytecode to eth_estimateGas, which understates the real deploy cost of the canonical OpenZeppelin v5 ERC20 by roughly 30 to 500x depending on chain. Rather than publish misleading numbers, we removed the EVM leg until we finalise the real OZ v5 artifact and re-enable it in a follow-up." - q: "Is the comparison fair across chains?" a: "As fair as the underlying chains allow. Token creation means structurally different things in each ecosystem. We do not pretend the numbers are perfectly equivalent. Instead we publish exactly what the canonical and most used method costs in dollars on each chain, plus the formula and inputs for every value so readers can adjust. The methodology field lists the precise method per chain." - - q: "Why is Ethereum sometimes around one dollar and sometimes one hundred dollars?" - a: "Two of the three inputs are live and volatile. Gas units stay near 555 thousand for the canonical OpenZeppelin v5 ERC20 bytecode. Gas price moves with network congestion (around 0.5 gwei during calm periods in 2026, 20 to 80 gwei during normal load, over 100 gwei during peak congestion). ETH USD price moves independently. Cost equals gas times gas price times ETH price, so a 100x gas price swing dominates everything. The bench publishes the actual current cost, not a historical average. Open the chart for a fair view across the day." - - q: "Why are Osmosis and Neutron showing zero?" - a: "These TokenFactory chains have set their denom_creation_fee parameter to an empty list, meaning the only cost is the gas to execute MsgCreateDenom (roughly one tenth of a cent at current gas prices, well below our display precision). We render this as less than zero point zero zero one dollar with a tooltip explaining no protocol level creation fee. Injective by contrast charges a flat zero point one INJ for a tokenfactory denom." + - q: "Why is Osmosis showing near zero?" + a: "Osmosis TokenFactory has set its denom_creation_fee parameter to an empty list, meaning the only cost is the gas to execute MsgCreateDenom (roughly one tenth of a cent at current gas prices, well below our display precision). We render this as less than zero point zero zero one dollar with a tooltip explaining no protocol level creation fee. Injective by contrast charges a flat zero point one INJ for a tokenfactory denom." - q: "Is the Solana number really fair compared to ERC20?" - a: "Yes. Earlier versions of the benchmark only counted the bare 82 byte mint account rent, which understated the realistic cost by about 2.5 times. The current methodology also includes the 165 byte Associated Token Account for the initial holder and the 679 byte Metaplex Token Metadata account that holds name, symbol and URI. EVM ERC20 deployments embed name and symbol in the contract storage natively (which is why the EVM gas figure is over half a million), so adding the Solana metadata account is the apples to apples adjustment. A bare mint without metadata would not be discoverable in Phantom, Jupiter or any wallet UI." + a: "Yes. Earlier versions of the benchmark only counted the bare 82 byte mint account rent, which understated the realistic cost by about 2.5 times. The current methodology also includes the 165 byte Associated Token Account for the initial holder and the 679 byte Metaplex Token Metadata account that holds name, symbol and URI. EVM ERC20 deployments embed name and symbol in the contract storage natively, so adding the Solana metadata account is the apples to apples adjustment. A bare mint without metadata would not be discoverable in Phantom, Jupiter or any wallet UI." - q: "Why is Cardano always similar?" a: "The Cardano min UTxO formula is deterministic. The coins_per_utxo_size protocol parameter is set by governance and moves rarely. A standard native asset bundle (32 byte policy hash plus short asset name) sits around 70 bytes, and the on chain mint transaction fee is a few cents of ADA. The USD figure on the leaderboard moves with ADA price, not with congestion." - - q: "How does the L2 number include the L1 data fee?" - a: "Two paths depending on the rollup. Arbitrum Nitro: the chain bakes the L1 component into the returned gas via NodeInterface, so the plain eth_estimateGas figure already covers most of it (caveat: under L1 congestion the public RPC undercounts the L1 component by 10 to 50 percent and would need the explicit gasEstimateL1Component call). OP Stack rollups (Optimism, Base, Blast, Mantle, opBNB, Celo): eth_estimateGas returns L2 execution only, and we add the L1 data fee via OVM_GasPriceOracle.getL1Fee at 0x420000000000000000000000000000000000000F on the deploy calldata. Scroll and Linea embed the data cost directly in their gas accounting." - - q: "Where can I see the contract source?" - a: "The canonical OpenZeppelin v5.0.2 ERC20 source and its compiled artifact (solc 0.8.24, optimizer runs 200) live in the harness directory under contracts/Token.sol and contracts/Token.json. The same bytecode is shipped to every EVM chain, so the cross chain gas comparison is on identical contract code. Reproduce locally with forge build and verify against the embedded constant in cmd/script/evm.go." - q: "Can I cite a value from this page?" a: "Yes. Every number is a live Prometheus query against the OpenChainBench gateway. The harness source is open at the link in the source field below. Cite the value, the chain and the timestamp at the top of the page." - q: "Why publish a live cost when most readers will see a one off snapshot?" @@ -41,45 +37,40 @@ unit: usd higher_is_better: false abstract: | - Every 5 minutes the harness asks each of 21 chains what it would cost - right now to create a standard fungible token. For the 13 EVM chains - it submits the same canonical OpenZeppelin v5 ERC20 init bytecode to - eth_estimateGas, multiplies the returned gas by eth_gasPrice, adds - the OP Stack L1 data fee where applicable, and converts to USD using - the live native token price from Mobula. For Solana it reads rent - exemption for the SPL mint, the Associated Token Account and the - Metaplex metadata account. For Sui and Aptos it uses the reference - gas price multiplied by a pinned Move publish budget. For Cosmos - TokenFactory chains it reads denom_creation_fee from the LCD. For - Cardano it reads coins_per_utxo_size and applies the Conway min UTxO - formula. For Stellar it reads base_reserve_in_stroops and base_fee - and prices the canonical issuer plus distribution plus trustline two - account flow. No transaction is broadcast on any chain. The 21 numbers - are published as Prometheus gauges with chain and layer labels. + Every 5 minutes the harness asks each of 7 non-EVM L1 chains what it + would cost right now to create a standard fungible token. For Solana + it reads rent exemption for the SPL mint, the Associated Token Account + and the Metaplex metadata account. For Sui and Aptos it uses the + reference gas price multiplied by a pinned Move publish budget. For + Cosmos TokenFactory chains (Osmosis, Injective) it reads + denom_creation_fee from the LCD. For Cardano it reads + coins_per_utxo_size and applies the Conway min UTxO formula. For + Stellar it reads base_reserve_in_stroops and base_fee and prices the + canonical issuer plus distribution plus trustline two account flow. + No transaction is broadcast on any chain. The 7 numbers are published + as Prometheus gauges with chain and layer labels. EVM chains are + excluded pending a canonical OZ v5 ERC20 artifact. methodology: - - "Refresh cadence. 5 minutes. One process samples all 21 chains in parallel goroutines and emits Prometheus gauges." - - "EVM L1 (Ethereum, BNB Chain, Avalanche, Polygon). eth_estimateGas with from=0x...dEaD and data=canonical OZ v5 ERC20 init bytecode (3057 bytes, compiled with solc 0.8.24 optimizer runs 200). Multiply gas by eth_gasPrice and the native token USD price." - - "OP Stack L2 (Optimism, Base, Blast, Mantle, opBNB, Celo). Same eth_estimateGas plus a getL1Fee call to OVM_GasPriceOracle at 0x420000000000000000000000000000000000000F for the L1 data posting component." - - "Other EVM L2 (Arbitrum Nitro, Scroll, Linea). eth_estimateGas bakes the L1 component into the returned gas (Arbitrum via NodeInterface, Scroll and Linea via their gas accounting). Caveat: Arbitrum public RPC undercounts L1 under congestion; gasEstimateL1Component would tighten it." + - "Refresh cadence. 5 minutes. One process samples all 7 chains in parallel goroutines and emits Prometheus gauges." - "Solana SPL. getMinimumBalanceForRentExemption(82) for the mint + (165) for the Associated Token Account + (679) for the Metaplex Token Metadata account + 5000 lamports per signature. Multiply by SOL USD price. Without the metadata account the headline would understate by ~2.5x." - "Sui. suix_getReferenceGasPrice times 5 million MIST gas units for a canonical coin module publish (net of storage rebate). Converted to USD via SUI price." - "Aptos. /v1/estimate_gas_price.gas_estimate octas per unit times 150000 gas units for a canonical FA standard publish. Converted to USD via APT price." - - "Cosmos TokenFactory (Osmosis, Injective, Neutron). LCD //tokenfactory/v1beta1/params.denom_creation_fee. Empty array (Osmosis, Neutron) means gas only, rendered as less than 0.001 dollar. Injective charges a flat 0.1 INJ." + - "Cosmos TokenFactory (Osmosis, Injective). LCD //tokenfactory/v1beta1/params.denom_creation_fee. Empty array (Osmosis) means gas only, rendered as less than 0.001 dollar. Injective charges a flat 0.1 INJ." - "Cardano. Koios /epoch_params.coins_per_utxo_size. Apply Conway minUTxO formula = (160 byte overhead + 70 byte single asset bundle) × coins_per_utxo_size + 180000 lovelace mint tx fee. Converted to USD via ADA price." - "Stellar. Horizon /ledgers.base_reserve_in_stroops and base_fee_in_stroops on the latest ledger. Total cost = 3 base reserves (issuer account + distribution account + trustline on distribution) + 2 base fees (create_account + change_trust). Converted to USD via XLM price." - - "USD prices. api.mobula.io/api/1/market/multi-data?symbols=ETH,SOL,BNB,POL,INJ,NTRN,SUI,APT,XLM,ADA,OSMO,MNT,CELO,AVAX polled every 5 minutes." + - "USD prices. api.mobula.io/api/1/market/multi-data?symbols=SOL,SUI,APT,OSMO,INJ,ADA,XLM polled every 5 minutes." - "Failures. Any upstream error increments token_deployment_samples_total{chain,status=error} and leaves the previous gauge value in place." + - "EVM chains excluded. Ethereum, BNB Chain, Avalanche, Polygon, Arbitrum, Optimism, Base, Blast, Mantle, opBNB, Celo, Scroll and Linea were previously exposed but relied on a placeholder init bytecode that undercounted real ERC20 deploy cost by 30 to 500x. Removed until the canonical OZ v5 artifact is finalised." findings: - "{{best_name}} is the cheapest tracked chain to deploy a token at {{best_p50}} right now." - - "Ethereum sits at {{p50:ethereum}} for a canonical OZ v5 ERC20 deploy. This number swings 100x or more between calm and congested periods because gas price moves much more than ETH price." - - "OP Stack rollups (Optimism, Base, Blast, Mantle, opBNB, Celo) cluster well under one cent during calm periods. The L1 data fee component dominates the L2 execution component during ETH mainnet congestion." - - "Cosmos TokenFactory chains Osmosis and Neutron are effectively free (gas only, sub one tenth of a cent). Injective charges a flat 0.1 INJ governance fee." + - "Osmosis TokenFactory is effectively free (gas only, sub one tenth of a cent). Injective charges a flat 0.1 INJ governance fee." - "Solana headline includes the Metaplex metadata account for fair comparison against ERC20 which embeds name and symbol natively. The bare mint cost (no metadata) is roughly 2.5 times lower but produces a token invisible to wallets and DEXs." - - "Sui canonical coin publish is the most expensive non Ethereum chain on the list, around 0.3 to 0.5 USD, because Move modules are larger than ERC20 bytecode and Sui charges per byte of storage even after the rebate." + - "Sui canonical coin publish is the most expensive tracked chain on the list, around 0.3 to 0.5 USD, because Move modules are larger than ERC20 bytecode and Sui charges per byte of storage even after the rebate." + - "Cardano native asset creation is deterministic and stable in ADA terms. The USD figure moves purely with ADA price, not congestion." -source: https://github.com/MobulaFi/mobula-monorepo/tree/main/miniapps/token-deployment-cost +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/token-deployment-cost prometheus: window: 24h @@ -93,162 +84,6 @@ prometheus: # token_deployment_samples_total{chain, status} counter providers: - - slug: ethereum - name: Ethereum - layer: l1 - tag: OZ v5 ERC20 deploy via eth_estimateGas - formula: "Median USD cost over 24h: gas (~555k for canonical OZ v5 ERC20) × eth_gasPrice × ETH USD price." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="ethereum"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="ethereum"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="ethereum"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="ethereum"}[24h]) - series: token_deployment_cost_usd{chain="ethereum"} - - - slug: bnb - name: BNB Chain - layer: l1 - tag: OZ v5 ERC20 deploy via eth_estimateGas - formula: "Median USD cost over 24h: gas × eth_gasPrice × BNB USD price." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="bnb"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="bnb"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="bnb"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="bnb"}[24h]) - series: token_deployment_cost_usd{chain="bnb"} - - - slug: avalanche - name: Avalanche - layer: l1 - tag: OZ v5 ERC20 deploy via eth_estimateGas (C-Chain) - formula: "Median USD cost over 24h: gas × eth_gasPrice × AVAX USD price." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="avalanche"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="avalanche"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="avalanche"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="avalanche"}[24h]) - series: token_deployment_cost_usd{chain="avalanche"} - - - slug: polygon - name: Polygon - layer: l1 - tag: OZ v5 ERC20 deploy via eth_estimateGas - formula: "Median USD cost over 24h: gas × eth_gasPrice × POL USD price." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="polygon"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="polygon"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="polygon"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="polygon"}[24h]) - series: token_deployment_cost_usd{chain="polygon"} - - - slug: arbitrum - name: Arbitrum - layer: l2 - tag: Nitro RPC, L1 component baked in - formula: "USD cost over 24h: eth_estimateGas (L1 component included via Nitro) × eth_gasPrice × ETH USD price." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="arbitrum"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="arbitrum"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="arbitrum"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="arbitrum"}[24h]) - series: token_deployment_cost_usd{chain="arbitrum"} - - - slug: optimism - name: Optimism - layer: l2 - tag: OP Stack, L2 gas + OVM_GasPriceOracle.getL1Fee - formula: "L2 execution (eth_estimateGas × gasPrice) + L1 data fee (getL1Fee at 0x420...000F)." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="optimism"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="optimism"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="optimism"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="optimism"}[24h]) - series: token_deployment_cost_usd{chain="optimism"} - - - slug: base - name: Base - layer: l2 - tag: OP Stack, L2 gas + OVM_GasPriceOracle.getL1Fee - formula: "L2 execution + L1 data fee via OVM_GasPriceOracle." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="base"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="base"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="base"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="base"}[24h]) - series: token_deployment_cost_usd{chain="base"} - - - slug: blast - name: Blast - layer: l2 - tag: OP Stack, L2 gas + OVM_GasPriceOracle.getL1Fee - formula: "L2 execution + L1 data fee." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="blast"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="blast"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="blast"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="blast"}[24h]) - series: token_deployment_cost_usd{chain="blast"} - - - slug: mantle - name: Mantle - layer: l2 - tag: OP Stack with MNT gas asset - formula: "L2 execution + L1 data fee, priced in MNT." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="mantle"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="mantle"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="mantle"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="mantle"}[24h]) - series: token_deployment_cost_usd{chain="mantle"} - - - slug: opbnb - name: opBNB - layer: l2 - tag: OP Stack BNB rollup - formula: "L2 execution + L1 data fee, priced in BNB." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="opbnb"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="opbnb"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="opbnb"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="opbnb"}[24h]) - series: token_deployment_cost_usd{chain="opbnb"} - - - slug: celo - name: Celo - layer: l2 - tag: OP Stack L2 since Q1 2025 - formula: "L2 execution + L1 data fee." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="celo"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="celo"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="celo"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="celo"}[24h]) - series: token_deployment_cost_usd{chain="celo"} - - - slug: scroll - name: Scroll - layer: l2 - tag: zk rollup, data cost in estimateGas - formula: "eth_estimateGas including zk proof + data cost × gasPrice." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="scroll"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="scroll"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="scroll"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="scroll"}[24h]) - series: token_deployment_cost_usd{chain="scroll"} - - - slug: linea - name: Linea - layer: l2 - tag: zk rollup, data cost in estimateGas - formula: "eth_estimateGas including data cost × gasPrice." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="linea"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="linea"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="linea"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="linea"}[24h]) - series: token_deployment_cost_usd{chain="linea"} - - slug: solana name: Solana layer: l1 @@ -309,18 +144,6 @@ providers: mean: avg_over_time(token_deployment_cost_usd{chain="injective"}[24h]) series: token_deployment_cost_usd{chain="injective"} - - slug: neutron - name: Neutron - layer: l1 - tag: TokenFactory denom_creation_fee - formula: "LCD denom_creation_fee × NTRN USD price. Currently empty array, gas only." - queries: - p50: quantile_over_time(0.50, token_deployment_cost_usd{chain="neutron"}[24h]) - p90: quantile_over_time(0.90, token_deployment_cost_usd{chain="neutron"}[24h]) - p99: quantile_over_time(0.99, token_deployment_cost_usd{chain="neutron"}[24h]) - mean: avg_over_time(token_deployment_cost_usd{chain="neutron"}[24h]) - series: token_deployment_cost_usd{chain="neutron"} - - slug: cardano name: Cardano layer: l1 diff --git a/benchmarks/validator-yield.yml b/benchmarks/validator-yield.yml index 192b077e5..61638a066 100644 --- a/benchmarks/validator-yield.yml +++ b/benchmarks/validator-yield.yml @@ -3,7 +3,7 @@ slug: validator-yield number: "026" title: Highest staking yield, live validator net APR across Solana and Hyperliquid -seo_title: "Highest staking yield 2026: Solana vs Hyperliquid validator APR" +seo_title: "Highest staking yield 2026: SOL vs HYPE" seo_description: "Highest staking yield ranked live by net validator APR. Solana top 200 (Stakewiz total APY, MEV in) vs Hyperliquid active set. Uptime + commission + stake." subtitle: Median validator net yield (APR multiplied by uptime) in basis points, compared across chains. Solana top 200 by stake, all active Hyperliquid validators. category: Blockchains diff --git a/benchmarks/wallet-labels-coverage.yml b/benchmarks/wallet-labels-coverage.yml index cfef99072..cf972711f 100644 --- a/benchmarks/wallet-labels-coverage.yml +++ b/benchmarks/wallet-labels-coverage.yml @@ -3,8 +3,8 @@ slug: wallet-labels-coverage number: "008" title: Best wallet labeling API provider -seo_title: "Best wallet labeling API in 2026: Blockscout, Helius, Mobula, Moralis, TonAPI live coverage audit" -seo_description: "Live wallet labeling API leaderboard split by address kind. EOA tab measures curated entity coverage on plain wallets (CEX hot wallets, OFAC SDN, public figures); Contract tab measures smart-contract name resolution. Audited every 30 minutes across 11 chains against ~180 anchors." +seo_title: "Best wallet labeling API 2026" +seo_description: "Live wallet labeling API leaderboard by address kind: EOA + contract entity coverage across chains." subtitle: Share of well-known addresses each provider correctly labels with an entity (CEX, DEX, multisig, sanctioned, etc.), split by EOA vs contract. category: Aggregators status: live diff --git a/harnesses/gas-estimation/cmd/script/config.go b/harnesses/gas-estimation/cmd/script/config.go index 14013bdf4..3c79e75da 100644 --- a/harnesses/gas-estimation/cmd/script/config.go +++ b/harnesses/gas-estimation/cmd/script/config.go @@ -10,9 +10,9 @@ import ( // Tier is the unified percentile label every oracle's prediction is // mapped onto. We chose p25/p50/p90 because every oracle in the bench // exposes at least three buckets that roughly align with this scheme; -// finer buckets (p75/p99) exist on Owlracle and are added back as -// `tier="p75"`/`tier="p99"` when the oracle supplies them. The label -// is what the OCB site groups by, so the per-oracle +// finer buckets (p75/p99) exist on Blocknative and Owlracle and are +// added back as `tier="p75"`/`tier="p99"` when the oracle supplies +// them. The label is what the OCB site groups by, so the per-oracle // "fast/standard/slow" names never leak into the public metric. type Tier string @@ -28,24 +28,26 @@ const ( // Add an entry to src/data/provider-registry.ts on the OCB side when // onboarding a new oracle. const ( - OraclePublicNode Oracle = "publicnode-feehistory" - OracleOwlracle Oracle = "owlracle" - OracleEtherscan Oracle = "etherscan" + OracleBlocknative Oracle = "blocknative" + OraclePublicNode Oracle = "publicnode-feehistory" + OracleOwlracle Oracle = "owlracle" + OracleEtherscan Oracle = "etherscan" ) type Oracle string // Cadences pinned per oracle. Lifted directly from the verification -// agent's findings: PublicNode feeHistory is fair-use at 5 req/s; -// Owlracle free tier is 100/hour, so 60 s is the safe ceiling; -// Etherscan no-key throttles to 1/5 s, so 15 s gives a comfortable -// margin. Cadences are per-chain too: running the same oracle against -// 3 chains at 12 s = 0.25 req/s per oracle, well inside every -// free-tier budget. +// agent's findings: Blocknative tolerates 12 s no-key; PublicNode +// feeHistory is fair-use at 5 req/s; Owlracle free tier is 100/hour, +// so 60 s is the safe ceiling; Etherscan no-key throttles to 1/5 s, +// so 15 s gives a comfortable margin. Cadences are per-chain too: +// running the same oracle against 3 chains at 12 s = 0.25 req/s +// per oracle, well inside every free-tier budget. var pollIntervals = map[Oracle]time.Duration{ - OraclePublicNode: 12 * time.Second, - OracleOwlracle: 60 * time.Second, - OracleEtherscan: 15 * time.Second, + OracleBlocknative: 12 * time.Second, + OraclePublicNode: 12 * time.Second, + OracleOwlracle: 60 * time.Second, + OracleEtherscan: 15 * time.Second, } // Realized-block poll cadence per chain. Picked close to each chain's @@ -89,7 +91,7 @@ func chains() []Chain { RealizedRPC: envDefault("GAS_REALIZED_RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com"), OwlracleSlug: "eth", BlockTimeSec: 12, - // Etherscan v2 free tier covers chainid=1. All three oracles work. + // Etherscan v2 free tier covers chainid=1. All four oracles work. SupportedSet: []Oracle{OraclePublicNode, OracleOwlracle, OracleEtherscan}, }, { @@ -98,7 +100,7 @@ func chains() []Chain { RealizedRPC: envDefault("GAS_REALIZED_RPC_POLYGON", "https://polygon-bor-rpc.publicnode.com"), OwlracleSlug: "poly", BlockTimeSec: 2, - // Etherscan v2 free tier covers chainid=137 (verified). All three oracles work. + // Etherscan v2 free tier covers chainid=137 (verified). All four oracles work. SupportedSet: []Oracle{OraclePublicNode, OracleOwlracle, OracleEtherscan}, }, { @@ -108,7 +110,7 @@ func chains() []Chain { OwlracleSlug: "avax", BlockTimeSec: 2, // Etherscan v2 returns "Free API access is not supported for this chain" on chainid=43114 — paid plan required. - // Two oracles only (PublicNode feeHistory + Owlracle). + // Three oracles only (Blocknative + PublicNode feeHistory + Owlracle). SupportedSet: []Oracle{OraclePublicNode, OracleOwlracle}, }, } @@ -118,11 +120,13 @@ func chains() []Chain { // override without a rebuild. The URL field is the BASE — per-chain // rewriting happens in endpointForChain() below. type OracleEndpoint struct { - URL string + URL string + AuthHeader string } // endpointForChain builds the per-(oracle, chain) URL. // +// - Blocknative: same host, query param `?chainid=`. // - PublicNode feeHistory: per-chain RPC URL from the Chain struct. // - Owlracle: per-chain path slug from the Chain struct. // - Etherscan v2: same host, `?chainid=&module=gastracker&action=gasoracle`. @@ -135,9 +139,14 @@ func endpointForChain(o Oracle, c Chain) OracleEndpoint { strings.ToUpper(c.Slug), ) if override := envDefault(envKey, ""); override != "" { - return OracleEndpoint{URL: override} + return OracleEndpoint{URL: override, AuthHeader: oracleAuthHeader(o)} } switch o { + case OracleBlocknative: + return OracleEndpoint{ + URL: fmt.Sprintf("https://api.blocknative.com/gasprices/blockprices?chainid=%d", c.ChainID), + AuthHeader: envDefault("GAS_TOKEN_BLOCKNATIVE", ""), + } case OraclePublicNode: return OracleEndpoint{URL: c.RealizedRPC} case OracleOwlracle: @@ -152,6 +161,15 @@ func endpointForChain(o Oracle, c Chain) OracleEndpoint { return OracleEndpoint{} } +// oracleAuthHeader returns the optional Authorization header value +// for the given oracle. Currently only Blocknative supports a token. +func oracleAuthHeader(o Oracle) string { + if o == OracleBlocknative { + return envDefault("GAS_TOKEN_BLOCKNATIVE", "") + } + return "" +} + func envDefault(key, def string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v diff --git a/harnesses/gas-estimation/cmd/script/main.go b/harnesses/gas-estimation/cmd/script/main.go index d45fcec7c..0a7b69b23 100644 --- a/harnesses/gas-estimation/cmd/script/main.go +++ b/harnesses/gas-estimation/cmd/script/main.go @@ -116,7 +116,7 @@ func runOraclePoller(ctx context.Context, o Oracle, ep OracleEndpoint, interval // Stagger startup so all pollers across chains don't fire at t=0. // Jitter folds in both the oracle name AND the chain slug so the - // per-chain pollers for the same oracle hit at different offsets. + // 3 Blocknative pollers (one per chain) hit at different offsets. time.Sleep(jitterFor(string(o) + ":" + chain.Slug)) tick() for { diff --git a/harnesses/gas-estimation/cmd/script/oracles.go b/harnesses/gas-estimation/cmd/script/oracles.go index e21578d4d..a630cc58c 100644 --- a/harnesses/gas-estimation/cmd/script/oracles.go +++ b/harnesses/gas-estimation/cmd/script/oracles.go @@ -22,9 +22,10 @@ const ( ) // pollResult is what every oracle client returns. TargetBlock is the -// block these predictions apply to (oracle-specific: feeHistory -// returns the projected baseFee for "the next block", Etherscan -// reports lastBlock+1, Owlracle predicts the upcoming few blocks). +// block these predictions apply to (oracle-specific: Blocknative +// gives an explicit next-block number, feeHistory returns the +// projected baseFee for "the next block", Owlracle predicts the +// upcoming few blocks). type pollResult struct { TargetBlock uint64 Predictions []Prediction @@ -41,6 +42,8 @@ type pollResult struct { // adapter. func pollOracle(ctx context.Context, o Oracle, ep OracleEndpoint) pollResult { switch o { + case OracleBlocknative: + return pollBlocknative(ctx, ep) case OraclePublicNode: return pollFeeHistory(ctx, ep) case OracleOwlracle: @@ -52,6 +55,74 @@ func pollOracle(ctx context.Context, o Oracle, ep OracleEndpoint) pollResult { } } +// ─── Blocknative ────────────────────────────────────────────────── + +type bnEstimatedPrice struct { + Confidence int `json:"confidence"` + Price float64 `json:"price"` + MaxPriorityFeePerGas float64 `json:"maxPriorityFeePerGas"` + MaxFeePerGas float64 `json:"maxFeePerGas"` +} + +type bnBlockPrice struct { + BlockNumber uint64 `json:"blockNumber"` + BaseFeePerGas float64 `json:"baseFeePerGas"` + BlobBaseFeePerGas float64 `json:"blobBaseFeePerGas"` + EstimatedTransactions int `json:"estimatedTransactionCount"` + EstimatedPrices []bnEstimatedPrice `json:"estimatedPrices"` +} + +type bnResp struct { + System string `json:"system"` + CurrentBlock uint64 `json:"currentBlockNumber"` + MsSinceLastBlock int `json:"msSinceLastBlock"` + BlockPrices []bnBlockPrice `json:"blockPrices"` +} + +func pollBlocknative(ctx context.Context, ep OracleEndpoint) pollResult { + req, _ := http.NewRequestWithContext(ctx, "GET", ep.URL, nil) + if ep.AuthHeader != "" { + req.Header.Set("Authorization", ep.AuthHeader) + } + body, status, err := httpDo(ctx, req) + if err != nil { + return pollResult{Err: err} + } + if status != 200 { + return pollResult{Err: fmt.Errorf("http %d", status)} + } + var r bnResp + if err := json.Unmarshal(body, &r); err != nil { + return pollResult{Err: fmt.Errorf("parse: %w", err)} + } + if len(r.BlockPrices) == 0 { + return pollResult{Err: fmt.Errorf("empty blockPrices")} + } + bp := r.BlockPrices[0] + // Confidence 70/80/90/95/99 → p25/p50/p75/p90/p99 per spec. + mapping := map[int]Tier{ + 70: TierP25, + 80: TierP50, + 90: TierP75, + 95: TierP90, + 99: TierP99, + } + out := pollResult{TargetBlock: bp.BlockNumber, BaseGwei: bp.BaseFeePerGas} + for _, e := range bp.EstimatedPrices { + tier, ok := mapping[e.Confidence] + if !ok { + continue + } + out.Predictions = append(out.Predictions, Prediction{ + Oracle: OracleBlocknative, + Tier: tier, + PriorityGwei: e.MaxPriorityFeePerGas, + BaseGwei: bp.BaseFeePerGas, + }) + } + return out +} + // ─── eth_feeHistory (PublicNode, Alchemy share this shape) ──────── type fhResult struct { diff --git a/harnesses/gas-estimation/cmd/script/realized.go b/harnesses/gas-estimation/cmd/script/realized.go index 3ba5b0ebb..97b5b8b72 100644 --- a/harnesses/gas-estimation/cmd/script/realized.go +++ b/harnesses/gas-estimation/cmd/script/realized.go @@ -232,11 +232,11 @@ func processBlock(ctx context.Context, buf *Buffer, blockNum uint64, chain Chain } realized := map[Tier]float64{TierP25: p25, TierP50: p50, TierP90: p90} - // p75/p99 are emitted by Owlracle; we approximate realized p75 = - // (p50 + p90)/2 and p99 = p90 to give those tiers a comparator - // even though we don't compute them directly. Better than - // dropping the metric — but the realized side is noisy for tail - // tiers, so the bench page should footnote this. + // p75/p99 are emitted by Blocknative & Owlracle; we approximate + // realized p75 = (p50 + p90)/2 and p99 = p90 to give those + // tiers a comparator even though we don't compute them directly. + // Better than dropping the metric — but the realized side is + // noisy for tail tiers, so the bench page should footnote this. realized[TierP75] = (p50 + p90) / 2 realized[TierP99] = p90 diff --git a/harnesses/hyperliquid-frontends/cmd/script/main.go b/harnesses/hyperliquid-frontends/cmd/script/main.go index ae61629f9..13b24509f 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/main.go +++ b/harnesses/hyperliquid-frontends/cmd/script/main.go @@ -1,601 +1,147 @@ package main +// Hyperliquid frontends quality benchmark. +// +// Pulls the per-builder, per-day fills CSV that the Hyperliquid team +// publishes at https://stats-data.hyperliquid.xyz/Mainnet/builder_fills/ +// then computes three quality metrics per builder: +// +// - effective_fee_bps = sum(builder_fee) / sum(notional) * 10000 +// - fees_per_user_usd = sum(builder_fee) / count(distinct user) +// - volume_usd_24h = sum(px * sz) +// +// 30-day fee discipline is NOT computed here — it falls out for free +// via `stddev_over_time(hl_frontend_effective_fee_bps[30d])` in +// Prometheus once the time-series accumulates. +// +// MVP shape — wire it up, point at the public bucket, expose Prom on +// :2112. Builder address registry lives at ../builders.json so a new +// builder is added without code change. + import ( "context" - "encoding/json" - "flag" "fmt" - "log" - "net/http" "os" "os/signal" - "path/filepath" - "sort" - "strconv" - "strings" - "sync" "syscall" "time" - - "github.com/prometheus/client_golang/prometheus/promhttp" ) -// Local HL frontends bench harness (v2). -// -// Reads /mnt/hyperliquid/data/node_fills_by_block/hourly/YYYYMMDD/HH files -// produced by the hl-node running on the same host. Aggregates fills per -// known builder over a rolling 24h window and exposes Prometheus metrics on -// :2113/metrics (loopback). A Caddy reverse proxy fronts :8088 with basic -// auth for external scrape from the OCB Prom on Railway. - -type Builder struct { - Slug string `json:"slug"` - Name string `json:"name"` - Address string `json:"address"` - Addresses []string `json:"addresses,omitempty"` - ValidFrom string `json:"valid_from"` - Notes string `json:"notes"` -} - -// allAddresses returns every builder address attributed to this entry, lowercased. -// Some frontends (Okto and friends) route through multiple builder addresses; -// the registry can list them under `addresses` while `address` stays as the -// primary for human readability. We always include `address` as the canonical -// entry and append any extras from `addresses` without duplicates. -func (b Builder) allAddresses() []string { - seen := make(map[string]struct{}, 1+len(b.Addresses)) - out := make([]string, 0, 1+len(b.Addresses)) - add := func(s string) { - s = strings.ToLower(strings.TrimSpace(s)) - if s == "" { - return - } - if _, ok := seen[s]; ok { - return - } - seen[s] = struct{}{} - out = append(out, s) - } - add(b.Address) - for _, a := range b.Addresses { - add(a) - } - return out -} +const ( + scrapeInterval = 3600 * time.Second // poll each builder hourly + httpTimeout = 30 * time.Second +) func main() { installLogCapture() // capture stdout into /logs ring buffer - var ( - dataDir = flag.String("data", "/mnt/hyperliquid/data/node_fills_by_block/hourly", "HL node_fills_by_block hourly root") - buildersF = flag.String("builders", "builders.json", "builders registry") - metricsAddr = flag.String("addr", "127.0.0.1:2113", "metrics listen addr") - windowH = flag.Int("window-hours", 24, "rolling window length in hours") - tickEvery = flag.Duration("tick", 30*time.Second, "aggregate refresh interval") - usersBackfD = flag.Int("users-backfill-days", 8, "days of fill history to scan once at startup to seed the 7d/30d unique-user sets") - ) - flag.Parse() + fmt.Println("=== Hyperliquid Frontends Quality Harness ===") + fmt.Println("OpenChainBench - builder-code effective fee + $/user + volume.") + fmt.Println() - builders, err := loadBuilders(*buildersF) + registry, err := loadRegistry("./builders.json") if err != nil { - log.Fatalf("load builders: %v", err) + fmt.Printf("[fatal] load registry: %v\n", err) + os.Exit(1) } - log.Printf("loaded %d builders", len(builders)) - - registerMetrics(builders) - - state := newAggState(builders, time.Duration(*windowH)*time.Hour) - // Hour floor of the oldest file warmup will read. Backfill seeds - // additive HIP-3 sums only for strictly older files (see AggState). - state.backfillCutoffSec = time.Now().UTC(). - Add(-time.Duration(*windowH+2) * time.Hour). - Truncate(time.Hour).Unix() - - if err := state.warmup(*dataDir); err != nil { - log.Printf("warmup: %v (continuing — values will fill over time)", err) + fmt.Printf("Registry: %d builders\n", len(registry)) + for _, b := range registry { + fmt.Printf(" · %-15s %s\n", b.Slug, b.Address) } - state.publish() - - go state.backfillUsers(*dataDir, *usersBackfD) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go runFundingLoop(ctx, time.Minute) - go func() { - t := time.NewTicker(*tickEvery) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - if err := state.tickRefresh(*dataDir); err != nil { - log.Printf("tickRefresh: %v", err) - } - state.publish() - } - } - }() - - mux := http.NewServeMux() - mux.Handle("/metrics", promhttp.Handler()) - mux.Handle("/logs", logsHandler()) - mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "ok") - }) - srv := &http.Server{Addr: *metricsAddr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} - - go func() { - log.Printf("metrics listening on %s", *metricsAddr) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("http: %v", err) - } - }() - - stop := make(chan os.Signal, 1) - signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) - <-stop - log.Printf("shutting down") - shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer shutCancel() - _ = srv.Shutdown(shutCtx) -} + fmt.Println() -func loadBuilders(p string) ([]Builder, error) { - b, err := os.ReadFile(p) + state, err := OpenState() if err != nil { - return nil, err + fmt.Printf("[fatal] open state: %v\n", err) + os.Exit(1) } - var bs []Builder - if err := json.Unmarshal(b, &bs); err != nil { - return nil, err - } - for i := range bs { - bs[i].Address = strings.ToLower(bs[i].Address) - for j := range bs[i].Addresses { - bs[i].Addresses[j] = strings.ToLower(bs[i].Addresses[j]) - } - } - return bs, nil -} - -type AggState struct { - mu sync.Mutex - builders []Builder - byAddr map[string]string - window time.Duration - fillsByBuilder map[string][]fill - lastPxByCoin map[string]float64 - fileCursors map[string]int64 - lastTopAssetsByBuilder map[string][]string - // Per-builder hourly aggregates kept for 30 days. Key: floor(ts to the - // hour, UTC) in unix seconds. Memory cost: 720 buckets * ~60 builders * - // 2 floats = ~350 KB total. Used to compute 7d / 30d rolling sums for - // fees and volume without keeping every fill in memory for 30 days. - hourlyByBuilder map[string]map[int64]*hourlyBucket - // Per-builder per-UTC-day unique wallet sets, for the 7d/30d user - // counts. Day key: floor(ts to the day, UTC) in unix seconds. - // Uniqueness can't be folded into hourlyBucket floats — it needs the - // full wallet set per window. Memory: top builders run ~3-4k wallets - // per day; across the cohort this stays well under 100 MB for 30 days. - dailyUsersByBuilder map[string]map[int64]map[string]struct{} - // Set once the one-shot disk backfill has scanned the retention - // horizon. Until then the 7d/30d user gauges are not published, so a - // restart never lowballs them off the 26h warmup window alone. - usersSeeded bool - - // HIP-3 deployer aggregates. A fill belongs to a HIP-3 dex when its - // coin is namespaced ("xyz:AAPL" → dex "xyz"); the deployerFee field - // carries the dex operator's cut in USDC. Volumes here are far larger - // than the builder-code stream (trade.xyz alone clears ~4M fills/24h), - // so nothing is kept per fill: hourly buckets for sums, hourly wallet - // and market sets for the exact 24h unions (pruned past 26h), per-day - // wallet sets for the 7d/30d user counts (seeded by the backfill). - hip3Hourly map[string]map[int64]*hip3Bucket - hip3HourlyUsers map[string]map[int64]map[string]struct{} - hip3HourlyMarkets map[string]map[int64]map[string]struct{} - hip3DailyUsers map[string]map[int64]map[string]struct{} - hip3LastFillMs map[string]int64 - // Hour floor (unix sec) of the oldest file the warmup/tail path owns. - // The backfill seeds additive hip3 sums ONLY for strictly older files, - // so overlapping hours are never double counted. Wallet sets are - // idempotent and merged from both paths. - backfillCutoffSec int64 -} - -type hip3Bucket struct { - feesUSD float64 - volumeUSD float64 - fills float64 -} + defer state.Close() -type hourlyBucket struct { - feesUSD float64 - volumeUSD float64 -} - -type fill struct { - tsMs int64 - user string - coin string - notional float64 - builderFee float64 - crossed bool - devBps float64 - devValid bool -} + fmt.Println("Metrics server: :2112/metrics") + fmt.Println() -func newAggState(builders []Builder, window time.Duration) *AggState { - byAddr := make(map[string]string, len(builders)) - for _, b := range builders { - for _, addr := range b.allAddresses() { - byAddr[addr] = b.Slug - } - } - return &AggState{ - builders: builders, - byAddr: byAddr, - window: window, - fillsByBuilder: make(map[string][]fill), - lastPxByCoin: make(map[string]float64), - fileCursors: make(map[string]int64), - lastTopAssetsByBuilder: make(map[string][]string), - hourlyByBuilder: make(map[string]map[int64]*hourlyBucket), - dailyUsersByBuilder: make(map[string]map[int64]map[string]struct{}), - hip3Hourly: make(map[string]map[int64]*hip3Bucket), - hip3HourlyUsers: make(map[string]map[int64]map[string]struct{}), - hip3HourlyMarkets: make(map[string]map[int64]map[string]struct{}), - hip3DailyUsers: make(map[string]map[int64]map[string]struct{}), - hip3LastFillMs: make(map[string]int64), + // Hardcoded :2112 per the OCB Railway scrape convention. We + // deliberately ignore Railway's $PORT (would move the listener + // away from where the shared Prom expects it). METRICS_ADDR is + // a local-dev escape hatch only — never set in production. + addr := os.Getenv("METRICS_ADDR") + if addr == "" { + addr = ":2112" } -} - -func (a *AggState) publish() { - a.mu.Lock() - defer a.mu.Unlock() - - nowMs := time.Now().UnixMilli() - cutoff := nowMs - a.window.Milliseconds() - windowMin := a.window.Minutes() - - // Hourly bucket horizons (unix seconds, UTC floor) for 7d and 30d sums. - // We prune everything older than 30d on each publish — ~720 entries per - // builder, so the iteration cost is negligible. - nowSec := nowMs / 1000 - cutoff7d := nowSec - 7*24*3600 - cutoff30d := nowSec - 30*24*3600 - - for _, b := range a.builders { - fills := a.fillsByBuilder[b.Slug] - var pruned []fill - var volume, fees float64 - var devSum float64 - var devCount int - var maxTs int64 - var takerCount int - assetVol := make(map[string]float64) - users := make(map[string]struct{}) - - for _, f := range fills { - if f.tsMs < cutoff { - continue - } - pruned = append(pruned, f) - volume += f.notional - fees += f.builderFee - if f.user != "" { - users[f.user] = struct{}{} - } - if f.devValid { - devSum += f.devBps - devCount++ - } - if f.tsMs > maxTs { - maxTs = f.tsMs - } - if f.crossed { - takerCount++ - } - if f.coin != "" { - assetVol[f.coin] += f.notional - } - } - a.fillsByBuilder[b.Slug] = pruned - - hlVolumeUSD24h.WithLabelValues(b.Slug).Set(volume) - hlFeesUSD24h.WithLabelValues(b.Slug).Set(fees) - hlUsers24h.WithLabelValues(b.Slug).Set(float64(len(users))) - hlFillsTotal24h.WithLabelValues(b.Slug).Set(float64(len(pruned))) - - if volume > 0 { - hlEffectiveFeeBps.WithLabelValues(b.Slug).Set(fees / volume * 10_000) - } else { - hlEffectiveFeeBps.WithLabelValues(b.Slug).Set(0) - } - - if len(users) > 0 { - hlFeesPerUserUSD.WithLabelValues(b.Slug).Set(fees / float64(len(users))) - } else { - hlFeesPerUserUSD.WithLabelValues(b.Slug).Set(0) - } - - if maxTs > 0 { - ageSec := float64(nowMs-maxTs) / 1000.0 - hlLastFillAgeSeconds.WithLabelValues(b.Slug).Set(ageSec) - } else { - hlLastFillAgeSeconds.WithLabelValues(b.Slug).Set(a.window.Seconds()) - } - - if windowMin > 0 { - hlFillsPerMin.WithLabelValues(b.Slug).Set(float64(len(pruned)) / windowMin) - } - - if devCount > 0 { - hlPriceDeviationBps.WithLabelValues(b.Slug).Set(devSum / float64(devCount)) - } else { - hlPriceDeviationBps.WithLabelValues(b.Slug).Set(0) - } - - if len(pruned) > 0 { - hlTakerPct.WithLabelValues(b.Slug).Set(float64(takerCount) / float64(len(pruned))) - } else { - hlTakerPct.WithLabelValues(b.Slug).Set(0) + go func() { + if err := StartMetricsServer(addr); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + os.Exit(1) } + }() - a.publishTopAssets(b.Slug, assetVol) - - // 7d / 30d rolling sums from hourly buckets. Prune anything older - // than 30d while we're iterating. - buckets := a.hourlyByBuilder[b.Slug] - var fees7d, fees30d, volume7d, volume30d float64 - for tsHour, bk := range buckets { - if tsHour < cutoff30d { - delete(buckets, tsHour) - continue - } - volume30d += bk.volumeUSD - fees30d += bk.feesUSD - if tsHour >= cutoff7d { - volume7d += bk.volumeUSD - fees7d += bk.feesUSD - } - } - hlVolumeUSD7d.WithLabelValues(b.Slug).Set(volume7d) - hlVolumeUSD30d.WithLabelValues(b.Slug).Set(volume30d) - hlFeesUSD7d.WithLabelValues(b.Slug).Set(fees7d) - hlFeesUSD30d.WithLabelValues(b.Slug).Set(fees30d) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - // 7d/30d unique users from the per-UTC-day wallet sets. Gated on - // the disk backfill so a fresh process never publishes counts - // built from the 26h warmup alone. Day sets older than 31 days - // are pruned in the same pass. - if a.usersSeeded { - cutoffDay7 := nowSec - 7*24*3600 - cutoffDayPrune := nowSec - 31*24*3600 - u7 := make(map[string]struct{}) - u30 := make(map[string]struct{}) - days := a.dailyUsersByBuilder[b.Slug] - for day, set := range days { - if day < cutoffDayPrune { - delete(days, day) - continue - } - for u := range set { - u30[u] = struct{}{} - if day >= cutoffDay7 { - u7[u] = struct{}{} - } - } - } - hlUsers7d.WithLabelValues(b.Slug).Set(float64(len(u7))) - hlUsers30d.WithLabelValues(b.Slug).Set(float64(len(u30))) + // First cycle fires after a 5 s warmup so /metrics has data on the + // first scrape; subsequent cycles run on the configured interval. + warmup := time.NewTimer(5 * time.Second) + defer warmup.Stop() + ticker := time.NewTicker(scrapeInterval) + defer ticker.Stop() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + + for { + select { + case <-warmup.C: + runCycle(ctx, registry, state) + case <-ticker.C: + runCycle(ctx, registry, state) + case s := <-sig: + fmt.Printf("\n[shutdown] received %v\n", s) + cancel() + return } } - - a.publishHip3Locked(nowMs) - hlLastTickUnix.Set(float64(time.Now().Unix())) } -// publishHip3Locked folds the HIP-3 hourly buckets and wallet/market sets -// into the per-dex gauges. Caller must hold a.mu. -func (a *AggState) publishHip3Locked(nowMs int64) { - nowSec := nowMs / 1000 - cutoff24 := nowSec - 24*3600 - cutoff7d := nowSec - 7*24*3600 - cutoff30d := nowSec - 30*24*3600 - cutoffSets := nowSec - 26*3600 - - for dex, hours := range a.hip3Hourly { - var f24, v24, fl24, f7, v7, f30, v30 float64 - for h, bk := range hours { - if h < cutoff30d { - delete(hours, h) - continue - } - f30 += bk.feesUSD - v30 += bk.volumeUSD - if h >= cutoff7d { - f7 += bk.feesUSD - v7 += bk.volumeUSD - } - if h >= cutoff24 { - f24 += bk.feesUSD - v24 += bk.volumeUSD - fl24 += bk.fills - } +func runCycle(ctx context.Context, registry []Builder, state *State) { + cycleStart := time.Now() + // Per-builder pass: fetch CSVs, update gauges, upsert into state. + var totalNotional float64 + perBuilderNotional := make(map[string]float64, len(registry)) + for _, b := range registry { + select { + case <-ctx.Done(): + return + default: } - - users24 := make(map[string]struct{}) - for h, set := range a.hip3HourlyUsers[dex] { - if h < cutoffSets { - delete(a.hip3HourlyUsers[dex], h) - continue - } - if h < cutoff24 { - continue - } - for u := range set { - users24[u] = struct{}{} - } + notional := processBuilder(ctx, b, state) + perBuilderNotional[b.Slug] = notional + totalNotional += notional + } + // Volume share + retention pass once state is up to date. + for _, b := range registry { + if totalNotional > 0 { + hlVolumeSharePct.WithLabelValues(b.Slug).Set(perBuilderNotional[b.Slug] / totalNotional * 100) } - markets24 := make(map[string]struct{}) - for h, set := range a.hip3HourlyMarkets[dex] { - if h < cutoffSets { - delete(a.hip3HourlyMarkets[dex], h) + for _, days := range []int{7, 30} { + ret, cohort, err := state.Retention(ctx, b.Slug, days) + if err != nil { + fmt.Printf("[%s] retention(%dd) error: %v\n", b.Slug, days, err) continue } - if h < cutoff24 { - continue - } - for m := range set { - markets24[m] = struct{}{} + switch days { + case 7: + hlD7Retention.WithLabelValues(b.Slug).Set(ret) + hlD7CohortSize.WithLabelValues(b.Slug).Set(float64(cohort)) + case 30: + hlD30Retention.WithLabelValues(b.Slug).Set(ret) + hlD30CohortSize.WithLabelValues(b.Slug).Set(float64(cohort)) } } - - hip3FeesUSD24h.WithLabelValues(dex).Set(f24) - hip3FeesUSD7d.WithLabelValues(dex).Set(f7) - hip3FeesUSD30d.WithLabelValues(dex).Set(f30) - hip3VolumeUSD24h.WithLabelValues(dex).Set(v24) - hip3VolumeUSD7d.WithLabelValues(dex).Set(v7) - hip3VolumeUSD30d.WithLabelValues(dex).Set(v30) - hip3Fills24h.WithLabelValues(dex).Set(fl24) - hip3Users24h.WithLabelValues(dex).Set(float64(len(users24))) - hip3Markets24h.WithLabelValues(dex).Set(float64(len(markets24))) - if v24 > 0 { - hip3EffectiveFeeBps.WithLabelValues(dex).Set(f24 / v24 * 10_000) - } else { - hip3EffectiveFeeBps.WithLabelValues(dex).Set(0) - } - if last := a.hip3LastFillMs[dex]; last > 0 { - hip3LastFillAgeSeconds.WithLabelValues(dex).Set(float64(nowMs-last) / 1000.0) - } - - if a.usersSeeded { - cutoffDay7 := nowSec - 7*24*3600 - cutoffDayPrune := nowSec - 31*24*3600 - u7 := make(map[string]struct{}) - u30 := make(map[string]struct{}) - for day, set := range a.hip3DailyUsers[dex] { - if day < cutoffDayPrune { - delete(a.hip3DailyUsers[dex], day) - continue - } - for u := range set { - u30[u] = struct{}{} - if day >= cutoffDay7 { - u7[u] = struct{}{} - } - } - } - hip3Users7d.WithLabelValues(dex).Set(float64(len(u7))) - hip3Users30d.WithLabelValues(dex).Set(float64(len(u30))) - } - } -} - -// backfillUsers streams the node's hourly fill files across the retention -// horizon ONCE at startup and seeds the per-day wallet sets. Runs in a -// goroutine after warmup; the 24h metrics are live the whole time. The -// scan only JSON-parses lines that contain a builder attribution (~5% of -// fills), so a full 8-day horizon (~35 GB) takes single-digit minutes of -// sequential IO without starving the co-located node. -func (a *AggState) backfillUsers(root string, days int) { - start := time.Now() - now := time.Now().UTC() - type hourFile struct { - path string - hourTs int64 - } - var files []hourFile - for i := days * 24; i >= 0; i-- { - t := now.Add(-time.Duration(i) * time.Hour) - p := filepath.Join(root, t.Format("20060102"), fmt.Sprintf("%d", t.Hour())) - if _, err := os.Stat(p); err == nil { - files = append(files, hourFile{p, t.Truncate(time.Hour).Unix()}) - } - } - for _, f := range files { - if err := a.scanHistoryFile(f.path, f.hourTs); err != nil { - log.Printf("history backfill %s: %v", f.path, err) - } - } - a.mu.Lock() - a.usersSeeded = true - a.mu.Unlock() - log.Printf("users backfill done: %d hourly files in %s", len(files), time.Since(start)) -} - -func (a *AggState) publishTopAssets(slug string, assetVol map[string]float64) { - type kv struct { - asset string - vol float64 - } - pairs := make([]kv, 0, len(assetVol)) - for k, v := range assetVol { - if v > 0 { - pairs = append(pairs, kv{k, v}) - } - } - sort.Slice(pairs, func(i, j int) bool { return pairs[i].vol > pairs[j].vol }) - if len(pairs) > 3 { - pairs = pairs[:3] - } - - prev := a.lastTopAssetsByBuilder[slug] - newAssets := make(map[string]bool, len(pairs)) - for _, p := range pairs { - newAssets[p.asset] = true - } - for i, old := range prev { - if !newAssets[old] { - hlAssetVolumeTopUSD.DeleteLabelValues(slug, old, strconv.Itoa(i+1)) - } } - - current := make([]string, len(pairs)) - for i, p := range pairs { - hlAssetVolumeTopUSD.WithLabelValues(slug, p.asset, strconv.Itoa(i+1)).Set(p.vol) - current[i] = p.asset - } - a.lastTopAssetsByBuilder[slug] = current -} - -func (a *AggState) listHourFiles(root string) ([]string, error) { - now := time.Now().UTC() - hours := int(a.window.Hours()) + 2 - var out []string - for i := hours; i >= 0; i-- { - t := now.Add(-time.Duration(i) * time.Hour) - p := filepath.Join(root, t.Format("20060102"), fmt.Sprintf("%d", t.Hour())) - if _, err := os.Stat(p); err == nil { - out = append(out, p) - } - } - return out, nil -} - -func (a *AggState) warmup(root string) error { - files, err := a.listHourFiles(root) - if err != nil { - return err - } - for _, f := range files { - if err := a.consumeFile(f, true); err != nil { - log.Printf("warmup %s: %v", f, err) - } - } - log.Printf("warmup done; rolling window pre-loaded across %d hourly files", len(files)) - return nil -} - -func (a *AggState) tickRefresh(root string) error { - files, err := a.listHourFiles(root) - if err != nil { - return err - } - for _, f := range files { - if err := a.consumeFile(f, false); err != nil { - log.Printf("consumeFile %s: %v", f, err) - } + // Prune anything past our 90-day horizon so the SQLite file stays + // bounded. 90d > our longest retention window (D30) with 2x safety + // margin for backfill / replay scenarios. + if n, err := state.PruneOlderThan(90); err == nil && n > 0 { + fmt.Printf("[state] pruned %d rows older than 90d\n", n) } - return nil + fmt.Printf("[cycle] done in %s\n", time.Since(cycleStart).Round(time.Millisecond)) } diff --git a/harnesses/hyperliquid-frontends/cmd/script/metrics.go b/harnesses/hyperliquid-frontends/cmd/script/metrics.go index da0c1fb6d..b73efd01b 100644 --- a/harnesses/hyperliquid-frontends/cmd/script/metrics.go +++ b/harnesses/hyperliquid-frontends/cmd/script/metrics.go @@ -1,282 +1,137 @@ package main import ( + "net/http" + "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" ) -// All v2 metrics use the `builder` label name to stay consistent with the v1 -// bucket-fetch harness query patterns. The v1 service emits the same metric -// names without the _v2 suffix; both coexist in Prom during A/B. +// Prom metrics for the Hyperliquid frontends quality bench. Names are +// prefixed `hl_frontend_` so the OCB MCP allowlist needs one entry +// (`hl_frontend_`) for every metric this harness emits. var ( - hlVolumeUSD24h = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_volume_usd_24h_v2", - Help: "Rolling 24h notional volume in USD per HL builder (local node source)", - }, - []string{"builder"}, - ) - - hlFeesUSD24h = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_fees_usd_24h_v2", - Help: "Rolling 24h builder-fee revenue in USD per HL builder (local node source)", - }, - []string{"builder"}, - ) - - hlVolumeUSD7d = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_volume_usd_7d_v2", - Help: "Rolling 7d notional volume in USD per HL builder (hourly bucket sum)", - }, - []string{"builder"}, - ) - - hlVolumeUSD30d = promauto.NewGaugeVec( + hlEffectiveFeeBps = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_volume_usd_30d_v2", - Help: "Rolling 30d notional volume in USD per HL builder (hourly bucket sum)", + Name: "hl_frontend_effective_fee_bps", + Help: "Volume-weighted effective builder fee in basis points over the last 24h: sum(builder_fee) / sum(notional) * 10000.", }, []string{"builder"}, ) - hlFeesUSD7d = promauto.NewGaugeVec( + hlFeesPerUserUSD = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_fees_usd_7d_v2", - Help: "Rolling 7d builder-fee revenue in USD per HL builder (hourly bucket sum)", + Name: "hl_frontend_fees_per_user_usd", + Help: "USD fees captured per unique trader over the last 24h: sum(builder_fee_usd) / count(distinct user).", }, []string{"builder"}, ) - hlFeesUSD30d = promauto.NewGaugeVec( + hlVolumeUSD24h = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_fees_usd_30d_v2", - Help: "Rolling 30d builder-fee revenue in USD per HL builder (hourly bucket sum)", + Name: "hl_frontend_volume_usd_24h", + Help: "Total notional USD volume routed via this builder in the last 24h. Secondary signal — not the headline.", }, []string{"builder"}, ) hlUsers24h = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_users_24h_v2", - Help: "Unique users that traded through this builder in the last 24h", + Name: "hl_frontend_users_24h", + Help: "Count of unique trader addresses attributed to this builder in the last 24h.", }, []string{"builder"}, ) - // 7d/30d unique users from per-UTC-day wallet sets. Deliberately NOT - // zero-initialised in registerMetrics: the series stays absent until - // the one-shot disk backfill seeds the day sets, so a fresh restart - // can't publish lowball counts that a dashboard would read as a user - // exodus. 30d is limited by node file retention (counts grow until - // 30 full days of fills exist on disk, same caveat as fees_30d). - hlUsers7d = promauto.NewGaugeVec( + hlFillsTotal = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_users_7d_v2", - Help: "Unique users that traded through this builder over the last 7 UTC days (union of daily wallet sets)", + Name: "hl_frontend_fills_total", + Help: "Count of attributed fills observed in the last 24h. Drives the leaderboard sample_size column.", }, []string{"builder"}, ) - hlUsers30d = promauto.NewGaugeVec( + hlUnattributedShare = promauto.NewGauge( prometheus.GaugeOpts{ - Name: "hl_frontend_users_30d_v2", - Help: "Unique users that traded through this builder over the last 30 UTC days (union of daily wallet sets, bounded by node file retention)", + Name: "hl_frontend_unattributed_share_pct", + Help: "Share of Hyperliquid builder-fee volume from addresses NOT in our registry. >2% should trigger a registry-update review.", }, - []string{"builder"}, ) - hlFillsTotal24h = promauto.NewGaugeVec( + hlRegistryAge = promauto.NewGauge( prometheus.GaugeOpts{ - Name: "hl_frontend_fills_total_24h_v2", - Help: "Count of fills attributed to this builder in the last 24h", + Name: "hl_frontend_registry_age_seconds", + Help: "Seconds since the builders.json file was last modified on disk. Surfaces stale-registry drift.", }, - []string{"builder"}, ) - hlEffectiveFeeBps = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_effective_fee_bps_v2", - Help: "Builder fees / notional volume in bps over the last 24h (user-perspective cost)", + hlCSVFetchStatus = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "hl_frontend_csv_fetch_status_total", + Help: "Outcome of each fetch of the per-day Hyperliquid fills CSV, by HTTP code (200, 403=no fills that day, 5xx, error).", }, - []string{"builder"}, + []string{"builder", "code"}, ) - hlFeesPerUserUSD = promauto.NewGaugeVec( + // Cohort retention — the unique OCB edge. Of the users whose first + // observed fill for a builder lands in the 24h window {7,30} days + // ago, the fraction that traded again in the last 24h. Requires + // the SQLite state layer because no public source publishes it. + hlD7Retention = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_fees_per_user_usd_v2", - Help: "Builder fees / unique users over the last 24h (per-user ARPU)", + Name: "hl_frontend_d7_retention_pct", + Help: "D7 cohort retention in percent: of users who first traded via this builder 7 days ago, fraction that traded again in the last 24h.", }, []string{"builder"}, ) - // BANGER №1: outage / freshness - hlLastFillAgeSeconds = promauto.NewGaugeVec( + hlD30Retention = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_last_fill_age_seconds_v2", - Help: "Seconds since this builder's most recent fill — outage detector", + Name: "hl_frontend_d30_retention_pct", + Help: "D30 cohort retention in percent: of users who first traded via this builder 30 days ago, fraction that traded again in the last 24h.", }, []string{"builder"}, ) - hlFillsPerMin = promauto.NewGaugeVec( + hlD7CohortSize = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_fills_per_min_v2", - Help: "Rate of fills per minute for this builder, averaged over the rolling 24h window", + Name: "hl_frontend_d7_cohort_size", + Help: "Number of users in the D7 cohort. Low cohort sizes (<50) make the retention percentage statistically noisy.", }, []string{"builder"}, ) - // BANGER №2: price deviation (slippage proxy) - hlPriceDeviationBps = promauto.NewGaugeVec( + hlD30CohortSize = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_price_deviation_bps_v2", - Help: "Mean |fill_px - last_trade_px_same_asset| in bps. Proxy for slippage / execution quality. Lower = better.", + Name: "hl_frontend_d30_cohort_size", + Help: "Number of users in the D30 cohort.", }, []string{"builder"}, ) - // BANGER №3: maker / taker split. crossed=true ⇒ taker; false ⇒ maker. - hlTakerPct = promauto.NewGaugeVec( + // Volume share computed across builders in the registry. Every + // other dashboard has this — we expose it as secondary so the + // page still surfaces the number readers expect to see. + hlVolumeSharePct = promauto.NewGaugeVec( prometheus.GaugeOpts{ - Name: "hl_frontend_taker_pct_v2", - Help: "Share of this builder's fills (0..1) that were takers (crossed the spread)", + Name: "hl_frontend_volume_share_pct", + Help: "Share in percent of the last 24h notional volume across builders in our registry. Coverage gap (= volume from unregistered addresses) surfaces in hl_frontend_unattributed_share_pct.", }, []string{"builder"}, ) - - // BANGER №4: per-asset top-3 dominance. 8 builders × 3 ranks = 24 series. - hlAssetVolumeTopUSD = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_asset_volume_top_usd_v2", - Help: "USD volume in the rolling 24h for the top-N asset traded via this builder (rank=1 is #1)", - }, - []string{"builder", "asset", "rank"}, - ) - - hlLastTickUnix = promauto.NewGauge( - prometheus.GaugeOpts{ - Name: "hl_frontend_local_last_tick_unix_v2", - Help: "Unix timestamp of the last successful aggregate refresh (harness liveness)", - }, - ) - - // HIP-3 deployer metrics. The `dex` label is the coin namespace prefix - // ("xyz:AAPL" → "xyz"); the set is dynamic (no registry) because HIP-3 - // deployment is permissionless and cardinality is naturally low (one - // label value per staked deployer). Not zero-initialised: a dex series - // appears with its first observed fill. users_7d/30d share the - // usersSeeded gate with the builder metrics. - hip3FeesUSD24h = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_hip3_deployer_fees_usd_24h", - Help: "Rolling 24h deployer-fee revenue in USD per HIP-3 dex (local node source)", - }, - []string{"dex"}, - ) - hip3FeesUSD7d = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_hip3_deployer_fees_usd_7d", - Help: "Rolling 7d deployer-fee revenue in USD per HIP-3 dex (hourly bucket sum)", - }, - []string{"dex"}, - ) - hip3FeesUSD30d = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_hip3_deployer_fees_usd_30d", - Help: "Rolling 30d deployer-fee revenue in USD per HIP-3 dex (hourly bucket sum, bounded by node file retention)", - }, - []string{"dex"}, - ) - hip3VolumeUSD24h = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_hip3_deployer_volume_usd_24h", - Help: "Rolling 24h notional volume in USD per HIP-3 dex", - }, - []string{"dex"}, - ) - hip3VolumeUSD7d = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_hip3_deployer_volume_usd_7d", - Help: "Rolling 7d notional volume in USD per HIP-3 dex (hourly bucket sum)", - }, - []string{"dex"}, - ) - hip3VolumeUSD30d = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_hip3_deployer_volume_usd_30d", - Help: "Rolling 30d notional volume in USD per HIP-3 dex (hourly bucket sum, bounded by node file retention)", - }, - []string{"dex"}, - ) - hip3Users24h = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_hip3_deployer_users_24h", - Help: "Unique wallets that traded on this HIP-3 dex in the last 24h (hourly set union)", - }, - []string{"dex"}, - ) - hip3Users7d = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_hip3_deployer_users_7d", - Help: "Unique wallets that traded on this HIP-3 dex over the last 7 UTC days (union of daily wallet sets)", - }, - []string{"dex"}, - ) - hip3Users30d = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_hip3_deployer_users_30d", - Help: "Unique wallets that traded on this HIP-3 dex over the last 30 UTC days (union of daily wallet sets, bounded by node file retention)", - }, - []string{"dex"}, - ) - hip3Fills24h = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_hip3_deployer_fills_24h", - Help: "Count of fills on this HIP-3 dex in the last 24h", - }, - []string{"dex"}, - ) - hip3Markets24h = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_hip3_deployer_markets_24h", - Help: "Distinct markets traded on this HIP-3 dex in the last 24h", - }, - []string{"dex"}, - ) - hip3EffectiveFeeBps = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_hip3_deployer_effective_fee_bps", - Help: "Deployer fees / notional volume in bps over the last 24h (trader-perspective cost of the dex)", - }, - []string{"dex"}, - ) - hip3LastFillAgeSeconds = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_hip3_deployer_last_fill_age_seconds", - Help: "Seconds since this HIP-3 dex's most recent fill — outage detector", - }, - []string{"dex"}, - ) ) -func registerMetrics(builders []Builder) { - for _, b := range builders { - hlVolumeUSD24h.WithLabelValues(b.Slug).Set(0) - hlFeesUSD24h.WithLabelValues(b.Slug).Set(0) - hlVolumeUSD7d.WithLabelValues(b.Slug).Set(0) - hlVolumeUSD30d.WithLabelValues(b.Slug).Set(0) - hlFeesUSD7d.WithLabelValues(b.Slug).Set(0) - hlFeesUSD30d.WithLabelValues(b.Slug).Set(0) - hlUsers24h.WithLabelValues(b.Slug).Set(0) - hlFillsTotal24h.WithLabelValues(b.Slug).Set(0) - hlEffectiveFeeBps.WithLabelValues(b.Slug).Set(0) - hlFeesPerUserUSD.WithLabelValues(b.Slug).Set(0) - hlLastFillAgeSeconds.WithLabelValues(b.Slug).Set(0) - hlFillsPerMin.WithLabelValues(b.Slug).Set(0) - hlPriceDeviationBps.WithLabelValues(b.Slug).Set(0) - hlTakerPct.WithLabelValues(b.Slug).Set(0) - } +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("hyperliquid-frontends harness · OpenChainBench")) + }) + return http.ListenAndServe(addr, mux) } diff --git a/harnesses/hyperliquid-frontends/go.mod b/harnesses/hyperliquid-frontends/go.mod index a9a1aff22..55b100d95 100644 --- a/harnesses/hyperliquid-frontends/go.mod +++ b/harnesses/hyperliquid-frontends/go.mod @@ -1,17 +1,33 @@ -module github.com/OpenChainBench/OpenChainBench/harnesses/hyperliquid-frontends +module github.com/MobulaFi/mobula-monorepo/miniapps/hyperliquid-frontends -go 1.22 +go 1.23 -require github.com/prometheus/client_golang v1.20.5 +require ( + github.com/pierrec/lz4/v4 v4.1.21 + github.com/prometheus/client_golang v1.20.5 + modernc.org/sqlite v1.34.1 +) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/sys v0.22.0 // indirect google.golang.org/protobuf v1.34.2 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect ) diff --git a/harnesses/hyperliquid-frontends/go.sum b/harnesses/hyperliquid-frontends/go.sum index d5318cf86..55d3db802 100644 --- a/harnesses/hyperliquid-frontends/go.sum +++ b/harnesses/hyperliquid-frontends/go.sum @@ -2,14 +2,30 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= @@ -18,7 +34,40 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= +modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/harnesses/l1-finality/cmd/script/config.go b/harnesses/l1-finality/cmd/script/config.go index e5b982ba6..7d45d01cd 100644 --- a/harnesses/l1-finality/cmd/script/config.go +++ b/harnesses/l1-finality/cmd/script/config.go @@ -17,7 +17,7 @@ const ( KindStellar ChainKind = "stellar" KindHedera ChainKind = "hedera" KindSui ChainKind = "sui" - KindTon ChainKind = "ton" + KindTon ChainKind = "gram" // PoW chains — "finalized" approximated by N confirmations. KindBitcoinLike ChainKind = "bitcoin_like" KindMonero ChainKind = "monero" @@ -90,7 +90,7 @@ func loadConfig() *Config { // // SUI moved to high-frequency HTTP polling wall-clock — see // sui_ws.go. Same methodology mismatch as before fixed. - // Gram (formerly TON) removed from HTTP polling — same issue as BNB/Avalanche + // TON removed from HTTP polling — same issue as BNB/Avalanche // (masterchain blocks finalize in ~0.5s, polling 10s = wrong // methodology). Now measured via SSE wall-clock subscriber on // tonapi.io's /v2/sse/blocks stream. diff --git a/harnesses/l1-finality/cmd/script/ton.go b/harnesses/l1-finality/cmd/script/ton.go index 7137eceb2..d8f61a0a9 100644 --- a/harnesses/l1-finality/cmd/script/ton.go +++ b/harnesses/l1-finality/cmd/script/ton.go @@ -10,7 +10,7 @@ import ( "time" ) -// Gram (formerly TON): tonapi.io anonymous tier supports masterchain-head + blocks/{id} +// TON: tonapi.io anonymous tier supports masterchain-head + blocks/{id} // reads. Toncenter free has 1 rps which we burst past with two block // fetches per cycle; tonapi is friendlier. Switching there. diff --git a/harnesses/l1-finality/cmd/script/ton_ws.go b/harnesses/l1-finality/cmd/script/ton_ws.go index d8a8f3a8a..8c881384b 100644 --- a/harnesses/l1-finality/cmd/script/ton_ws.go +++ b/harnesses/l1-finality/cmd/script/ton_ws.go @@ -11,12 +11,10 @@ import ( "time" ) -// Gram (formerly TON) wall-clock finality measurement via tonapi.io SSE stream. -// Function/type names and the TON_API_KEY env var keep their pre-rebrand -// identifiers so the deployed harness keeps booting without ops coordination. +// TON wall-clock finality measurement via tonapi.io SSE stream. // // The tonapi `/v2/sse/blocks?workchain=-1` endpoint pushes one event per -// masterchain block (~0.4-0.7 s cadence). Per the Gram (formerly TON) payment-processor +// masterchain block (~0.4-0.7 s cadence). Per the TON payment-processor // docs, "a transaction is finalized once included in a masterchain // block" — so the wall-clock interval between block N and block N+1 is // the time the network needs to finalize block N. Recording the first @@ -40,7 +38,7 @@ type tonSSEMessage struct { FileHash string `json:"file_hash"` } -// StartTONWallClock launches a persistent SSE subscriber for Gram (formerly TON) +// StartTONWallClock launches a persistent SSE subscriber for TON // masterchain. Reconnects with exponential backoff on error. When the // SSE stream is unavailable (tonapi gated it behind auth in 2026-06 and // deprecated it in favor of webhooks), falls back to fast-polling the @@ -53,7 +51,7 @@ func StartTONWallClock() { err := runTONSSE() if err != nil { fmt.Printf("[L1][ton] SSE error: %v (reconnecting in %v)\n", err, backoff) - wallClockHealth.WithLabelValues("ton").Set(0) + wallClockHealth.WithLabelValues("gram").Set(0) // Run the REST fallback for a window, then retry SSE // (the key may have been provisioned + service restarted, // or tonapi may have restored the stream). @@ -89,17 +87,17 @@ func pollTONWallClock(window time.Duration) { continue } if !healthy { - wallClockHealth.WithLabelValues("ton").Set(1) + wallClockHealth.WithLabelValues("gram").Set(1) healthy = true } - // Cap at 2 s in poll mode: Gram masterchain cadence is 0.4-0.7 s, + // Cap at 2 s in poll mode: TON masterchain cadence is 0.4-0.7 s, // so a multi-second "lag" here is poll aliasing (429 backoff // stretching the cadence), not finality. Observed pre-cap: 10.6 s // garbage samples polluting the 24h histogram. st.observeSeqno(head.Seqno, 2000) time.Sleep(interval) } - wallClockHealth.WithLabelValues("ton").Set(0) + wallClockHealth.WithLabelValues("gram").Set(0) } // observeSeqno records the first-seen time of a masterchain seqno and @@ -120,9 +118,9 @@ func (st *tonState) observeSeqno(seqno int64, maxLagMs float64) { if t, ok := st.firstSeen[prev]; ok && prev > st.lastSeen { lagMs := float64(now.Sub(t).Milliseconds()) if lagMs >= 0 && (maxLagMs == 0 || lagMs <= maxLagMs) { - wallClockLagGauge.WithLabelValues("ton").Set(lagMs) - wallClockLagSum.WithLabelValues("ton").Observe(lagMs) - wallClockSampleCtr.WithLabelValues("ton").Inc() + wallClockLagGauge.WithLabelValues("gram").Set(lagMs) + wallClockLagSum.WithLabelValues("gram").Observe(lagMs) + wallClockSampleCtr.WithLabelValues("gram").Inc() mode := "" if maxLagMs > 0 { mode = " (poll)" @@ -168,7 +166,7 @@ func runTONSSE() error { return fmt.Errorf("status_%d", resp.StatusCode) } - wallClockHealth.WithLabelValues("ton").Set(1) + wallClockHealth.WithLabelValues("gram").Set(1) fmt.Println("[L1][ton] SSE connected, listening for masterchain blocks") st := &tonState{firstSeen: map[int64]time.Time{}} diff --git a/harnesses/metadata-coverage/alertmanager/Dockerfile b/harnesses/metadata-coverage/alertmanager/Dockerfile index c71698026..2c74ae71e 100644 --- a/harnesses/metadata-coverage/alertmanager/Dockerfile +++ b/harnesses/metadata-coverage/alertmanager/Dockerfile @@ -1,15 +1,12 @@ FROM prom/alertmanager:latest -# Config carries __SLACK_WEBHOOK_URL__; entrypoint.sh sed-substitutes -# it from $SLACK_WEBHOOK_URL at container start. Keeps the real webhook -# URL out of the repo and out of the image layer. +# Copy AlertManager configuration COPY alertmanager.yml /etc/alertmanager/alertmanager.yml -COPY entrypoint.sh /entrypoint.sh - -USER root -RUN chmod +x /entrypoint.sh -USER nobody +# Expose AlertManager port EXPOSE 9093 -ENTRYPOINT ["/entrypoint.sh"] +# Run AlertManager +CMD ["--config.file=/etc/alertmanager/alertmanager.yml", \ + "--storage.path=/alertmanager", \ + "--log.level=debug"] diff --git a/harnesses/metadata-coverage/cmd/script/metadata_coverage_monitor.go b/harnesses/metadata-coverage/cmd/script/metadata_coverage_monitor.go index a66a8093a..5cefd473a 100644 --- a/harnesses/metadata-coverage/cmd/script/metadata_coverage_monitor.go +++ b/harnesses/metadata-coverage/cmd/script/metadata_coverage_monitor.go @@ -722,10 +722,15 @@ func checkTokenMetadata(token TokenToCheck, config *Config) { jupiterLogo = boolToIcon(jupiterResult.HasLogo) } - fmt.Printf("[META] %s/%s | M:%s%s%s | C:%s%s%s | J:%s\n", - token.Symbol, chainName, - boolToIcon(mobulaResult.HasLogo), boolToIcon(mobulaResult.HasDescription), boolToIcon(mobulaResult.HasTwitter), - boolToIcon(codexResult.HasLogo), boolToIcon(codexResult.HasDescription), boolToIcon(codexResult.HasTwitter), + // Adds the contract address to the condensed line so a divergence + // (M:✓✗✗ vs C:✓✓✓ etc.) is directly verifiable in the provider's UI + // without cross-referencing logs. Address goes after symbol; 4 boolean + // columns per provider so website is visible alongside logo/desc/twitter + // (the page renders 4 fields, the prior 3-column line hid that one). + fmt.Printf("[META] %s/%s %s | M:%s%s%s%s | C:%s%s%s%s | J:%s\n", + token.Symbol, chainName, token.Address, + boolToIcon(mobulaResult.HasLogo), boolToIcon(mobulaResult.HasDescription), boolToIcon(mobulaResult.HasTwitter), boolToIcon(mobulaResult.HasWebsite), + boolToIcon(codexResult.HasLogo), boolToIcon(codexResult.HasDescription), boolToIcon(codexResult.HasTwitter), boolToIcon(codexResult.HasWebsite), jupiterLogo) // Print stats every 50 checks (reduced from 10) diff --git a/harnesses/network-coverage/cmd/script/metrics.go b/harnesses/network-coverage/cmd/script/metrics.go index 80e68739d..4a110aa3e 100644 --- a/harnesses/network-coverage/cmd/script/metrics.go +++ b/harnesses/network-coverage/cmd/script/metrics.go @@ -89,6 +89,15 @@ func recordResult(provider string, res ProviderResult, dur time.Duration) { networksFetchLatency.WithLabelValues(provider).Set(float64(dur.Milliseconds())) if res.Err != "" { + // quota_exhausted (free-tier 402 on paid providers) is an expected + // monthly state, neither a fetch error nor an outage. + // Skipping recordResult entirely keeps the gauge at its last good + // value (Prom keeps scraping it from the still-running harness), + // so present_over_time stays >0 and the cron's "offline" alert + // never fires. Resumes naturally on the next successful fetch. + if res.Err == "quota_exhausted" { + return + } errType := classifyError(res.Err) networksFetchErrors.WithLabelValues(provider, errType).Inc() return diff --git a/harnesses/nft-metadata-coverage/Dockerfile b/harnesses/nft-metadata-coverage/Dockerfile new file mode 100644 index 000000000..63108cfc1 --- /dev/null +++ b/harnesses/nft-metadata-coverage/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/nft-metadata-coverage/README.md b/harnesses/nft-metadata-coverage/README.md new file mode 100644 index 000000000..a68dd2886 --- /dev/null +++ b/harnesses/nft-metadata-coverage/README.md @@ -0,0 +1,90 @@ +# nft-metadata-coverage + +OpenChainBench harness that scores collection-level metadata coverage for the +4 Ethereum NFT indexers we audit: **Moralis**, **Alchemy**, **OpenSea**, and +**Rarible**. + +## What it does + +Every 6 hours (configurable) the harness walks a hardcoded list of 50 blue-chip +Ethereum collections (BAYC, CryptoPunks, Pudgy Penguins, Azuki, etc.) and for +each one fetches the collection metadata from all 4 providers in parallel. It +then scores 5 fields per provider: + +| Field | Counts as present when | +| -------------- | ------------------------------------ | +| `name` | non-empty string | +| `image` | non-empty URL | +| `description` | non-empty string | +| `floor_eth` | positive number | +| `external_url` | non-empty URL | + +Per-provider field mapping (locked from apple-to-apple validation in +`/tmp/nft-validation/`): + +- **Moralis**: `name`, `collection_logo`, `description`, `floor_price` (string), + `project_url` +- **Alchemy**: `name`, `openSeaMetadata.imageUrl`, `openSeaMetadata.description`, + `openSeaMetadata.floorPrice`, `openSeaMetadata.externalUrl` +- **OpenSea**: 2 calls per collection — `/collections/{slug}` then + `/collections/{slug}/stats`. The slug is pre-resolved live at startup from + the contract address via `/chain/ethereum/contract/{contract}`. +- **Rarible**: `meta.name`, first `meta.content[]` entry where `@type=="IMAGE"`, + `meta.description`, `meta.externalLink`. Rarible's `floor_eth` is + **deliberately skipped** because the endpoint only exposes a bid-side floor + (`bestBidOrder`), not an ask-side floor — counting it would unfairly punish + the venue on apple-to-apple scoring. + +## Prometheus metrics on `:2112/metrics` + +- `nft_metadata_checks_total{provider, collection, field, region}` (counter) +- `nft_metadata_success_total{provider, collection, field, region}` (counter) +- `nft_metadata_latency_milliseconds{provider, region}` (histogram) +- `nft_metadata_errors_total{provider, error_type, region}` (counter) + +## Env vars + +| Name | Required | Default | +| ----------------------- | -------- | --------- | +| `MORALIS_API_KEY` | yes | (none) | +| `ALCHEMY_API_KEY` | yes | (none) | +| `OPENSEA_API_KEY` | yes | (none) | +| `RARIBLE_API_KEY` | yes | (none) | +| `MONITOR_REGION` | no | `eu-west` | +| `REFRESH_INTERVAL_HOURS`| no | `6` | +| `LOGS_TOKEN` | no | (disabled)| + +## Run locally + +```bash +cd miniapps/nft-metadata-coverage +go build -o cmd/script/script ./cmd/script + +# One-shot smoke run (10 validated collections, then idles waiting for SIGINT) +MORALIS_API_KEY=... \ + ALCHEMY_API_KEY=... \ + OPENSEA_API_KEY=... \ + RARIBLE_API_KEY=... \ + ./cmd/script/script --smoke +``` + +## /logs endpoint + +Set `LOGS_TOKEN=`, then: + +```bash +curl -s -H "X-Logs-Token: " "http://localhost:2112/logs?tail=200" +``` + +## Rate-limit plan + +| Provider | Plan | +| -------- | --------------------------------------------------- | +| Moralis | parallel, generous | +| Alchemy | parallel, generous | +| OpenSea | serial, ~650 ms between calls (60 req/min cap, 2 calls per collection) | +| Rarible | serial, 1 req/sec | + +Wall-clock per cycle at 50 collections: ~110s OpenSea + ~50s Rarible, both +serial, gated by their slower-of-two. Moralis/Alchemy fire in parallel inside +the same per-collection iteration. diff --git a/harnesses/nft-metadata-coverage/cmd/script/alchemy.go b/harnesses/nft-metadata-coverage/cmd/script/alchemy.go new file mode 100644 index 000000000..1922cd2ca --- /dev/null +++ b/harnesses/nft-metadata-coverage/cmd/script/alchemy.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// AlchemyContractMetadataResponse is the v3 NFT API contract endpoint shape. +// All five scored fields live under `openSeaMetadata` except `name` which is +// at top level. floorPrice is a JSON number (not a string like Moralis). +type AlchemyContractMetadataResponse struct { + Name string `json:"name"` + OpenSeaMetadata struct { + ImageURL string `json:"imageUrl"` + Description string `json:"description"` + FloorPrice float64 `json:"floorPrice"` + ExternalURL string `json:"externalUrl"` + } `json:"openSeaMetadata"` +} + +var alchemyClient = &http.Client{Timeout: 10 * time.Second} + +func checkAlchemy(coll NFTCollection, apiKey, region string) NFTResult { + res := newResult("alchemy", coll.Name) + + if apiKey == "" { + res.Error = "missing_api_key" + res.ErrorType = "config" + recordError("alchemy", region, "config") + return res + } + + url := fmt.Sprintf("https://eth-mainnet.g.alchemy.com/nft/v3/%s/getContractMetadata?contractAddress=%s", apiKey, coll.Contract) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + res.Error = err.Error() + res.ErrorType = "request_error" + recordError("alchemy", region, "request_error") + return res + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + + start := time.Now() + resp, err := alchemyClient.Do(req) + res.LatencyMs = float64(time.Since(start).Milliseconds()) + recordLatency("alchemy", region, res.LatencyMs) + + if err != nil { + res.Error = err.Error() + res.ErrorType = classifyNetError(err) + recordError("alchemy", region, res.ErrorType) + return res + } + defer resp.Body.Close() + res.StatusCode = resp.StatusCode + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + res.Error = fmt.Sprintf("status_%d", resp.StatusCode) + res.ErrorType = classifyHTTPStatus(resp.StatusCode) + recordError("alchemy", region, res.ErrorType) + return res + } + + var parsed AlchemyContractMetadataResponse + if err := json.Unmarshal(body, &parsed); err != nil { + res.Error = "parse_error: " + err.Error() + res.ErrorType = "parse_error" + recordError("alchemy", region, "parse_error") + return res + } + + res.Fields["name"] = strNonEmpty(parsed.Name) + res.Fields["image"] = strNonEmpty(parsed.OpenSeaMetadata.ImageURL) + res.Fields["description"] = strNonEmpty(parsed.OpenSeaMetadata.Description) + res.Fields["floor_eth"] = numPositive(parsed.OpenSeaMetadata.FloorPrice) + res.Fields["external_url"] = strNonEmpty(parsed.OpenSeaMetadata.ExternalURL) + res.Values["floor_eth"] = fmt.Sprintf("%g", parsed.OpenSeaMetadata.FloorPrice) + return res +} diff --git a/harnesses/nft-metadata-coverage/cmd/script/collections.go b/harnesses/nft-metadata-coverage/cmd/script/collections.go new file mode 100644 index 000000000..303cd95b5 --- /dev/null +++ b/harnesses/nft-metadata-coverage/cmd/script/collections.go @@ -0,0 +1,89 @@ +package main + +// NFTCollection is a single Ethereum blue-chip target with its provider keys. +// `OpenSeaSlug` is a hint: at startup we resolve it LIVE via the OpenSea +// /chain/ethereum/contract endpoint and overwrite the field. The seed value +// only exists so the smoke run has something to hit if the resolve call fails. +type NFTCollection struct { + Name string // display name used in logs + Contract string // ERC-721/1155 contract address, mixed case ok + OpenSeaSlug string // canonical slug (overwritten by live resolve) +} + +// The first 10 entries are the ones explicitly validated apple-to-apple in +// /tmp/nft-validation/REPORT.md. The remainder are well-established Ethereum +// collections selected for stability (multi-year-old, indexed by every +// provider). Slugs are seeds; runtime always re-resolves via OpenSea. +var COLLECTIONS = []NFTCollection{ + // Validated set + {"CryptoPunks", "0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB", "cryptopunks"}, + {"BAYC", "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D", "boredapeyachtclub"}, + {"MAYC", "0x60E4d786628Fea6478F785A6d7e704777c86a7c6", "mutant-ape-yacht-club"}, + {"Pudgy Penguins", "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", "pudgypenguins"}, + {"Doodles", "0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e", "doodles-official"}, + {"Azuki", "0xED5AF388653567Af2F388E6224dC7C4b3241C544", "azuki"}, + {"CloneX", "0x49cF6f5d44E70224e2E23fDcdd2C053F30aDA28B", "clonex"}, + {"Milady", "0x5Af0D9827E0c53E4799BB226655A1de152A425a5", "milady"}, + {"Moonbirds", "0x23581767a106ae21c074b2276D25e5C3e136a68b", "proof-moonbirds"}, + {"Cool Cats", "0x1A92f7381B9F03921564a437210bB9396471050C", "cool-cats-nft"}, + + // Extended set (40 more, blue-chip ETH collections) + {"World of Women", "0xe785E82358879F061BC3dcAC6f0444462D4b5330", "world-of-women-nft"}, + {"BAKC", "0xba30E5F9Bb24caa003E9f2f0497Ad287FDF95623", "bored-ape-kennel-club"}, + {"Otherdeed", "0x34d85c9CDeB23FA97cb08333b511ac86E1C4E258", "otherdeed"}, + {"CrypToadz", "0x1CB1A5e65610AEFF2551A50f76a87a7d3fB649C6", "cryptoadz-by-gremplin"}, + {"VeeFriends", "0xa3AEe8BcE55BEeA1951EF834b99f3Ac60d1ABeeB", "veefriends"}, + {"Meebits", "0x7Bd29408f11D2bFC23c34f18275bBf23bB716Bc7", "meebits"}, + {"CyberKongz", "0x57a204AA1042f6E66DD7730813f4024114d74f37", "cyberkongz"}, + {"Mfers", "0x79FCDEF22feeD20eDDacbB2587640e45491b757f", "mfers"}, + {"Renga", "0x394E3d3044fC89fCDd966D3cb35Ac0B32B0Cda91", "renga"}, + {"Nakamigos", "0xd774557b647330C91Bf44cfEAB205095f7E6c367", "nakamigos"}, + {"Goblintown", "0xbCe3781ae7Ca1a5e050Bd9C4c77369867eBc307e", "goblintownwtf"}, + {"DeGods", "0x8821BeE2ba0dF28761AffF119D66390D594CD280", "degods"}, + {"Beanz", "0x306b1ea3ecdf94aB739F1910bbda052Ed4A9f949", "beanzofficial"}, + {"Lazy Lions", "0x8943C7bAC1914C9A7ABa750Bf2B6B09Fd21037E0", "lazy-lions"}, + {"Invisible Friends", "0x59468516a8259058baD1cA5F8f4BFF190d30E066", "invisiblefriends"}, + {"KILLABEARS", "0xC99c679C50033Bbc5321EB88752E89a93e9e83C5", "killabears"}, + {"Memeland Captainz", "0x769272677faB02575E84945F03Eca517ACc544Cc", "the-captainz"}, + {"Memeland Potatoz", "0x39ee2c7b3cb80254225884ca001F57118C8f21B6", "thepotatoz"}, + {"Pixelmon", "0x32973908FaeE0Bf825A343000fE412ebE56F802A", "pixelmon"}, + {"Sewer Pass", "0x764AeebcF425d56800eF2c84F2578689415a2DAa", "sewerpass"}, + {"Otherside Vessel", "0x5b1085136a811e55b2Bb2CA1eA456bA82126A376", "otherside-vessels"}, + {"Murakami Flowers", "0x7D8820FA92EB1584636f4F5b8515B5476B75171a", "murakami-flowers-2022-official"}, + {"Boss Beauties", "0xb5C747561a185A146f83cFff25BdfD2455b31fF4", "boss-beauties"}, + {"CryptoDickbutts", "0x42069ABFE407C60cf4ae4112bEDEaD391dBa1cdB", "cryptodickbutts-s3"}, + {"Chromie Squiggle", "0x059EDD72Cd353dF5106D2B9cC5ab83a52287aC3a", "chromie-squiggle-by-snowfro"}, + {"Otherside Koda", "0xe012Baf811CF9c05c408e879C399960D1f305903", "kodas"}, + {"Mocaverse", "0x59325733eb952a92e069C87F0A6168b29E80627f", "mocaverse"}, + {"Wrapped CryptoPunks", "0xb7F7F6C52F2e2fdb1963Eab30438024864c313F6", "wrapped-cryptopunks"}, + {"Cool Pets", "0x86C10D10ECa1Fca9DAF87a279abCcABe0063F247", "cool-pets-nft"}, + {"My Pet Hooligan", "0x09233d553058c2F42ba751C87816a8E9FaE7Ef10", "mypethooligan"}, + {"Bored Ape Chemistry Club", "0x22C36BfdCef207F9c0CC941936eff94D4246d14A", "bored-ape-chemistry-club"}, + {"Loot", "0xFF9C1b15B16263C61d017ee9F65C50e4AE0113D7", "lootproject"}, + {"Parallel Alpha", "0x76BE3b62873462d2142405439777e971754E8E77", "parallelalpha"}, + {"NeoTokyo Outer Identities", "0x86357A19E5537A8Fba9A004E555713BC943a66C0", "neo-tokyo-outers"}, + {"Moonrunners", "0x1485297e942ce64E0870EcE60179dFda34b4C625", "moonrunnersofficial"}, + {"Wassies by Wassies", "0x9d418c2cae665d877f909a725402ebd3a0742844", "wassiesbywassies"}, + {"On1 Force", "0x3bf2922f4520a8BA0c2eFC3D2a1539678DaD5e9D", "0n1-force"}, + {"Crypto Coven", "0x5180db8F5c931aaE63c74266b211F580155ecac8", "cryptocoven"}, + {"Zen Academy", "0xF64E6E64ddE3B221C8F5be7B23E2832A1Db7e6e8", "zen-academy"}, + {"Adidas Originals", "0x28472a58A490c5e09A238847F66A68a47cC76f0f", "adidasoriginals"}, +} + +// activeCollections returns the slice to probe this cycle. Smoke mode +// always trims to the 10 hardcoded validated entries so a local run +// finishes in <1 minute. Otherwise the COLLECTIONS_MODE env var picks +// between the static blue-chip list (default, stable) and a dynamic +// top-N from on-chain mint events over the last ~10k transfers (no +// curated list, refreshes every cycle). Discovery has its own fallback +// to the static list if the RPC call fails, so this function is +// guaranteed to return ≥1 entry. +func activeCollections(smoke bool) []NFTCollection { + if smoke { + return COLLECTIONS[:10] + } + if cfgCollectionsMode == "dynamic" { + return discoverTopMintedCollections(cfgDiscoveryRPCURL) + } + return COLLECTIONS +} diff --git a/harnesses/nft-metadata-coverage/cmd/script/config.go b/harnesses/nft-metadata-coverage/cmd/script/config.go new file mode 100644 index 000000000..753a0b9d0 --- /dev/null +++ b/harnesses/nft-metadata-coverage/cmd/script/config.go @@ -0,0 +1,110 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// userAgent identifies every probe per the OCB methodology page. Providers can +// contact us or block the UA selectively instead of banning a bare Go client. +const userAgent = "OpenChainBench/1.0 (+https://openchainbench.com/methodology; contact@mobula.io)" + +type Config struct { + MoralisAPIKey string + AlchemyAPIKey string + OpenSeaAPIKey string + RaribleAPIKey string + MonitorRegion string + RefreshInterval int // hours + Smoke bool +} + +// Package-level globals for the dynamic-collections discovery path. +// Set during loadConfig(), read by activeCollections() in collections.go. +// Kept off Config because activeCollections takes no Config (would +// require threading it through the whole file for a single flag). +// +// COLLECTIONS_MODE: +// - "" / "static" (default): the COLLECTIONS list in collections.go +// - "dynamic": top-N ERC721 contracts by unique mint recipients in the +// last ~10k mints, pulled from Alchemy via alchemy_getAssetTransfers. +// +// DISCOVERY_RPC_URL overrides the default endpoint +// (https://eth-mainnet.g.alchemy.com/v2/). Any JSON-RPC +// endpoint supporting alchemy_getAssetTransfers works. +var ( + cfgCollectionsMode string + cfgDiscoveryRPCURL string + cfgAlchemyAPIKeyForDiscovery string +) + +func loadConfig() *Config { + cfg := &Config{ + MoralisAPIKey: strings.TrimSpace(os.Getenv("MORALIS_API_KEY")), + AlchemyAPIKey: strings.TrimSpace(os.Getenv("ALCHEMY_API_KEY")), + OpenSeaAPIKey: strings.TrimSpace(os.Getenv("OPENSEA_API_KEY")), + RaribleAPIKey: strings.TrimSpace(os.Getenv("RARIBLE_API_KEY")), + MonitorRegion: strings.TrimSpace(os.Getenv("MONITOR_REGION")), + RefreshInterval: 6, + } + + if cfg.MonitorRegion == "" { + cfg.MonitorRegion = "eu-west" + } + if v := strings.TrimSpace(os.Getenv("REFRESH_INTERVAL_HOURS")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + cfg.RefreshInterval = n + } + } + + cfgCollectionsMode = strings.ToLower(strings.TrimSpace(os.Getenv("COLLECTIONS_MODE"))) + cfgDiscoveryRPCURL = strings.TrimSpace(os.Getenv("DISCOVERY_RPC_URL")) + // Same key drives the NFT REST endpoint and the JSON-RPC endpoint — + // only the path differs (/nft/v3 vs /v2). Exposed as a package-level + // global because activeCollections() doesn't see Config. + cfgAlchemyAPIKeyForDiscovery = cfg.AlchemyAPIKey + + for _, a := range os.Args[1:] { + if a == "--smoke" { + cfg.Smoke = true + } + } + + return cfg +} + +func mask(k string) string { + if k == "" { + return "(unset)" + } + if len(k) <= 8 { + return "***" + } + return k[:4] + "..." + k[len(k)-4:] +} + +func (c *Config) printSummary() { + mode := cfgCollectionsMode + if mode == "" { + mode = "static" + } + rpc := cfgDiscoveryRPCURL + if rpc == "" { + rpc = "(default: alchemy eth-mainnet)" + } + fmt.Println("=== nft-metadata-coverage harness ===") + fmt.Printf("Region: %s\n", c.MonitorRegion) + fmt.Printf("Refresh: %dh\n", c.RefreshInterval) + fmt.Printf("Smoke mode: %v (limits to 10 validated collections when true)\n", c.Smoke) + fmt.Printf("Collections mode: %s\n", mode) + if mode == "dynamic" { + fmt.Printf("Discovery RPC: %s\n", rpc) + } + fmt.Printf("MORALIS_API_KEY: %s\n", mask(c.MoralisAPIKey)) + fmt.Printf("ALCHEMY_API_KEY: %s\n", mask(c.AlchemyAPIKey)) + fmt.Printf("OPENSEA_API_KEY: %s\n", mask(c.OpenSeaAPIKey)) + fmt.Printf("RARIBLE_API_KEY: %s\n", mask(c.RaribleAPIKey)) + fmt.Println("=====================================") +} diff --git a/harnesses/nft-metadata-coverage/cmd/script/discovery.go b/harnesses/nft-metadata-coverage/cmd/script/discovery.go new file mode 100644 index 000000000..c293f9afd --- /dev/null +++ b/harnesses/nft-metadata-coverage/cmd/script/discovery.go @@ -0,0 +1,298 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" +) + +// Default discovery RPC. Uses Alchemy's purpose-built +// alchemy_getAssetTransfers endpoint (works on free tier), which: +// - returns ERC721 mint transfers in a single call (no eth_getLogs chunking) +// - rich payload with rawContract.address, to, blockNum already decoded +// - bypasses the eth_getLogs 10-block range cap on Alchemy free tier +// +// The endpoint path needs the API key embedded: +// https://eth-mainnet.g.alchemy.com/v2/{API_KEY} +// +// We use the Alchemy NFT API key the harness already has wired in. +// Operator override via DISCOVERY_RPC_URL env if a paid alternative is +// preferred — but no extra config is required for the default case. +const defaultDiscoveryRPCTemplate = "https://eth-mainnet.g.alchemy.com/v2/%s" + +const ( + // Page size for one alchemy_getAssetTransfers call. The max is 1000. + discoveryMaxCount = 1000 + + // discoveryMaxPages caps the pagination depth. 10 pages × 1000 + // transfers = 10k sampled mints, which on the current ETH NFT volume + // covers roughly 6-12 hours of activity and yields 100+ distinct + // contracts. After dropping top-3 spam and filtering by min 3 unique + // recipients, ~50 quality collections remain. Each page = 1 cheap + // HTTP call (~500ms), so the full discovery is ~5s. + discoveryMaxPages = 10 + + // dropTopForSpam systematically removes the top N from the ranking to + // shed mega-airdrop / free-mint farmer contracts that always pollute + // the absolute peak. The first few slots are reliably uninteresting + // for a metadata-coverage bench (these contracts often lack any + // off-chain metadata, so all providers tie on them anyway). + dropTopForSpam = 3 + + // minMintsToInclude filters the long tail. ≥3 unique recipients means + // at least three distinct wallets touched this contract in the sample + // window — clears the noise floor of one-off bot tests without being + // so strict that we cap the bench at ~10 collections per cycle. + minMintsToInclude = 3 + + // dynamicCollectionsCount mirrors the static list size so the bench + // page legend ("50 collections / cycle") stays accurate without YAML + // churn between modes. + dynamicCollectionsCount = 50 + + discoveryTimeoutMs = 15000 +) + +type assetTransfersParams struct { + FromBlock string `json:"fromBlock"` + ToBlock string `json:"toBlock"` + FromAddress string `json:"fromAddress"` + Category []string `json:"category"` + MaxCount string `json:"maxCount"` + Order string `json:"order"` + PageKey string `json:"pageKey,omitempty"` +} + +type rawContract struct { + Address string `json:"address"` +} + +type assetTransfer struct { + BlockNum string `json:"blockNum"` + To string `json:"to"` + RawContract rawContract `json:"rawContract"` +} + +type assetTransfersResult struct { + Transfers []assetTransfer `json:"transfers"` + PageKey string `json:"pageKey,omitempty"` +} + +type rpcReq struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params []any `json:"params"` +} + +type rpcErr struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type rpcResp struct { + Result *assetTransfersResult `json:"result,omitempty"` + Error *rpcErr `json:"error,omitempty"` +} + +var discoveryClient = &http.Client{Timeout: time.Duration(discoveryTimeoutMs) * time.Millisecond} + +type contractMintCount struct { + Contract string + UniqueRecipients int + Mints int +} + +// discoverTopMintedCollections is the dynamic-mode entry point. Pulls the +// most recent ERC721 mints from Alchemy via the dedicated +// alchemy_getAssetTransfers endpoint (single call, no log-range hacking), +// groups by contract, ranks by unique recipient count, drops the systematic +// top-of-list spam, and returns up to dynamicCollectionsCount NFTCollection +// seeds. Slug field is empty — resolveAllSlugs fills it at cycle start. +// +// On any fatal error, returns the static COLLECTIONS list as a fallback so +// the cycle still produces data — one stale cycle beats one empty cycle. +func discoverTopMintedCollections(rpcURL string) []NFTCollection { + endpoint := rpcURL + if endpoint == "" { + if cfgAlchemyAPIKeyForDiscovery == "" { + fmt.Println("[NFT][discovery] no DISCOVERY_RPC_URL and no ALCHEMY_API_KEY — falling back to static list") + return COLLECTIONS + } + endpoint = fmt.Sprintf(defaultDiscoveryRPCTemplate, cfgAlchemyAPIKeyForDiscovery) + } + fmt.Printf("[NFT][discovery] pulling last %d ERC721 mints (max %d pages) from alchemy_getAssetTransfers\n", + discoveryMaxCount*discoveryMaxPages, discoveryMaxPages) + t0 := time.Now() + transfers, err := fetchRecentMints(endpoint) + if err != nil || len(transfers) == 0 { + fmt.Printf("[NFT][discovery] fetch failed (%v, n=%d) — falling back to static list\n", err, len(transfers)) + return COLLECTIONS + } + fmt.Printf("[NFT][discovery] %d transfers fetched in %s\n", len(transfers), time.Since(t0).Round(time.Second)) + + ranked := rankByUniqueRecipients(transfers) + fmt.Printf("[NFT][discovery] %d distinct contracts in sample\n", len(ranked)) + + out := make([]NFTCollection, 0, dynamicCollectionsCount) + skipped := 0 + for i, c := range ranked { + if i < dropTopForSpam { + fmt.Printf("[NFT][discovery] skip top-%d %s (%d unique recipients, likely batch mint/airdrop)\n", + i+1, c.Contract, c.UniqueRecipients) + skipped++ + continue + } + if c.UniqueRecipients < minMintsToInclude { + break + } + out = append(out, NFTCollection{ + Name: shortContract(c.Contract), + Contract: c.Contract, + OpenSeaSlug: "", // resolveAllSlugs fills this at cycle start + }) + if len(out) >= dynamicCollectionsCount { + break + } + } + fmt.Printf("[NFT][discovery] selected %d collections (skipped %d top-of-list, threshold ≥%d unique recipients)\n", + len(out), skipped, minMintsToInclude) + if len(out) == 0 { + fmt.Println("[NFT][discovery] no collections passed filters — falling back to static list") + return COLLECTIONS + } + return out +} + +// fetchRecentMints calls alchemy_getAssetTransfers up to discoveryMaxPages +// times, paginating via the returned pageKey, and concatenates the result. +// Order=desc means each page is older than the previous, so we sample the +// most recent N*1000 mints. Bails early on the first page that returns an +// empty pageKey (= reached the bottom) or any error after page 1 (partial +// is fine, we still rank what we got). +func fetchRecentMints(endpoint string) ([]assetTransfer, error) { + var all []assetTransfer + pageKey := "" + for page := 0; page < discoveryMaxPages; page++ { + batch, nextKey, err := fetchOnePage(endpoint, pageKey) + if err != nil { + if page == 0 { + return nil, err + } + fmt.Printf("[NFT][discovery] page %d failed: %v (continuing with %d transfers)\n", page+1, err, len(all)) + break + } + all = append(all, batch...) + if nextKey == "" || len(batch) == 0 { + break + } + pageKey = nextKey + } + return all, nil +} + +func fetchOnePage(endpoint, pageKey string) ([]assetTransfer, string, error) { + params := assetTransfersParams{ + FromBlock: "0x0", + ToBlock: "latest", + FromAddress: "0x0000000000000000000000000000000000000000", + Category: []string{"erc721"}, + MaxCount: fmt.Sprintf("0x%x", discoveryMaxCount), + Order: "desc", + PageKey: pageKey, + } + body, err := json.Marshal(rpcReq{ + JSONRPC: "2.0", + ID: 1, + Method: "alchemy_getAssetTransfers", + Params: []any{params}, + }) + if err != nil { + return nil, "", fmt.Errorf("marshal: %w", err) + } + req, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) + if err != nil { + return nil, "", fmt.Errorf("new request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + + resp, err := discoveryClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("do: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("read: %w", err) + } + if resp.StatusCode != 200 { + return nil, "", fmt.Errorf("status %d: %s", resp.StatusCode, truncate(string(raw), 200)) + } + var envelope rpcResp + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, "", fmt.Errorf("unmarshal: %w (body: %s)", err, truncate(string(raw), 200)) + } + if envelope.Error != nil { + return nil, "", fmt.Errorf("rpc error %d: %s", envelope.Error.Code, envelope.Error.Message) + } + if envelope.Result == nil { + return nil, "", fmt.Errorf("empty result") + } + return envelope.Result.Transfers, envelope.Result.PageKey, nil +} + +// rankByUniqueRecipients aggregates transfers into (contract, unique-to-count) +// pairs sorted desc. Unique recipients (rather than raw transfer count) +// dedupes single-bot airdrops that would otherwise dominate the leaderboard. +func rankByUniqueRecipients(transfers []assetTransfer) []contractMintCount { + type acc struct { + mints int + to map[string]struct{} + } + by := map[string]*acc{} + for _, t := range transfers { + addr := strings.ToLower(t.RawContract.Address) + if addr == "" { + continue + } + a := by[addr] + if a == nil { + a = &acc{to: map[string]struct{}{}} + by[addr] = a + } + a.mints++ + if t.To != "" { + a.to[strings.ToLower(t.To)] = struct{}{} + } + } + out := make([]contractMintCount, 0, len(by)) + for addr, a := range by { + out = append(out, contractMintCount{ + Contract: addr, + UniqueRecipients: len(a.to), + Mints: a.mints, + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].UniqueRecipients != out[j].UniqueRecipients { + return out[i].UniqueRecipients > out[j].UniqueRecipients + } + return out[i].Mints > out[j].Mints + }) + return out +} + +// shortContract returns "0xabcdef…1234" for log-line readability. +func shortContract(addr string) string { + if len(addr) <= 14 { + return addr + } + return addr[:10] + "…" + addr[len(addr)-4:] +} diff --git a/harnesses/nft-metadata-coverage/cmd/script/loghub.go b/harnesses/nft-metadata-coverage/cmd/script/loghub.go new file mode 100644 index 000000000..17d68fe69 --- /dev/null +++ b/harnesses/nft-metadata-coverage/cmd/script/loghub.go @@ -0,0 +1,104 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Inlined per-harness so Railway Docker build context need not reach a shared +// module. Captures stdout/stderr into a bounded ring buffer and exposes +// GET /logs?tail=N protected by X-Logs-Token matching the LOGS_TOKEN env var. + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/nft-metadata-coverage/cmd/script/main.go b/harnesses/nft-metadata-coverage/cmd/script/main.go new file mode 100644 index 000000000..d8f186403 --- /dev/null +++ b/harnesses/nft-metadata-coverage/cmd/script/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + installLogCapture() + cfg := loadConfig() + cfg.printSummary() + + // Prom :2112 in its own goroutine — Railway $PORT is deliberately ignored + // so the shared Prometheus scraper always finds the listener. + go func() { + fmt.Println("[NFT] starting metrics server on :2112 (/metrics, /health, /logs)") + if err := StartMetricsServer(":2112"); err != nil { + fmt.Printf("[NFT] metrics server crashed: %v\n", err) + os.Exit(1) + } + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Initial pass at startup so Prom has data within the first 6h window. + go runCheckAllProviders(cfg) + + if cfg.Smoke { + // Smoke runs a single cycle then idles — Railway/local invocations + // can just SIGINT once done. We still keep the metrics server alive + // so /metrics can be scraped post-cycle. + fmt.Println("[NFT] --smoke set: running ONE cycle then waiting for SIGINT") + <-sigChan + fmt.Println("[NFT] shutdown") + return + } + + ticker := time.NewTicker(time.Duration(cfg.RefreshInterval) * time.Hour) + defer ticker.Stop() + + for { + select { + case <-sigChan: + fmt.Println("[NFT] shutdown") + return + case <-ticker.C: + go runCheckAllProviders(cfg) + } + } +} diff --git a/harnesses/nft-metadata-coverage/cmd/script/metrics.go b/harnesses/nft-metadata-coverage/cmd/script/metrics.go new file mode 100644 index 000000000..acd258c41 --- /dev/null +++ b/harnesses/nft-metadata-coverage/cmd/script/metrics.go @@ -0,0 +1,79 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// nft_metadata_* namespace: NFT collection-level metadata coverage across the +// 4 indexers we audit (Moralis / Alchemy / OpenSea / Rarible). The denominator +// (checks_total) intentionally EXCLUDES the {Rarible, floor_eth} pair because +// Rarible's collection endpoint only exposes a bid-side floor (bestBidOrder), +// not an ask-side floor — counting it would unfairly punish them on apples-to- +// apples scoring vs the 3 providers that DO expose an ask floor. +var ( + latencyBuckets = []float64{50, 100, 200, 500, 1000, 2000, 5000, 10000} + + checksTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "nft_metadata_checks_total", + Help: "Total per-field metadata checks performed. The {provider=rarible, field=floor_eth} series is deliberately never incremented (Rarible exposes only a bid floor, not an ask floor).", + }, []string{"provider", "collection", "field", "region"}) + + successTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "nft_metadata_success_total", + Help: "Per-field successes (value is a non-empty string or a positive number).", + }, []string{"provider", "collection", "field", "region"}) + + latencyMs = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "nft_metadata_latency_milliseconds", + Help: "Wall-clock latency per provider request (collection metadata fetch). OpenSea aggregates both calls (collection + stats).", + Buckets: latencyBuckets, + }, []string{"provider", "region"}) + + errorsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "nft_metadata_errors_total", + Help: "Per-provider errors classified by type: timeout, http_4xx, http_5xx, not_found (404), parse_error, request_error, slug_resolve_error, throttled (429).", + }, []string{"provider", "error_type", "region"}) +) + +// recordFieldChecks updates checks_total + success_total for every field on a +// single provider response. The Rarible floor exception is enforced upstream +// (see rarible.go which skips the floor_eth field entirely). +func recordFieldChecks(provider, collection, region string, fields map[string]bool) { + for field, present := range fields { + checksTotal.WithLabelValues(provider, collection, field, region).Inc() + if present { + successTotal.WithLabelValues(provider, collection, field, region).Inc() + } + } +} + +func recordLatency(provider, region string, ms float64) { + if ms < 0 { + return + } + latencyMs.WithLabelValues(provider, region).Observe(ms) +} + +func recordError(provider, region, errorType string) { + errorsTotal.WithLabelValues(provider, errorType, region).Inc() +} + +// StartMetricsServer binds /metrics + /health + /logs on addr. Blocking call, +// run in its own goroutine. :2112 is the OCB convention; Railway's $PORT is +// deliberately ignored so the shared Prometheus always finds the listener. +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("nft-metadata-coverage harness · OpenChainBench")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/nft-metadata-coverage/cmd/script/monitor.go b/harnesses/nft-metadata-coverage/cmd/script/monitor.go new file mode 100644 index 000000000..26f146536 --- /dev/null +++ b/harnesses/nft-metadata-coverage/cmd/script/monitor.go @@ -0,0 +1,336 @@ +package main + +import ( + "errors" + "fmt" + "net" + "strings" + "sync" + "sync/atomic" + "time" +) + +// fieldOrder is the canonical 5-field order used everywhere we render a +// condensed line. Keep this in sync with the YAML bench spec. +var fieldOrder = []string{"name", "image", "description", "floor_eth", "external_url"} + +// truncate clips a string to n chars, appending "..." if cut. Used to keep +// error logs compact when an upstream returns a multi-line HTML body. +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// providerOrder is the column order in the condensed line, log table, and +// any debug dump. +var providerOrder = []string{"moralis", "alchemy", "opensea", "rarible"} + +// fieldLetter maps each field to a single-letter code (NIDFE) used in the +// condensed per-collection log line. +var fieldLetter = map[string]string{ + "name": "N", + "image": "I", + "description": "D", + "floor_eth": "F", + "external_url": "E", +} + +// classifyHTTPStatus buckets a status code into the error_type label space. +// 429 gets its own bucket because it's the only error we'd actually rate- +// limit ourselves against; 4xx vs 5xx separation lets us tell venue bugs +// from our bugs. +func classifyHTTPStatus(status int) string { + switch { + case status == 429: + return "throttled" + case status == 404: + return "not_found" + case status >= 500: + return "http_5xx" + case status >= 400: + return "http_4xx" + default: + return fmt.Sprintf("http_%d", status) + } +} + +// classifyNetError reduces the long tail of transport errors to 3 buckets so +// the Prom label space stays bounded. Anything that smells like a deadline is +// "timeout", reset connections are "net_error", everything else stays as +// "request_error". +func classifyNetError(err error) string { + if err == nil { + return "" + } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return "timeout" + } + msg := err.Error() + if strings.Contains(msg, "connection reset") || strings.Contains(msg, "EOF") { + return "net_error" + } + return "request_error" +} + +// raribleThrottle enforces the 1 req/sec ceiling the validation harness +// observed. Implemented as a simple time-since-last-call guard because we +// have exactly one Rarible worker goroutine (serial loop in monitor.go). +var ( + raribleLastReqMu sync.Mutex + raribleLastReq time.Time +) + +func raribleWaitTurn() { + raribleLastReqMu.Lock() + now := time.Now() + since := now.Sub(raribleLastReq) + if since < time.Second { + wait := time.Second - since + raribleLastReqMu.Unlock() + time.Sleep(wait) + raribleLastReqMu.Lock() + } + raribleLastReq = time.Now() + raribleLastReqMu.Unlock() +} + +// openSeaThrottle enforces ~1 call per 650ms. OpenSea allows 60 req/min and +// we issue 2 calls per collection, so a 650ms gap keeps us under the cap +// even when both calls land. The harness is single-goroutine for OS, so +// global state is fine. +var ( + openSeaLastReqMu sync.Mutex + openSeaLastReq time.Time +) + +func openSeaWaitTurn() { + openSeaLastReqMu.Lock() + now := time.Now() + since := now.Sub(openSeaLastReq) + const gap = 650 * time.Millisecond + if since < gap { + wait := gap - since + openSeaLastReqMu.Unlock() + time.Sleep(wait) + openSeaLastReqMu.Lock() + } + openSeaLastReq = time.Now() + openSeaLastReqMu.Unlock() +} + +// resolveAllSlugs walks every collection at startup and replaces the seed +// slug with the canonical OpenSea slug. Failures are logged and the seed +// slug is kept (likely to fail later on /collections/{slug}, but at least +// the other 3 providers still run). +func resolveAllSlugs(colls []NFTCollection, cfg *Config) []NFTCollection { + out := make([]NFTCollection, len(colls)) + for i, c := range colls { + openSeaWaitTurn() + slug, err := resolveOpenSeaSlug(c.Contract, cfg.OpenSeaAPIKey, cfg.MonitorRegion) + if err != nil || slug == "" { + fmt.Printf("[NFT][slug] %s: resolve failed (%v), keeping seed=%q\n", c.Name, err, c.OpenSeaSlug) + out[i] = c + continue + } + if slug != c.OpenSeaSlug { + fmt.Printf("[NFT][slug] %s: %q -> %q\n", c.Name, c.OpenSeaSlug, slug) + } + c.OpenSeaSlug = slug + out[i] = c + } + return out +} + +// renderFieldCode produces a 5-char letter code per provider. "N"=present, +// "."=absent, "-"=skipped (Rarible floor_eth). Order matches fieldOrder. +func renderFieldCode(provider string, fields map[string]bool) string { + out := make([]byte, 0, len(fieldOrder)) + for _, f := range fieldOrder { + if provider == "rarible" && f == "floor_eth" { + out = append(out, '-') + continue + } + v, ok := fields[f] + if !ok { + out = append(out, '.') + continue + } + if v { + out = append(out, fieldLetter[f][0]) + } else { + out = append(out, '.') + } + } + return string(out) +} + +// applyResult is the single funnel that pushes a provider response into +// Prometheus. The Rarible floor_eth skip lives here (we drop the key before +// recordFieldChecks runs). +func applyResult(res NFTResult, region string) { + if res.Provider == "rarible" { + delete(res.Fields, "floor_eth") + } + if len(res.Fields) == 0 { + // Hard failure (e.g. config error). Don't pollute the success ratio + // — only error counters were touched at the call site. + return + } + recordFieldChecks(res.Provider, res.Collection, region, res.Fields) + bumpCycleStats(res.Provider, res.Fields) +} + +// runCheckAllProviders is the per-cycle entry point. Pre-resolves OpenSea +// slugs once, then iterates the active collection list and fires the 4 +// provider probes per the rate-limit plan from the spec. +func runCheckAllProviders(cfg *Config) { + colls := activeCollections(cfg.Smoke) + fmt.Printf("[NFT][cycle] start: %d collections, region=%s, smoke=%v\n", + len(colls), cfg.MonitorRegion, cfg.Smoke) + + // Pre-resolve slugs (uses the openSeaWaitTurn throttle). + colls = resolveAllSlugs(colls, cfg) + + startedAt := time.Now() + processed := int32(0) + + for _, coll := range colls { + results := map[string]NFTResult{} + var mu sync.Mutex + var wg sync.WaitGroup + + // Moralis + Alchemy in parallel — generous rate limits, no need to + // serialize. + wg.Add(2) + go func() { + defer wg.Done() + r := checkMoralis(coll, cfg.MoralisAPIKey, cfg.MonitorRegion) + mu.Lock() + results["moralis"] = r + mu.Unlock() + }() + go func() { + defer wg.Done() + r := checkAlchemy(coll, cfg.AlchemyAPIKey, cfg.MonitorRegion) + mu.Lock() + results["alchemy"] = r + mu.Unlock() + }() + wg.Wait() + + // OpenSea is serial via openSeaWaitTurn (2 calls per collection, + // 650ms gap between any 2 calls). + openSeaWaitTurn() // gap before call 1 + osRes := checkOpenSea(coll, cfg.OpenSeaAPIKey, cfg.MonitorRegion) + results["opensea"] = osRes + openSeaWaitTurn() // accounts for the second internal call's spacing + + // Rarible is serial @ 1 req/sec. + raribleWaitTurn() + raRes := checkRarible(coll, cfg.RaribleAPIKey, cfg.MonitorRegion) + results["rarible"] = raRes + + for _, p := range providerOrder { + applyResult(results[p], cfg.MonitorRegion) + } + + // Console-log any provider error so Railway logs surface it. The + // recordError() calls only increment Prom counters; without this + // echo, a silent provider failure (auth, rate-limit, network) reads + // as "M:....." in the condensed line below with zero explanation. + for _, p := range providerOrder { + r := results[p] + if r.Error != "" { + fmt.Printf("[NFT][%s][err] %s (%s): status=%d type=%s msg=%s\n", + p, coll.Name, coll.Contract, r.StatusCode, r.ErrorType, truncate(r.Error, 200)) + } + } + + // Condensed per-collection log line. + parts := make([]string, 0, len(providerOrder)) + for _, p := range providerOrder { + r := results[p] + letter := strings.ToUpper(p[:1]) + parts = append(parts, fmt.Sprintf("%s:%s", letter, renderFieldCode(p, r.Fields))) + } + fmt.Printf("[NFT] %-22s | %s\n", coll.Name, strings.Join(parts, " | ")) + + n := atomic.AddInt32(&processed, 1) + if n%10 == 0 { + printCycleStats(colls[:n], cfg) + } + } + + fmt.Printf("[NFT][cycle] done: %d collections in %s\n", + len(colls), time.Since(startedAt).Round(time.Second)) + printCycleStats(colls, cfg) +} + +// printCycleStats walks the Prom counters and renders a coverage table for +// the slice provided. Reads the counter snapshot via Prometheus' gather() +// would require more plumbing; we just keep a small in-memory mirror. +// +// To avoid duplicating state, we compute totals on the fly by re-walking the +// already-emitted Prom families. That's expensive at 50 collections though, +// so we instead maintain a parallel in-process accumulator below. +var ( + statsMu sync.Mutex + // stats[provider][field] = [success, total] + cycleStats = map[string]map[string][2]int{} +) + +func init() { + for _, p := range providerOrder { + cycleStats[p] = map[string][2]int{} + } +} + +// applyResult feeds Prom AND the in-memory cycle stats so we can print a +// table without scraping our own /metrics endpoint. +// +// (override of the earlier applyResult — Go allows only one definition, so +// the implementation above is the only one. Stats tracking happens inline.) + +func bumpCycleStats(provider string, fields map[string]bool) { + statsMu.Lock() + defer statsMu.Unlock() + for f, ok := range fields { + cur := cycleStats[provider][f] + cur[1]++ + if ok { + cur[0]++ + } + cycleStats[provider][f] = cur + } +} + +func printCycleStats(processed []NFTCollection, cfg *Config) { + statsMu.Lock() + defer statsMu.Unlock() + fmt.Printf("\n--- NFT coverage snapshot (after %d collections, region=%s) ---\n", + len(processed), cfg.MonitorRegion) + fmt.Printf("%-10s | %-12s | %-12s | %-12s | %-12s | %-12s\n", + "provider", "name", "image", "description", "floor_eth", "external_url") + fmt.Printf("%s\n", strings.Repeat("-", 84)) + for _, p := range providerOrder { + row := fmt.Sprintf("%-10s", p) + for _, f := range fieldOrder { + cur := cycleStats[p][f] + if p == "rarible" && f == "floor_eth" { + row += " | " + fmt.Sprintf("%-12s", "skipped") + continue + } + if cur[1] == 0 { + row += " | " + fmt.Sprintf("%-12s", "-") + continue + } + pct := float64(cur[0]) / float64(cur[1]) * 100 + row += " | " + fmt.Sprintf("%5.1f%% %3d/%-3d", pct, cur[0], cur[1]) + } + fmt.Println(row) + } + fmt.Println() +} diff --git a/harnesses/nft-metadata-coverage/cmd/script/moralis.go b/harnesses/nft-metadata-coverage/cmd/script/moralis.go new file mode 100644 index 000000000..f82a76f7f --- /dev/null +++ b/harnesses/nft-metadata-coverage/cmd/script/moralis.go @@ -0,0 +1,134 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// NFTResult is the per-provider per-collection probe outcome. `Fields` maps +// each scored field name to whether the provider returned a usable value. The +// 5 keys are always: name, image, description, floor_eth, external_url — +// except Rarible which omits floor_eth because the venue only exposes a bid +// floor (see metrics.go for the apple-to-apple rationale). +type NFTResult struct { + Provider string + Collection string + Fields map[string]bool // field -> present + Values map[string]string // field -> raw value (logged only, not metric) + LatencyMs float64 + StatusCode int + Error string // empty when ok + ErrorType string // populated when Error != "" +} + +func newResult(provider, collection string) NFTResult { + return NFTResult{ + Provider: provider, + Collection: collection, + Fields: map[string]bool{}, + Values: map[string]string{}, + } +} + +// strNonEmpty is the "field counts" predicate for string fields: non-empty +// after trim. JSON null and missing keys naturally land as "" via the typed +// unmarshal so we don't have to special-case them. +func strNonEmpty(s string) bool { return s != "" } + +// numPositive is the predicate for numeric fields (floor_eth). Zero is treated +// as "no floor" because every provider returns 0 when they have no listing — +// indistinguishable from "missing" for the purposes of coverage scoring. +func numPositive(f float64) bool { return f > 0 } + +// parseFloorString accepts the providers that return floor as a JSON string +// (Moralis: "8.95"). Falls back to 0 on parse error. +func parseFloorString(s string) float64 { + if s == "" { + return 0 + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0 + } + return v +} + +// MoralisCollectionResponse covers the fields we score plus the few extras we +// keep for log lines. The shape comes from GET /nft/{contract}/metadata which +// returns a flat object (no wrapping `data`). +type MoralisCollectionResponse struct { + Name string `json:"name"` + CollectionLogo string `json:"collection_logo"` + Description string `json:"description"` + FloorPrice string `json:"floor_price"` + ProjectURL string `json:"project_url"` +} + +var moralisClient = &http.Client{Timeout: 10 * time.Second} + +func checkMoralis(coll NFTCollection, apiKey, region string) NFTResult { + res := newResult("moralis", coll.Name) + + if apiKey == "" { + res.Error = "missing_api_key" + res.ErrorType = "config" + recordError("moralis", region, "config") + return res + } + + url := fmt.Sprintf("https://deep-index.moralis.io/api/v2.2/nft/%s/metadata?chain=eth", coll.Contract) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + res.Error = err.Error() + res.ErrorType = "request_error" + recordError("moralis", region, "request_error") + return res + } + req.Header.Set("X-API-Key", apiKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + + start := time.Now() + resp, err := moralisClient.Do(req) + res.LatencyMs = float64(time.Since(start).Milliseconds()) + recordLatency("moralis", region, res.LatencyMs) + + if err != nil { + res.Error = err.Error() + res.ErrorType = classifyNetError(err) + recordError("moralis", region, res.ErrorType) + return res + } + defer resp.Body.Close() + res.StatusCode = resp.StatusCode + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + res.Error = fmt.Sprintf("status_%d", resp.StatusCode) + res.ErrorType = classifyHTTPStatus(resp.StatusCode) + recordError("moralis", region, res.ErrorType) + return res + } + + var parsed MoralisCollectionResponse + if err := json.Unmarshal(body, &parsed); err != nil { + res.Error = "parse_error: " + err.Error() + res.ErrorType = "parse_error" + recordError("moralis", region, "parse_error") + return res + } + + floor := parseFloorString(parsed.FloorPrice) + res.Fields["name"] = strNonEmpty(parsed.Name) + res.Fields["image"] = strNonEmpty(parsed.CollectionLogo) + res.Fields["description"] = strNonEmpty(parsed.Description) + res.Fields["floor_eth"] = numPositive(floor) + res.Fields["external_url"] = strNonEmpty(parsed.ProjectURL) + res.Values["floor_eth"] = parsed.FloorPrice + return res +} diff --git a/harnesses/nft-metadata-coverage/cmd/script/opensea.go b/harnesses/nft-metadata-coverage/cmd/script/opensea.go new file mode 100644 index 000000000..944ae8390 --- /dev/null +++ b/harnesses/nft-metadata-coverage/cmd/script/opensea.go @@ -0,0 +1,164 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// OpenSeaCollectionResponse: GET /api/v2/collections/{slug}. We extract name, +// description, image_url, project_url here; floor lives in a SEPARATE endpoint +// (see OpenSeaStatsResponse below) so each collection costs 2 OS calls. +type OpenSeaCollectionResponse struct { + Name string `json:"name"` + Description string `json:"description"` + ImageURL string `json:"image_url"` + ProjectURL string `json:"project_url"` +} + +// OpenSeaStatsResponse: GET /api/v2/collections/{slug}/stats. The ask-side +// floor lives at stats.total.floor_price (a number in the listing currency, +// usually ETH). +type OpenSeaStatsResponse struct { + Total struct { + FloorPrice float64 `json:"floor_price"` + } `json:"total"` +} + +// OpenSeaContractResolveResponse: GET /api/v2/chain/ethereum/contract/{addr}. +// Used at startup to map a contract -> canonical slug. Without this we can't +// hit /collections/{slug} because OpenSea changed slugs on multiple blue +// chips post-rebrand. +type OpenSeaContractResolveResponse struct { + Collection string `json:"collection"` +} + +var openSeaClient = &http.Client{Timeout: 10 * time.Second} + +// resolveOpenSeaSlug looks up the canonical OS slug from a contract address. +// Called once per collection at startup. Returns "" on failure; the caller +// then falls back to the seed slug from collections.go. +func resolveOpenSeaSlug(contract, apiKey, region string) (string, error) { + url := fmt.Sprintf("https://api.opensea.io/api/v2/chain/ethereum/contract/%s", contract) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("x-api-key", apiKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + + resp, err := openSeaClient.Do(req) + if err != nil { + recordError("opensea", region, "slug_resolve_error") + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + recordError("opensea", region, "slug_resolve_error") + return "", fmt.Errorf("opensea slug resolve status %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + var parsed OpenSeaContractResolveResponse + if err := json.Unmarshal(body, &parsed); err != nil { + recordError("opensea", region, "slug_resolve_error") + return "", err + } + return parsed.Collection, nil +} + +// checkOpenSea performs the 2-call sequence (collection + stats) and merges +// the result. Latency is the WALL-CLOCK sum of both calls — a fair +// representation of "what would a single coverage query against OpenSea +// look like" given they split the data. +func checkOpenSea(coll NFTCollection, apiKey, region string) NFTResult { + res := newResult("opensea", coll.Name) + + if apiKey == "" { + res.Error = "missing_api_key" + res.ErrorType = "config" + recordError("opensea", region, "config") + return res + } + if coll.OpenSeaSlug == "" { + res.Error = "missing_slug" + res.ErrorType = "slug_resolve_error" + recordError("opensea", region, "slug_resolve_error") + return res + } + + start := time.Now() + + // Call 1: collection metadata + url1 := fmt.Sprintf("https://api.opensea.io/api/v2/collections/%s", coll.OpenSeaSlug) + req1, _ := http.NewRequest("GET", url1, nil) + req1.Header.Set("x-api-key", apiKey) + req1.Header.Set("Accept", "application/json") + req1.Header.Set("User-Agent", userAgent) + + resp1, err := openSeaClient.Do(req1) + if err != nil { + res.LatencyMs = float64(time.Since(start).Milliseconds()) + recordLatency("opensea", region, res.LatencyMs) + res.Error = err.Error() + res.ErrorType = classifyNetError(err) + recordError("opensea", region, res.ErrorType) + return res + } + defer resp1.Body.Close() + res.StatusCode = resp1.StatusCode + + if resp1.StatusCode != 200 { + res.LatencyMs = float64(time.Since(start).Milliseconds()) + recordLatency("opensea", region, res.LatencyMs) + res.Error = fmt.Sprintf("status_%d", resp1.StatusCode) + res.ErrorType = classifyHTTPStatus(resp1.StatusCode) + recordError("opensea", region, res.ErrorType) + return res + } + + body1, _ := io.ReadAll(resp1.Body) + var meta OpenSeaCollectionResponse + if err := json.Unmarshal(body1, &meta); err != nil { + res.LatencyMs = float64(time.Since(start).Milliseconds()) + recordLatency("opensea", region, res.LatencyMs) + res.Error = "parse_error: " + err.Error() + res.ErrorType = "parse_error" + recordError("opensea", region, "parse_error") + return res + } + + // Call 2: stats (floor price). Non-fatal if it fails — we still score 4/5. + url2 := fmt.Sprintf("https://api.opensea.io/api/v2/collections/%s/stats", coll.OpenSeaSlug) + req2, _ := http.NewRequest("GET", url2, nil) + req2.Header.Set("x-api-key", apiKey) + req2.Header.Set("Accept", "application/json") + req2.Header.Set("User-Agent", userAgent) + + var floor float64 + resp2, err2 := openSeaClient.Do(req2) + if err2 == nil { + if resp2.StatusCode == 200 { + body2, _ := io.ReadAll(resp2.Body) + var stats OpenSeaStatsResponse + if jerr := json.Unmarshal(body2, &stats); jerr == nil { + floor = stats.Total.FloorPrice + } + } else { + recordError("opensea", region, "stats_"+classifyHTTPStatus(resp2.StatusCode)) + } + resp2.Body.Close() + } else { + recordError("opensea", region, "stats_"+classifyNetError(err2)) + } + + res.LatencyMs = float64(time.Since(start).Milliseconds()) + recordLatency("opensea", region, res.LatencyMs) + + res.Fields["name"] = strNonEmpty(meta.Name) + res.Fields["image"] = strNonEmpty(meta.ImageURL) + res.Fields["description"] = strNonEmpty(meta.Description) + res.Fields["floor_eth"] = numPositive(floor) + res.Fields["external_url"] = strNonEmpty(meta.ProjectURL) + res.Values["floor_eth"] = fmt.Sprintf("%g", floor) + return res +} diff --git a/harnesses/nft-metadata-coverage/cmd/script/rarible.go b/harnesses/nft-metadata-coverage/cmd/script/rarible.go new file mode 100644 index 000000000..a46db90b2 --- /dev/null +++ b/harnesses/nft-metadata-coverage/cmd/script/rarible.go @@ -0,0 +1,109 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// RaribleCollectionResponse: GET /v0.1/collections/ETHEREUM:{contract}. +// 4 fields scored (name/image/description/external_url). floor_eth is +// DELIBERATELY skipped — see metrics.go for the rationale. +// +// The image lives inside meta.content[] as the first entry where +// `@type=="IMAGE"`. There can be multiple representations (ORIGINAL / +// PREVIEW); we pick the first IMAGE in array order, which is what the +// validation harness did. +type RaribleCollectionResponse struct { + Meta struct { + Name string `json:"name"` + Description string `json:"description"` + ExternalLink string `json:"externalLink"` + Content []struct { + Type string `json:"@type"` + URL string `json:"url"` + } `json:"content"` + } `json:"meta"` +} + +var raribleClient = &http.Client{Timeout: 10 * time.Second} + +func checkRarible(coll NFTCollection, apiKey, region string) NFTResult { + res := newResult("rarible", coll.Name) + + if apiKey == "" { + res.Error = "missing_api_key" + res.ErrorType = "config" + recordError("rarible", region, "config") + return res + } + + url := fmt.Sprintf("https://api.rarible.org/v0.1/collections/ETHEREUM:%s", coll.Contract) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("X-API-KEY", apiKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + + start := time.Now() + resp, err := raribleClient.Do(req) + res.LatencyMs = float64(time.Since(start).Milliseconds()) + recordLatency("rarible", region, res.LatencyMs) + + if err != nil { + res.Error = err.Error() + res.ErrorType = classifyNetError(err) + recordError("rarible", region, res.ErrorType) + return res + } + defer resp.Body.Close() + res.StatusCode = resp.StatusCode + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == 404 { + // Some legacy collections (CryptoPunks) are unindexed on Rarible. + // Mark the 4 scored fields as `false` so the denominator still moves + // — we want the coverage % to reflect that this collection isn't + // indexed, not silently exclude it. + res.Error = "not_found" + res.ErrorType = "not_found" + recordError("rarible", region, "not_found") + res.Fields["name"] = false + res.Fields["image"] = false + res.Fields["description"] = false + res.Fields["external_url"] = false + return res + } + + if resp.StatusCode != 200 { + res.Error = fmt.Sprintf("status_%d", resp.StatusCode) + res.ErrorType = classifyHTTPStatus(resp.StatusCode) + recordError("rarible", region, res.ErrorType) + return res + } + + var parsed RaribleCollectionResponse + if err := json.Unmarshal(body, &parsed); err != nil { + res.Error = "parse_error: " + err.Error() + res.ErrorType = "parse_error" + recordError("rarible", region, "parse_error") + return res + } + + imageURL := "" + for _, c := range parsed.Meta.Content { + if c.Type == "IMAGE" && c.URL != "" { + imageURL = c.URL + break + } + } + + res.Fields["name"] = strNonEmpty(parsed.Meta.Name) + res.Fields["image"] = strNonEmpty(imageURL) + res.Fields["description"] = strNonEmpty(parsed.Meta.Description) + res.Fields["external_url"] = strNonEmpty(parsed.Meta.ExternalLink) + // floor_eth intentionally absent — Rarible exposes bid floor only. + return res +} diff --git a/harnesses/nft-metadata-coverage/go.mod b/harnesses/nft-metadata-coverage/go.mod new file mode 100644 index 000000000..8b3ccb27e --- /dev/null +++ b/harnesses/nft-metadata-coverage/go.mod @@ -0,0 +1,18 @@ +module nft-metadata-coverage + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/nft-metadata-coverage/go.sum b/harnesses/nft-metadata-coverage/go.sum new file mode 100644 index 000000000..d6b8ca98b --- /dev/null +++ b/harnesses/nft-metadata-coverage/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/oracle-deviation/cmd/script/chainlink.go b/harnesses/oracle-deviation/cmd/script/chainlink.go index c89d956cb..fdd292de2 100644 --- a/harnesses/oracle-deviation/cmd/script/chainlink.go +++ b/harnesses/oracle-deviation/cmd/script/chainlink.go @@ -107,6 +107,15 @@ func runChainlink(ctx context.Context, specs []PairSpec) { tick := func() { for _, s := range specs { + // An empty ChainlinkFeed means the spec author declared + // "no Chainlink source for this pair" — Chainlink retired + // the mainnet aggregator (XRP/ADA/DOGE since 2025). Skip + // silently: no round-trip, no error counter, no stale + // chainlink line on the bench. Pyth/Binance/Coinbase + // still feed the deviation calc for this pair. + if s.ChainlinkFeed == "" { + continue + } if c.unsupported[s.ChainlinkFeed] { // Mark error every cycle so the deviation calc skips // stale values, but skip the network round-trip. diff --git a/harnesses/oracle-deviation/cmd/script/config.go b/harnesses/oracle-deviation/cmd/script/config.go index d24297953..db0f23b6b 100644 --- a/harnesses/oracle-deviation/cmd/script/config.go +++ b/harnesses/oracle-deviation/cmd/script/config.go @@ -55,9 +55,19 @@ func pairs() []PairSpec { {"ETH/USD", "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419", "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace", "ETHUSDT", "ETH-USD"}, {"SOL/USD", "0x4ffC43a60e009B551865A93d232E33Fce9f01507", "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d", "SOLUSDT", "SOL-USD"}, {"BNB/USD", "0x14e613AC84a31f709eadbdF89C6CC390fDc9540A", "0x2f95862b045670cd22bee3114c39763a4a08beeb663b145d283c31d7d1101c4f", "BNBUSDT", "BNB-USD"}, - {"XRP/USD", "0xCed2660c6Dd1Ffd856A5A82C67f3482d88C50b12", "0xec5d399846a9209f3fe5881d70aae9268c94339ff9817e8d18ff19fa05eea1c8", "XRPUSDT", "XRP-USD"}, - {"ADA/USD", "0xAE48c91dF1fE419994FFDa27da09D5aC69c30f55", "0x2a01deaec9e51a579277b34b122399984d0bbf57e2458a7e42fecd2829867a0d", "ADAUSDT", "ADA-USD"}, - {"DOGE/USD", "0x2465CefD3b488BE410b941b1d4b2767088e2A028", "0xdcef50dd0a4cd2dcc17e45df1676dcb336a11a61c69df7a0299b0150c672d25c", "DOGEUSDT", "DOGE-USD"}, + // XRP/ADA/DOGE: Chainlink retired the Ethereum mainnet + // AggregatorV3 contracts for these three pairs. eth_call + // against the previously-published proxies (0xCed266..., 0xAE48c9..., + // 0x2465Ce...) now reverts in 100% of cases, which spammed + // ocb_oracle_scrape_errors_total at ~2.9 k errors / pair / day. + // Pyth / Binance / Coinbase still publish these symbols, so we + // keep the pair with an empty ChainlinkFeed — the poller treats + // empty addresses as "no Chainlink source for this pair", skips + // the RPC round-trip and produces no error. Restore the address + // the day Chainlink re-publishes mainnet feeds for these pairs. + {"XRP/USD", "", "0xec5d399846a9209f3fe5881d70aae9268c94339ff9817e8d18ff19fa05eea1c8", "XRPUSDT", "XRP-USD"}, + {"ADA/USD", "", "0x2a01deaec9e51a579277b34b122399984d0bbf57e2458a7e42fecd2829867a0d", "ADAUSDT", "ADA-USD"}, + {"DOGE/USD", "", "0xdcef50dd0a4cd2dcc17e45df1676dcb336a11a61c69df7a0299b0150c672d25c", "DOGEUSDT", "DOGE-USD"}, {"AVAX/USD", "0xFF3EEb22B5E3dE6e705b44749C2559d704923FD7", "0x93da3352f9f1d105fdfe4971cfa80e9dd777bfc5d0f683ebb6e1294b92137bb7", "AVAXUSDT", "AVAX-USD"}, {"LINK/USD", "0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c", "0x8ac0c70fff57e9aefdf5edf44b51d62c2d433653cbb2cf5cc06bb115af04d221", "LINKUSDT", "LINK-USD"}, // MATIC: Polygon migrated MATIC → POL 1:1 in Sep 2024. The diff --git a/harnesses/pm-freshness-bench/Dockerfile b/harnesses/pm-freshness-bench/Dockerfile new file mode 100644 index 000000000..d8ce6a93f --- /dev/null +++ b/harnesses/pm-freshness-bench/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd ./cmd +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/script ./cmd/script + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates && update-ca-certificates +WORKDIR /app +COPY --from=build /out/script /app/script +EXPOSE 2112 +ENTRYPOINT ["/app/script"] diff --git a/harnesses/pm-freshness-bench/README.md b/harnesses/pm-freshness-bench/README.md new file mode 100644 index 000000000..1477a7742 --- /dev/null +++ b/harnesses/pm-freshness-bench/README.md @@ -0,0 +1,84 @@ +# pm-freshness-bench + +Bench №032 — prediction market data freshness across providers. + +Subscribes to the same basket of high-volume Polymarket markets on three +data providers simultaneously, cross-correlates each trade event by +`(conditionId, outcomeId, price, size, time-window)` and measures how +many milliseconds each provider lags the canonical Polymarket CLOB WS +gateway. + +## Providers (v2) + +The bench now compares per-venue. Polymarket events flow through 3 providers; +Kalshi events flow through 2 (no Mobula Kalshi coverage). + +| Venue | Provider | Endpoint | Auth | Notes | +|---|---|---|---|---| +| Polymarket | Polymarket CLOB | `wss://ws-subscriptions-clob.polymarket.com/ws/market` | none | Canonical T0 for Polymarket | +| Polymarket | Codex / defined.fi | `wss://graph.codex.io/graphql` | scraped JWT + Webshare proxy | Firehose, branch on marketId suffix | +| Polymarket | Mobula PM | `wss://pm-api-prod-eu.mobula.io` | API key | Browser UA required | +| Kalshi | Kalshi public REST | `https://api.elections.kalshi.com/v1/social/trades` | none | Canonical T0 via `create_date` (venue clock, µs precision) | +| Kalshi | Codex / defined.fi | (same firehose) | (same auth) | Filtered to `:Kalshi` marketIds | + +## Run locally + +```bash +cp .env.example .env +# fill in keys +go build ./cmd/script +./script +``` + +## Endpoints + +- `:2112/metrics` — Prometheus scrape (hardcoded — Railway $PORT ignored) +- `:2112/logs?tail=N` — last N log lines (token-gated by `LOGS_TOKEN` when set) + +## Metrics + +All metrics now carry a `venue` label (`polymarket` | `kalshi`). The T0 +reference clock per venue is the direct venue feed (`polymarket` for +Polymarket events, `kalshi` for Kalshi events). + +- `pm_freshness_delta_ms_bucket{provider, venue, kind}` histogram — per-event + delta vs that venue's T0, kind ∈ {trade, price} +- `pm_events_total{provider, venue, kind}` counter — raw events received +- `pm_matched_total{provider, venue, kind}` counter — events successfully matched to T0 +- `pm_health{provider, venue}` gauge — 1 if (provider, venue) published at least + one event in the last 60 s, else 0 +- `pm_fetch_errors_total{provider, venue, error_type}` counter +- `pm_basket_size{venue}` gauge + +## Kalshi support + +Kalshi runs as a second venue alongside Polymarket. Codex's +prediction-trades firehose already carries Kalshi trades; we just stopped +filtering them out. The Kalshi T0 (canonical publish time) comes from the +public `/v1/social/trades` REST endpoint — the same one that powers the +trade ticker on kalshi.com homepage. No account, no KYC, no key. + +The freshness measurement uses the `create_date` field embedded in the +Kalshi response, which is the venue's own publish timestamp at microsecond +precision. The poll cadence (default 5s, tunable via +`KALSHI_POLL_INTERVAL_SEC`) only governs when correlation happens, not +the measurement itself: a Codex relay event that lands at T+100ms is +still credited with a 100ms delta even if our poller surfaces the matching +Kalshi trade up to 5 seconds later. + +CloudFront fronts the endpoint with `max-age=10s`; polling faster than +~5s just hits cache. Tested live from Paris with 200ms RTT, no geo +block, no auth header required. + +## Methodology + +Every 5 minutes the harness polls `gamma-api.polymarket.com` for the top-20 +active markets by 24h volume, then opens / updates subscriptions on all +three providers simultaneously. Trades are indexed in memory for 60 s +after first arrival; deltas are computed against the earliest receive +time for that `(conditionId, outcomeId, price, size)` signature, which +is always the Polymarket T0 if Polymarket emitted the trade at all. + +Polymarket trades that no other provider relays in the 60 s window are +counted in `pm_events_total{provider="polymarket"}` but not in any +matched counter. diff --git a/harnesses/pm-freshness-bench/cmd/script/codex.go b/harnesses/pm-freshness-bench/cmd/script/codex.go new file mode 100644 index 000000000..b4db8351d --- /dev/null +++ b/harnesses/pm-freshness-bench/cmd/script/codex.go @@ -0,0 +1,323 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "nhooyr.io/websocket" +) + +const codexWS = "wss://graph.codex.io/graphql" + +func runCodex(ctx context.Context, cfg Config, basketCh <-chan []Market, kalshiBasketCh <-chan []Market) { + if cfg.DefinedSessionCookie == "" { + appendLog("[codex] DEFINED_SESSION_COOKIE not set — skipping Codex provider") + return + } + + // Codex uses its own composite marketId format. For Polymarket events: + // :Polymarket:: + // For Kalshi events the trailing segment is `:Kalshi` and the leading + // segment is the market_ticker. We branch on the suffix to pick venue + // and which basket to check against. + + var ( + cidMu sync.RWMutex + knownPoly = map[string]bool{} + knownKalshi = map[string]bool{} + ) + updateKnownPoly := func(ms []Market) { + cidMu.Lock() + defer cidMu.Unlock() + knownPoly = map[string]bool{} + for _, m := range ms { + knownPoly[m.ConditionId] = true + } + } + updateKnownKalshi := func(ms []Market) { + cidMu.Lock() + defer cidMu.Unlock() + knownKalshi = map[string]bool{} + for _, m := range ms { + knownKalshi[m.ConditionId] = true + } + } + isKnown := func(venue, cid string) bool { + cidMu.RLock() + defer cidMu.RUnlock() + if venue == "kalshi" { + return knownKalshi[cid] + } + return knownPoly[cid] + } + + updateKnownPoly(currentBasket()) + updateKnownKalshi(currentKalshiBasket()) + + // basket update consumers — Polymarket + Kalshi basket channels are + // independent because each venue has its own refresh loop. + go func() { + for { + select { + case <-ctx.Done(): + return + case ms := <-basketCh: + updateKnownPoly(ms) + case ms := <-kalshiBasketCh: + updateKnownKalshi(ms) + } + } + }() + + backoff := 10 * time.Second + consecutiveFails := 0 + for ctx.Err() == nil { + err := codexConnect(ctx, cfg, isKnown) + if ctx.Err() != nil { + return + } + consecutiveFails++ + errType := classify(err) + // Errors are reported on the polymarket venue to keep historical + // series; Codex transport errors don't have a per-venue semantic. + fetchErrors.WithLabelValues("codex", "polymarket", errType).Inc() + appendLog("[codex] disconnected: %v — backing off %v", err, backoff) + + // Mirror the head-lag bench's recovery rules. + if errType == "auth" || errType == "rate_limit" { + InvalidateCodexJWT() + backoff = 30 * time.Second + } + if consecutiveFails >= 10 { + InvalidateCodexJWT() + backoff = 10 * time.Second + consecutiveFails = 0 + } + + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 60*time.Second { + backoff *= 2 + } + } +} + +func codexConnect(ctx context.Context, cfg Config, isKnown func(venue, cid string) bool) error { + jwt, err := GetCodexJWT(cfg.DefinedSessionCookie) + if err != nil { + return fmt.Errorf("mint: %w", err) + } + + opts := &websocket.DialOptions{ + Subprotocols: []string{"graphql-transport-ws"}, + HTTPHeader: http.Header{ + "Origin": []string{"https://www.defined.fi"}, + "User-Agent": []string{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0"}, + }, + } + conn, _, err := websocket.Dial(ctx, codexWS, opts) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close(websocket.StatusInternalError, "") + conn.SetReadLimit(8 * 1024 * 1024) + + init := map[string]interface{}{ + "type": "connection_init", + "payload": map[string]interface{}{"Authorization": "Bearer " + jwt}, + } + ib, _ := json.Marshal(init) + if err := conn.Write(ctx, websocket.MessageText, ib); err != nil { + return fmt.Errorf("init: %w", err) + } + _, ackB, err := conn.Read(ctx) + if err != nil { + return fmt.Errorf("ack read: %w", err) + } + var ack struct { + Type string `json:"type"` + } + json.Unmarshal(ackB, &ack) + if ack.Type != "connection_ack" { + return fmt.Errorf("expected connection_ack got %s", ack.Type) + } + appendLog("[codex] connection_ack") + + // Firehose subscription. We accept both Polymarket AND Kalshi trades + // client-side because Codex's input doesn't take a protocol filter on + // the subscription itself (only on the discovery query). The volume is + // manageable — observed ~33 msg/sec mixing both venues. + subQuery := `subscription Fire { onPredictionTradesCreated { marketId trades { timestamp transactionHash priceUsd amountUsd tradeType outcomeId outcomeLabel } } }` + subMsg := map[string]interface{}{ + "type": "subscribe", + "id": "firehose", + "payload": map[string]interface{}{"query": subQuery, "variables": map[string]interface{}{}}, + } + sb, _ := json.Marshal(subMsg) + if err := conn.Write(ctx, websocket.MessageText, sb); err != nil { + return fmt.Errorf("subscribe write: %w", err) + } + + pingCtx, pingCancel := context.WithCancel(ctx) + defer pingCancel() + go func() { + t := time.NewTicker(20 * time.Second) + defer t.Stop() + for { + select { + case <-pingCtx.Done(): + return + case <-t.C: + conn.Write(pingCtx, websocket.MessageText, []byte(`{"type":"ping"}`)) + } + } + }() + + for { + _, b, err := conn.Read(ctx) + if err != nil { + return err + } + var m struct { + Type string `json:"type"` + Id string `json:"id"` + Payload json.RawMessage `json:"payload"` + } + json.Unmarshal(b, &m) + if m.Type != "next" { + continue + } + var p struct { + Data struct { + OnPredictionTradesCreated struct { + MarketId string `json:"marketId"` + Trades []struct { + Timestamp int64 `json:"timestamp"` + TransactionHash string `json:"transactionHash"` + PriceUsd string `json:"priceUsd"` + AmountUsd string `json:"amountUsd"` + TradeType string `json:"tradeType"` + } `json:"trades"` + } `json:"onPredictionTradesCreated"` + } `json:"data"` + } + if err := json.Unmarshal(m.Payload, &p); err != nil { + continue + } + marketId := p.Data.OnPredictionTradesCreated.MarketId + venue, cid := codexParseMarketId(marketId) + logCodexMarketIDSample(marketId, venue, cid, isKnown(venue, cid)) + if cid == "" || venue == "" || !isKnown(venue, cid) { + continue // out-of-basket, unknown venue, or unparsable id + } + health.WithLabelValues("codex", venue).Set(1) + markAlive("codex", venue) + now := nowSec() + for _, t := range p.Data.OnPredictionTradesCreated.Trades { + eventsTotal.WithLabelValues("codex", venue, "trade").Inc() + sig := EventSig{ + Venue: venue, + ConditionId: cid, + PriceMilli: priceToMilli(t.PriceUsd), + BucketSec: bucketSec(float64(t.Timestamp)), + } + logSigSample("codex", sig) + correlator.Add(sig, Arrival{Provider: "codex", Kind: "trade", RecvUnix: now}) + } + } +} + +// codexShapesMu + codexShapesLogged track which composite marketId +// shapes we've already logged a sample for. The goal is to surface every +// distinct shape Codex's firehose can emit in the first few minutes after +// boot — particularly important for Kalshi where the suffix format wasn't +// verified live. We log each (venue, known) tuple once per unique cid +// prefix shape, capped at 50 samples total. +var ( + codexShapesMu sync.Mutex + codexShapesSeen = map[string]struct{}{} + codexShapesLogged int + codexShapesCap = 50 +) + +func logCodexMarketIDSample(marketId, venue, cid string, known bool) { + if marketId == "" { + return + } + codexShapesMu.Lock() + defer codexShapesMu.Unlock() + if codexShapesLogged >= codexShapesCap { + return + } + // Shape key = venue + known + shape(marketId). Shape collapses hex + // runs to "" and decimal runs to "" so we don't log every + // individual market. + shape := shapeMarketID(marketId) + key := fmt.Sprintf("%s|%t|%s", venue, known, shape) + if _, ok := codexShapesSeen[key]; ok { + return + } + codexShapesSeen[key] = struct{}{} + codexShapesLogged++ + appendLog("[codex-debug] marketId=%s venue=%q cid=%q known=%t (sample %d/%d, shape=%s)", + marketId, venue, cid, known, codexShapesLogged, codexShapesCap, shape) +} + +// shapeMarketID replaces hex and decimal runs with placeholders so two +// otherwise-equivalent marketIds collapse to one shape string. +func shapeMarketID(s string) string { + var b strings.Builder + i := 0 + for i < len(s) { + c := s[i] + switch { + case c == '0' && i+1 < len(s) && (s[i+1] == 'x' || s[i+1] == 'X'): + b.WriteString("") + i += 2 + for i < len(s) && isHexDigit(s[i]) { + i++ + } + case c >= '0' && c <= '9': + b.WriteString("") + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + default: + b.WriteByte(c) + i++ + } + } + return b.String() +} + +func isHexDigit(c byte) bool { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') +} + +// codexParseMarketId splits Codex's composite marketId into (venue, id). +// Polymarket events look like 0x:Polymarket:0x: +// Kalshi events look like :Kalshi +// Returns ("", "") if the format isn't recognised. +func codexParseMarketId(s string) (venue string, id string) { + idx := strings.Index(s, ":") + if idx <= 0 { + return "", "" + } + id = strings.ToLower(s[:idx]) + rest := s[idx+1:] + switch { + case strings.HasPrefix(rest, "Polymarket"): + return "polymarket", id + case strings.HasPrefix(rest, "Kalshi"): + return "kalshi", id + } + return "", "" +} diff --git a/harnesses/pm-freshness-bench/cmd/script/codex_auth.go b/harnesses/pm-freshness-bench/cmd/script/codex_auth.go new file mode 100644 index 000000000..0720a305b --- /dev/null +++ b/harnesses/pm-freshness-bench/cmd/script/codex_auth.go @@ -0,0 +1,151 @@ +package main + +// JWT mint logic vendored from +// miniapps/aggregator-latency-benchmark/cmd/script/defined_auth.go +// keeping the same Webshare-proxy + 7-day-cookie flow. Reusing the working +// production pattern instead of re-inventing — the head-lag bench has been +// minting JWTs against defined.fi this way for months. + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +type definedTokenResponse struct { + Data struct { + CreateApiTokens []struct { + Token string `json:"token"` + } `json:"createApiTokens"` + } `json:"data"` +} + +type definedTokenCache struct { + mu sync.RWMutex + token string + expiresAt time.Time + lastRefresh time.Time +} + +var codexTokenCache = &definedTokenCache{} + +func decodeJWTExpiration(token string) (time.Time, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Time{}, fmt.Errorf("invalid JWT format") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return time.Time{}, err + } + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return time.Time{}, err + } + if claims.Exp == 0 { + return time.Time{}, fmt.Errorf("no exp") + } + return time.Unix(claims.Exp, 0), nil +} + +// GetCodexJWT returns a cached short-lived JWT, minting a new one when the +// cached one is within 1h of expiry. +func GetCodexJWT(sessionCookie string) (string, error) { + codexTokenCache.mu.RLock() + if codexTokenCache.token != "" && time.Now().Before(codexTokenCache.expiresAt.Add(-1*time.Hour)) { + t := codexTokenCache.token + codexTokenCache.mu.RUnlock() + return t, nil + } + codexTokenCache.mu.RUnlock() + + codexTokenCache.mu.Lock() + defer codexTokenCache.mu.Unlock() + if codexTokenCache.token != "" && time.Now().Before(codexTokenCache.expiresAt.Add(-1*time.Hour)) { + return codexTokenCache.token, nil + } + tok, err := mintCodexJWT(sessionCookie) + if err != nil { + return "", err + } + exp, err := decodeJWTExpiration(tok) + if err != nil { + exp = time.Now().Add(24 * time.Hour) + } + codexTokenCache.token = tok + codexTokenCache.expiresAt = exp + codexTokenCache.lastRefresh = time.Now() + appendLog("[codex-auth] JWT refreshed, expires in %.1fh", time.Until(exp).Hours()) + return tok, nil +} + +func InvalidateCodexJWT() { + codexTokenCache.mu.Lock() + defer codexTokenCache.mu.Unlock() + codexTokenCache.token = "" + codexTokenCache.expiresAt = time.Time{} + appendLog("[codex-auth] JWT cache invalidated") +} + +func mintCodexJWT(sessionCookie string) (string, error) { + // CRITICAL: route through HTTP_PROXY / HTTPS_PROXY (Webshare). Direct + // Railway IPs get stuck in Vercel's bot-ban loop after a few mints. + tr := &http.Transport{DisableKeepAlives: true, Proxy: http.ProxyFromEnvironment} + client := &http.Client{Timeout: 15 * time.Second, Transport: tr} + + body := map[string]interface{}{ + "operationName": "CreateApiToken", + "query": "mutation CreateApiToken { createApiTokens(input: { count: 1 }) { token } }", + "variables": map[string]interface{}{}, + } + bb, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", "https://www.defined.fi/api", bytes.NewBuffer(bb)) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "https://www.defined.fi") + req.Header.Set("Referer", "https://www.defined.fi/") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req.Header.Set("sec-ch-ua", `"Not_A Brand";v="8", "Chromium";v="131", "Google Chrome";v="131"`) + req.Header.Set("sec-ch-ua-mobile", "?0") + req.Header.Set("sec-ch-ua-platform", `"macOS"`) + req.Header.Set("sec-fetch-dest", "empty") + req.Header.Set("sec-fetch-mode", "cors") + req.Header.Set("sec-fetch-site", "same-origin") + req.AddCookie(&http.Cookie{Name: "session", Value: sessionCookie}) + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + rb, _ := io.ReadAll(resp.Body) + if resp.StatusCode == 429 { + return "", fmt.Errorf("rate limited (429)") + } + if resp.StatusCode != 200 { + return "", fmt.Errorf("status=%d body=%s", resp.StatusCode, snippet(rb)) + } + var tr2 definedTokenResponse + if err := json.Unmarshal(rb, &tr2); err != nil { + return "", err + } + if len(tr2.Data.CreateApiTokens) == 0 { + return "", fmt.Errorf("no token in response") + } + return tr2.Data.CreateApiTokens[0].Token, nil +} + +func snippet(b []byte) string { + if len(b) > 200 { + return string(b[:200]) + } + return string(b) +} diff --git a/harnesses/pm-freshness-bench/cmd/script/config.go b/harnesses/pm-freshness-bench/cmd/script/config.go new file mode 100644 index 000000000..fb5784e46 --- /dev/null +++ b/harnesses/pm-freshness-bench/cmd/script/config.go @@ -0,0 +1,43 @@ +package main + +import ( + "os" + "strconv" + "strings" +) + +type Config struct { + DefinedSessionCookie string + MobulaApiKey string + LogsToken string + + // Kalshi T0 polls /v1/social/trades (public, unauthenticated). + // The default 5s cadence matches the upstream CloudFront max-age=10. + KalshiPollIntervalSec int + + RefreshMarketsIntervalSec int + BasketSize int +} + +func loadConfig() Config { + return Config{ + DefinedSessionCookie: strings.TrimSpace(os.Getenv("DEFINED_SESSION_COOKIE")), + MobulaApiKey: strings.TrimSpace(os.Getenv("MOBULA_API_KEY")), + LogsToken: strings.TrimSpace(os.Getenv("LOGS_TOKEN")), + KalshiPollIntervalSec: intEnv("KALSHI_POLL_INTERVAL_SEC", 5), + RefreshMarketsIntervalSec: intEnv("REFRESH_MARKETS_INTERVAL_SEC", 300), + BasketSize: intEnv("BASKET_SIZE", 20), + } +} + +func intEnv(key string, dflt int) int { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return dflt + } + n, err := strconv.Atoi(v) + if err != nil { + return dflt + } + return n +} diff --git a/harnesses/pm-freshness-bench/cmd/script/correlator.go b/harnesses/pm-freshness-bench/cmd/script/correlator.go new file mode 100644 index 000000000..d825e2651 --- /dev/null +++ b/harnesses/pm-freshness-bench/cmd/script/correlator.go @@ -0,0 +1,199 @@ +package main + +import ( + "fmt" + "math" + "strings" + "sync" + "time" +) + +// EventSig is the cross-provider signature we use to match a single trade +// across the 3 streams. After empirical sampling of live traffic we know: +// - transactionHash: not on Mobula's payload, can't use +// - outcomeId: encoding differs (Codex wraps in composite scheme) +// - amountUSD: SEMANTICS differ — Mobula appears to aggregate multiple +// fills into one event while Polymarket fires one event per fill, +// so the dollar amount on the same trade ranges 1.4× to 38× off +// +// What ALL three providers report consistently for the same market +// activity: conditionId + the 3-decimal trade price + a coarse 5s time +// bucket. That's the signature. Trade-off: two distinct trades in the +// same market at the same price within a 5-second window collapse into +// one key — acceptable because we record time-to-first-arrival per +// provider, which remains representative of the freshness even when +// collapsed. +type EventSig struct { + Venue string // "polymarket" or "kalshi" — distinct venues never match each other + ConditionId string // Polymarket conditionId OR Kalshi market_ticker, lowercased + PriceMilli int // priceUSD * 1000 rounded + BucketSec int64 // floor(trade time / 5s) — coarsens skew between providers +} + +func (s EventSig) Key() string { + v := s.Venue + if v == "" { + v = "polymarket" + } + return fmt.Sprintf("%s|%s|%d|%d", v, s.ConditionId, s.PriceMilli, s.BucketSec) +} + +// Arrival records one provider's receipt of an event keyed by EventSig. +type Arrival struct { + Provider string // "polymarket", "codex", "mobula" + Kind string // "trade" or "price" + RecvUnix float64 +} + +type Correlator struct { + mu sync.Mutex + // per-signature, per-provider earliest arrival within the retention window + byKey map[string]map[string]Arrival + // signature key -> T0 arrival time, where T0 is the direct-venue source + // (Polymarket CLOB for polymarket venue, Kalshi WS for kalshi venue). + // Used for fast delta computation when a follower reports later. + t0 map[string]float64 + // LRU eviction list (key + insertion time) + insertOrder []keyAt +} + +type keyAt struct { + Key string + At float64 +} + +const retention = 90.0 // seconds; trades that don't match within 90s are forgotten + +func NewCorrelator() *Correlator { + return &Correlator{ + byKey: map[string]map[string]Arrival{}, + t0: map[string]float64{}, + } +} + +// isT0Provider returns true when the given provider is the direct-venue +// source for the given venue (i.e. the reference clock we measure lag from). +func isT0Provider(provider, venue string) bool { + switch venue { + case "kalshi": + return provider == "kalshi" + default: + return provider == "polymarket" + } +} + +func (c *Correlator) Add(sig EventSig, a Arrival) { + c.mu.Lock() + defer c.mu.Unlock() + c.evictExpired(a.RecvUnix) + + venue := sig.Venue + if venue == "" { + venue = "polymarket" + } + + key := sig.Key() + if _, ok := c.byKey[key]; !ok { + c.byKey[key] = map[string]Arrival{} + c.insertOrder = append(c.insertOrder, keyAt{Key: key, At: a.RecvUnix}) + } + if existing, ok := c.byKey[key][a.Provider]; ok && existing.RecvUnix <= a.RecvUnix { + return + } + c.byKey[key][a.Provider] = a + + if isT0Provider(a.Provider, venue) { + c.t0[key] = a.RecvUnix + for prov, follower := range c.byKey[key] { + if isT0Provider(prov, venue) { + continue + } + observeDelta(prov, venue, follower.Kind, follower.RecvUnix-a.RecvUnix) + } + return + } + + if t0, ok := c.t0[key]; ok { + observeDelta(a.Provider, venue, a.Kind, a.RecvUnix-t0) + } +} + +func observeDelta(provider, venue, kind string, deltaSec float64) { + if deltaSec < 0 { + deltaSec = 0 + } + ms := deltaSec * 1000 + if math.IsNaN(ms) || math.IsInf(ms, 0) { + return + } + freshnessDelta.WithLabelValues(provider, venue, kind).Observe(ms) + matchedTotal.WithLabelValues(provider, venue, kind).Inc() +} + +func (c *Correlator) evictExpired(now float64) { + cutoff := now - retention + cut := 0 + for cut < len(c.insertOrder) && c.insertOrder[cut].At < cutoff { + k := c.insertOrder[cut].Key + delete(c.byKey, k) + delete(c.t0, k) + cut++ + } + if cut > 0 { + c.insertOrder = c.insertOrder[cut:] + } +} + +func priceToMilli(price string) int { + f := parseF(price) + return int(math.Round(f * 1000)) +} + +func amountToCents(s string) int64 { + return int64(math.Round(parseF(s) * 100)) +} + +func amountFloatToCents(f float64) int64 { + return int64(math.Round(f * 100)) +} + +func parseF(s string) float64 { + if s == "" { + return 0 + } + var f float64 + fmt.Sscanf(strings.TrimSpace(s), "%f", &f) + return f +} + +func bucketSec(t float64) int64 { + return int64(t / 5) +} + +func nowSec() float64 { + return float64(time.Now().UnixNano()) / 1e9 +} + +// logSigSample prints the first 5 signatures per provider so we can eyeball +// cross-provider alignment when matched_total stays at zero. Pure debug +// instrumentation, dropped from output once we trust the matching. +var ( + sigSampleMu sync.Mutex + sigSamples = map[string]int{} +) + +func logSigSample(provider string, s EventSig) { + sigSampleMu.Lock() + defer sigSampleMu.Unlock() + key := provider + "/" + s.Venue + if sigSamples[key] >= 5 { + return + } + sigSamples[key]++ + cid := s.ConditionId + if len(cid) > 14 { + cid = cid[:14] + } + appendLog("[sig %s/%s #%d] cond=%s price=%d bucket=%d", + provider, s.Venue, sigSamples[key], cid, s.PriceMilli, s.BucketSec) +} diff --git a/harnesses/pm-freshness-bench/cmd/script/kalshi.go b/harnesses/pm-freshness-bench/cmd/script/kalshi.go new file mode 100644 index 000000000..13d576f59 --- /dev/null +++ b/harnesses/pm-freshness-bench/cmd/script/kalshi.go @@ -0,0 +1,270 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "sync" + "time" +) + +// Kalshi public trade feed. The /v1/social/trades endpoint is the same one +// kalshi.com homepage trade ticker hits; it is unauthenticated, CloudFront +// fronted (max-age=10s), no geo block on read, no auth header required. +// +// Why REST and not the official WebSocket: the WS at +// external-api-ws.kalshi.com requires RSA-PSS signed headers from an +// account with US KYC, and returns 403 from non-US IPs even with valid +// auth. The social/trades JSON carries `create_date` at microsecond +// precision -- that IS the venue T0, independent of our poll cadence. +// The poller's only job is to surface trade signatures so the correlator +// can match them against Codex's Kalshi stream. +// +// Calling without `series_ticker` returns a global feed of the most +// recent ~100 trades across all series, so a single HTTP call per cycle +// covers every active market. The Kalshi basket (used by codex.go to +// filter Kalshi events on its firehose) is built dynamically from the +// tickers actually seen in those trades, which auto-targets the active +// markets without needing a separate /markets call that returns zero +// volumes on most rows. +const kalshiSocialTradesURL = "https://api.elections.kalshi.com/v1/social/trades" + +func runKalshi(ctx context.Context, cfg Config, codexKalshiCh chan<- []Market) { + every := time.Duration(cfg.KalshiPollIntervalSec) * time.Second + if every < 2*time.Second { + every = 5 * time.Second + } + + seen := newSeenCache(8192) + tickerFreq := newTickerFreq(cfg.BasketSize) + + appendLog("[kalshi] REST poller started (global feed), interval=%s", every) + tick := time.NewTicker(every) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + } + if err := pollKalshiGlobal(ctx, seen, tickerFreq, codexKalshiCh); err != nil { + fetchErrors.WithLabelValues("kalshi", "kalshi", classify(err)).Inc() + health.WithLabelValues("kalshi", "kalshi").Set(0) + appendLog("[kalshi] poll err: %v", err) + } + } +} + +// pollKalshiGlobal pages through /v1/social/trades following the +// returned cursor until either every trade in a page is already in the +// seen cache (we caught up with last cycle) or we hit the page cap. +// One call returns ~100 trades covering ~3 seconds of venue time; with +// a 5 s poll cadence two pages cover the gap with overlap, the cap of +// 5 is generous headroom for traffic spikes. +const kalshiMaxPagesPerPoll = 5 + +func pollKalshiGlobal(ctx context.Context, seen *seenCache, freq *tickerFreq, codexKalshiCh chan<- []Market) error { + cursor := "" + for page := 0; page < kalshiMaxPagesPerPoll; page++ { + newOnThisPage, nextCursor, err := fetchKalshiPage(ctx, cursor, seen, freq) + if err != nil { + return fmt.Errorf("page %d: %w", page+1, err) + } + // Stop once a whole page returns zero new trades -- means we + // crossed back into the previous poll's window. + if newOnThisPage == 0 { + break + } + if nextCursor == "" { + break + } + cursor = nextCursor + } + // Push the latest observed-tickers basket so codex.go's isKnownKalshi + // accepts the same set of markets we're polling. The channel send is + // non-blocking; codex.go re-reads currentKalshiBasket on (re)connect + // anyway, so dropping an update is safe — the next poll will retry. + basket := freq.topTickers() + basketMu.Lock() + kalshiBasket = basket + basketMu.Unlock() + basketSize.WithLabelValues("kalshi").Set(float64(len(basket))) + if codexKalshiCh != nil && len(basket) > 0 { + select { + case codexKalshiCh <- basket: + default: + } + } + return nil +} + +func fetchKalshiPage(ctx context.Context, cursor string, seen *seenCache, freq *tickerFreq) (int, string, error) { + url := kalshiSocialTradesURL + if cursor != "" { + url += "?cursor=" + cursor + } + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "openchainbench-pm-freshness-bench") + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Do(req) + if err != nil { + return 0, "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return 0, "", fmt.Errorf("status_%d", resp.StatusCode) + } + var payload struct { + Trades []struct { + TradeID string `json:"trade_id"` + Ticker string `json:"ticker"` + Price int `json:"price"` + CreateDate string `json:"create_date"` + } `json:"trades"` + Cursor string `json:"cursor"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return 0, "", err + } + newCount := 0 + for _, t := range payload.Trades { + if t.TradeID == "" || t.Ticker == "" { + continue + } + if seen.contains(t.TradeID) { + continue + } + seen.add(t.TradeID) + ts, err := time.Parse(time.RFC3339Nano, t.CreateDate) + if err != nil { + continue + } + newCount++ + eventsTotal.WithLabelValues("kalshi", "kalshi", "trade").Inc() + health.WithLabelValues("kalshi", "kalshi").Set(1) + markAlive("kalshi", "kalshi") + + tickerLower := strings.ToLower(t.Ticker) + freq.bump(tickerLower) + + sig := EventSig{ + Venue: "kalshi", + ConditionId: tickerLower, + PriceMilli: t.Price * 10, + BucketSec: bucketSec(float64(ts.UnixNano()) / 1e9), + } + logSigSample("kalshi", sig) + correlator.Add(sig, Arrival{Provider: "kalshi", Kind: "trade", RecvUnix: float64(ts.UnixNano()) / 1e9}) + } + return newCount, payload.Cursor, nil +} + +// tickerFreq tracks how often each market_ticker has appeared in the +// recent trade stream, with exponential decay so quiet markets fade. +// We expose the top N as the "active basket" -- Codex's firehose filter +// reads from this set. +type tickerFreq struct { + mu sync.Mutex + counts map[string]float64 + cap int + lastDec time.Time +} + +func newTickerFreq(cap int) *tickerFreq { + return &tickerFreq{counts: map[string]float64{}, cap: cap, lastDec: time.Now()} +} + +func (f *tickerFreq) bump(ticker string) { + f.mu.Lock() + defer f.mu.Unlock() + f.decayLocked() + f.counts[ticker]++ +} + +// decayLocked applies an exponential decay so a market that stops +// trading drops out of the top-N after a few minutes. Tuned so a market +// at "1 trade per minute" decays past a 0.1 floor in ~10 minutes. +func (f *tickerFreq) decayLocked() { + now := time.Now() + elapsed := now.Sub(f.lastDec).Seconds() + if elapsed < 30 { + return + } + f.lastDec = now + decay := 0.95 * (elapsed / 60) // ~5% per minute + if decay > 0.5 { + decay = 0.5 + } + factor := 1 - decay + for k, v := range f.counts { + nv := v * factor + if nv < 0.1 { + delete(f.counts, k) + } else { + f.counts[k] = nv + } + } +} + +func (f *tickerFreq) topTickers() []Market { + f.mu.Lock() + defer f.mu.Unlock() + f.decayLocked() + type kv struct { + ticker string + score float64 + } + all := make([]kv, 0, len(f.counts)) + for t, c := range f.counts { + all = append(all, kv{t, c}) + } + sort.Slice(all, func(i, j int) bool { return all[i].score > all[j].score }) + if len(all) > f.cap { + all = all[:f.cap] + } + out := make([]Market, len(all)) + for i, kv := range all { + out[i] = Market{Slug: kv.ticker, ConditionId: kv.ticker, Vol24h: kv.score} + } + return out +} + +// seenCache is a small FIFO set just enough to dedup trade_id across polls. +// Each poll typically returns ~100 trades and we re-fetch every 5s, so 8k +// entries covers ~7 minutes of unique IDs at peak. +type seenCache struct { + mu sync.Mutex + set map[string]struct{} + order []string + cap int +} + +func newSeenCache(cap int) *seenCache { + return &seenCache{set: make(map[string]struct{}, cap), cap: cap} +} + +func (s *seenCache) contains(k string) bool { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.set[k] + return ok +} + +func (s *seenCache) add(k string) { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.set[k]; ok { + return + } + s.set[k] = struct{}{} + s.order = append(s.order, k) + if len(s.order) > s.cap { + old := s.order[0] + s.order = s.order[1:] + delete(s.set, old) + } +} diff --git a/harnesses/pm-freshness-bench/cmd/script/log_buffer.go b/harnesses/pm-freshness-bench/cmd/script/log_buffer.go new file mode 100644 index 000000000..2fb7e3df1 --- /dev/null +++ b/harnesses/pm-freshness-bench/cmd/script/log_buffer.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "net/http" + "sync" + "time" +) + +// Ring buffer mirroring the pattern used in the aggregator-latency-benchmark +// and wallet-labels miniapps. Captures the last ~5000 log lines for the +// `/logs?tail=N` endpoint. +const ringCapacity = 5000 + +type logEntry struct { + At time.Time + Line string +} + +var ( + ringMu sync.Mutex + ring = make([]logEntry, 0, ringCapacity) +) + +func appendLog(format string, args ...interface{}) { + line := fmt.Sprintf(format, args...) + ts := time.Now().UTC().Format("15:04:05.000") + fmt.Printf("[%s] %s\n", ts, line) + ringMu.Lock() + if len(ring) >= ringCapacity { + ring = ring[1:] + } + ring = append(ring, logEntry{At: time.Now(), Line: line}) + ringMu.Unlock() +} + +func setupLogsEndpoint(mux *http.ServeMux, token string) { + mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { + if token != "" && r.Header.Get("X-Logs-Token") != token { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 200 + if q := r.URL.Query().Get("tail"); q != "" { + fmt.Sscanf(q, "%d", &tail) + } + ringMu.Lock() + defer ringMu.Unlock() + start := 0 + if len(ring) > tail { + start = len(ring) - tail + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, e := range ring[start:] { + fmt.Fprintf(w, "[%s] %s\n", e.At.UTC().Format("15:04:05.000"), e.Line) + } + }) +} diff --git a/harnesses/pm-freshness-bench/cmd/script/main.go b/harnesses/pm-freshness-bench/cmd/script/main.go new file mode 100644 index 000000000..75ce8e63a --- /dev/null +++ b/harnesses/pm-freshness-bench/cmd/script/main.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" +) + +var correlator = NewCorrelator() + +func main() { + cfg := loadConfig() + appendLog("[boot] pm-freshness-bench starting (basket=%d, refresh=%ds)", cfg.BasketSize, cfg.RefreshMarketsIntervalSec) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // graceful shutdown + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + appendLog("[boot] shutdown signal received") + cancel() + }() + + // fan-out: every Polymarket basket refresh broadcasts on these per-consumer channels. + polyCh := make(chan []Market, 4) + codexPolyCh := make(chan []Market, 4) + mobulaCh := make(chan []Market, 4) + onChange := func(ms []Market) { + for _, ch := range []chan []Market{polyCh, codexPolyCh, mobulaCh} { + select { + case ch <- ms: + default: + // channel full — provider will pick up the change on the next refresh + } + } + } + + // Kalshi has its own basket which is now built dynamically by the + // Kalshi T0 client itself from the global /v1/social/trades feed. The + // /trade-api/v2/markets endpoint returns zero volume on every row so + // the previous markets-based refresh produced a basket of random + // inactive series; the observation-based basket auto-targets the + // active markets instead. + codexKalshiCh := make(chan []Market, 4) + + // health gauges decay: any (provider, venue) pair that hasn't published in 60s drops to 0. + go healthDecayLoop(ctx) + + var wg sync.WaitGroup + wg.Add(5) + go func() { + defer wg.Done() + refreshLoop(ctx, time.Duration(cfg.RefreshMarketsIntervalSec)*time.Second, cfg.BasketSize, onChange) + }() + go func() { defer wg.Done(); runPolymarket(ctx, polyCh) }() + go func() { defer wg.Done(); runCodex(ctx, cfg, codexPolyCh, codexKalshiCh) }() + go func() { defer wg.Done(); runMobula(ctx, cfg, mobulaCh) }() + go func() { defer wg.Done(); runKalshi(ctx, cfg, codexKalshiCh) }() + + // :2112 hardcoded per the OCB convention — Railway $PORT is intentionally ignored + // so the shared Prom can scrape every harness on the same well-known port. + go func() { + if err := startMetricsServer(":2112", cfg.LogsToken); err != nil { + appendLog("[metrics] server err: %v", err) + } + }() + + wg.Wait() +} + +// lastEventAt is updated by every WS client when it observes a real event, +// keyed by (provider, venue). The decay loop reads it and drops the +// corresponding health gauge to 0 when nothing has arrived in the last 60s. +type provVenue struct{ provider, venue string } + +var ( + lastEventMu sync.Mutex + lastEventAt = map[provVenue]time.Time{} +) + +// healthPairs is the static list of (provider, venue) tuples we track. +// Polymarket venue: 3 providers (polymarket T0, codex follower, mobula follower). +// Kalshi venue: 2 providers (kalshi T0, codex follower). +var healthPairs = []provVenue{ + {"polymarket", "polymarket"}, + {"codex", "polymarket"}, + {"mobula", "polymarket"}, + {"kalshi", "kalshi"}, + {"codex", "kalshi"}, +} + +func markAlive(provider, venue string) { + lastEventMu.Lock() + lastEventAt[provVenue{provider, venue}] = time.Now() + lastEventMu.Unlock() +} + +func healthDecayLoop(ctx context.Context) { + t := time.NewTicker(10 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + lastEventMu.Lock() + for _, pv := range healthPairs { + last, ok := lastEventAt[pv] + if !ok || time.Since(last) > 60*time.Second { + health.WithLabelValues(pv.provider, pv.venue).Set(0) + } + } + lastEventMu.Unlock() + } + } +} + +func classify(err error) string { + if err == nil { + return "none" + } + s := strings.ToLower(err.Error()) + switch { + case strings.Contains(s, "429") || strings.Contains(s, "rate limited"): + return "rate_limit" + case strings.Contains(s, "401") || strings.Contains(s, "403") || strings.Contains(s, "4403") || strings.Contains(s, "auth"): + return "auth" + case strings.Contains(s, "timeout"): + return "timeout" + case strings.Contains(s, "eof") || strings.Contains(s, "reset"): + return "conn_drop" + default: + return "other" + } +} diff --git a/harnesses/pm-freshness-bench/cmd/script/markets.go b/harnesses/pm-freshness-bench/cmd/script/markets.go new file mode 100644 index 000000000..2fd4d7997 --- /dev/null +++ b/harnesses/pm-freshness-bench/cmd/script/markets.go @@ -0,0 +1,220 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "sync" + "time" +) + +// A market we subscribe to across every provider. Each Polymarket market +// has a conditionId (0x-prefixed hex) shared by both outcomes, and a pair +// of clobTokenIds (decimal strings) — one per Yes/No outcome. +// For Kalshi the ConditionId field holds the market_ticker and ClobTokenIds +// is empty — the rest of the pipeline only needs ConditionId for matching. +type Market struct { + Slug string + ConditionId string // Polymarket conditionId OR Kalshi market_ticker (lowercased) + ClobTokenIds []string // 2 ids, the Polymarket WS `asset_id` per outcome (Polymarket only) + Vol24h float64 +} + +var ( + basketMu sync.RWMutex + basket []Market // Polymarket basket + kalshiBasket []Market +) + +// currentBasket returns a snapshot of the currently-subscribed Polymarket +// markets. Callers should treat the slice as read-only. +func currentBasket() []Market { + basketMu.RLock() + defer basketMu.RUnlock() + out := make([]Market, len(basket)) + copy(out, basket) + return out +} + +// currentKalshiBasket returns a snapshot of the currently-subscribed Kalshi +// markets. Same read-only contract as currentBasket. +func currentKalshiBasket() []Market { + basketMu.RLock() + defer basketMu.RUnlock() + out := make([]Market, len(kalshiBasket)) + copy(out, kalshiBasket) + return out +} + +// refreshLoop polls gamma-api on an interval and updates the shared basket. +// Connections are not torn down here — providers re-subscribe diff-style +// on every refresh (see their respective files). +func refreshLoop(ctx context.Context, every time.Duration, size int, onChange func([]Market)) { + tick := time.NewTicker(every) + defer tick.Stop() + if err := refreshBasket(ctx, size, onChange); err != nil { + appendLog("[markets] initial refresh err: %v", err) + } + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + if err := refreshBasket(ctx, size, onChange); err != nil { + appendLog("[markets] refresh err: %v", err) + } + } + } +} + +func refreshBasket(ctx context.Context, size int, onChange func([]Market)) error { + url := fmt.Sprintf("https://gamma-api.polymarket.com/markets?active=true&closed=false&order=volume24hr&ascending=false&limit=%d", size) + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("gamma-api status=%d", resp.StatusCode) + } + var raw []struct { + Slug string `json:"slug"` + ConditionId string `json:"conditionId"` + ClobTokenIds string `json:"clobTokenIds"` // JSON-encoded array of strings + Volume24hr float64 `json:"volume24hr"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return err + } + out := make([]Market, 0, len(raw)) + for _, r := range raw { + if r.ConditionId == "" || r.ClobTokenIds == "" { + continue + } + var ids []string + if err := json.Unmarshal([]byte(r.ClobTokenIds), &ids); err != nil { + continue + } + if len(ids) < 2 { + continue + } + out = append(out, Market{ + Slug: r.Slug, + ConditionId: strings.ToLower(r.ConditionId), + ClobTokenIds: ids, + Vol24h: r.Volume24hr, + }) + } + + basketMu.Lock() + basket = out + basketMu.Unlock() + basketSize.WithLabelValues("polymarket").Set(float64(len(out))) + appendLog("[markets] refreshed basket: %d markets, top vol=$%.0fk", len(out), firstVol(out)/1000) + if onChange != nil { + onChange(out) + } + return nil +} + +func firstVol(ms []Market) float64 { + if len(ms) == 0 { + return 0 + } + return ms[0].Vol24h +} + +// refreshKalshiLoop mirrors refreshLoop for Kalshi. Kalshi's public markets +// endpoint is a separate host so we drive it on its own goroutine. +func refreshKalshiLoop(ctx context.Context, every time.Duration, size int, onChange func([]Market)) { + tick := time.NewTicker(every) + defer tick.Stop() + if err := refreshKalshiBasket(ctx, size, onChange); err != nil { + appendLog("[kalshi-markets] initial refresh err: %v", err) + } + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + if err := refreshKalshiBasket(ctx, size, onChange); err != nil { + appendLog("[kalshi-markets] refresh err: %v", err) + } + } + } +} + +func refreshKalshiBasket(ctx context.Context, size int, onChange func([]Market)) error { + // Kalshi's elections host is the public unauthenticated read endpoint. + // We pull a wider page than `size` because the API doesn't expose a + // sort-by-volume parameter — we rank client-side and trim. + url := "https://api.elections.kalshi.com/trade-api/v2/markets?status=open&limit=200" + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("kalshi markets status=%d", resp.StatusCode) + } + var raw struct { + Markets []struct { + Ticker string `json:"ticker"` + Volume24h float64 `json:"volume_24h"` + Volume float64 `json:"volume"` + LastPrice float64 `json:"last_price"` + } `json:"markets"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return err + } + // Rank by 24h volume when available, fall back to all-time volume. + type scored struct { + Market + score float64 + } + scoredList := make([]scored, 0, len(raw.Markets)) + for _, r := range raw.Markets { + if r.Ticker == "" { + continue + } + s := r.Volume24h + if s == 0 { + s = r.Volume + } + scoredList = append(scoredList, scored{ + Market: Market{ + Slug: r.Ticker, + ConditionId: strings.ToLower(r.Ticker), + Vol24h: r.Volume24h, + }, + score: s, + }) + } + sort.Slice(scoredList, func(i, j int) bool { return scoredList[i].score > scoredList[j].score }) + if len(scoredList) > size { + scoredList = scoredList[:size] + } + out := make([]Market, len(scoredList)) + for i, s := range scoredList { + out[i] = s.Market + } + + basketMu.Lock() + kalshiBasket = out + basketMu.Unlock() + basketSize.WithLabelValues("kalshi").Set(float64(len(out))) + appendLog("[kalshi-markets] refreshed basket: %d markets, top vol24h=$%.0fk", len(out), firstVol(out)/1000) + if onChange != nil { + onChange(out) + } + return nil +} diff --git a/harnesses/pm-freshness-bench/cmd/script/metrics.go b/harnesses/pm-freshness-bench/cmd/script/metrics.go new file mode 100644 index 000000000..ce9090f49 --- /dev/null +++ b/harnesses/pm-freshness-bench/cmd/script/metrics.go @@ -0,0 +1,59 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var commonLabels = prometheus.Labels{"benchmark": "pm-freshness"} + +var ( + // Freshness delta vs Polymarket T0, in ms. Buckets sized for sub-second + // to ~30s lag (Codex p50 is around 4s). + freshnessDelta = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "pm_freshness_delta_ms", + Help: "Per-event delta in ms between provider arrival and venue T0 arrival, matched by (conditionId, price, time-bucket, venue).", + Buckets: []float64{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000}, + ConstLabels: commonLabels, + }, []string{"provider", "venue", "kind"}) + + eventsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "pm_events_total", + Help: "Raw events received per provider/venue/kind.", + ConstLabels: commonLabels, + }, []string{"provider", "venue", "kind"}) + + matchedTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "pm_matched_total", + Help: "Provider events that matched a venue T0 event by signature.", + ConstLabels: commonLabels, + }, []string{"provider", "venue", "kind"}) + + fetchErrors = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "pm_fetch_errors_total", + Help: "Provider errors (connection, auth, parse).", + ConstLabels: commonLabels, + }, []string{"provider", "venue", "error_type"}) + + health = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "pm_health", + Help: "1 if the (provider, venue) published at least one event in the last 60s.", + ConstLabels: commonLabels, + }, []string{"provider", "venue"}) + + basketSize = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "pm_basket_size", + Help: "Number of markets currently subscribed per venue.", + ConstLabels: commonLabels, + }, []string{"venue"}) +) + +func startMetricsServer(addr, logsToken string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + setupLogsEndpoint(mux, logsToken) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/pm-freshness-bench/cmd/script/mobula.go b/harnesses/pm-freshness-bench/cmd/script/mobula.go new file mode 100644 index 000000000..65a73c5ce --- /dev/null +++ b/harnesses/pm-freshness-bench/cmd/script/mobula.go @@ -0,0 +1,198 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "nhooyr.io/websocket" +) + +const mobulaWS = "wss://pm-api-prod-eu.mobula.io" + +// Cloudflare on the Mobula PM gateway blocks the default Go-http-client/1.1 +// User-Agent silently — the subscribe ack comes back but no data frame +// ever fires. Spoofing a normal browser UA on the WS upgrade unblocks it. +const mobulaUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + +func runMobula(ctx context.Context, cfg Config, basketCh <-chan []Market) { + if cfg.MobulaApiKey == "" { + appendLog("[mobula] MOBULA_API_KEY not set — skipping Mobula provider") + return + } + + backoff := 5 * time.Second + for ctx.Err() == nil { + err := mobulaConnect(ctx, cfg.MobulaApiKey, basketCh) + if ctx.Err() != nil { + return + } + appendLog("[mobula] disconnected: %v — backing off %v", err, backoff) + fetchErrors.WithLabelValues("mobula", "polymarket", classify(err)).Inc() + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 60*time.Second { + backoff *= 2 + } + } +} + +func mobulaConnect(ctx context.Context, apiKey string, basketCh <-chan []Market) error { + opts := &websocket.DialOptions{ + HTTPHeader: http.Header{ + "User-Agent": []string{mobulaUA}, + "Origin": []string{"https://mobula.io"}, + }, + } + conn, _, err := websocket.Dial(ctx, mobulaWS, opts) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close(websocket.StatusInternalError, "") + conn.SetReadLimit(8 * 1024 * 1024) + + var ( + subMu sync.Mutex + subscribed = map[string]bool{} // conditionId + ) + subscribeMissing := func(ms []Market) { + subMu.Lock() + defer subMu.Unlock() + for _, m := range ms { + if subscribed[m.ConditionId] { + continue + } + subscribed[m.ConditionId] = true + for _, channel := range []string{"pm-market-trade", "pm-market-price"} { + sub := map[string]interface{}{ + "event": channel, + "data": map[string]interface{}{ + "platform": "polymarket", + "marketId": m.ConditionId, + "authorization": apiKey, + }, + } + sb, _ := json.Marshal(sub) + if err := conn.Write(ctx, websocket.MessageText, sb); err != nil { + appendLog("[mobula] subscribe err: %v", err) + return + } + // pace the writes so Mobula doesn't drop them + time.Sleep(40 * time.Millisecond) + } + } + appendLog("[mobula] subscribed total=%d markets (trade+price)", len(subscribed)) + } + + if initial := currentBasket(); len(initial) > 0 { + subscribeMissing(initial) + } + go func() { + for { + select { + case <-ctx.Done(): + return + case ms := <-basketCh: + subscribeMissing(ms) + } + } + }() + + for { + _, b, err := conn.Read(ctx) + if err != nil { + return err + } + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + continue + } + evt, _ := m["event"].(string) + switch evt { + case "subscribed": + continue + case "error": + s := string(b) + if len(s) > 200 { + s = s[:200] + } + fetchErrors.WithLabelValues("mobula", "polymarket", "server_error").Inc() + appendLog("[mobula] error frame: %s", s) + continue + case "pm-market-trade": + handleMobulaTrade(m) + case "pm-market-price": + handleMobulaPrice(m) + } + } +} + +// Mobula's pm-market-trade payload is a flat object (no nested `data` on +// outbound), e.g. +// {"event":"pm-market-trade","marketId":"0xabc...","outcomeId":"60526...", +// "type":"buy","priceUSD":0.7,"sizeToken":12.34,"amountUsd":"8.64","date":1780...} +// Mobula's pm-market-price is similar but carries priceUSD/bestBid/bestAsk +// without size — we still record it as a "price" arrival for correlation +// against Polymarket `price_change`. +func handleMobulaTrade(m map[string]interface{}) { + eventsTotal.WithLabelValues("mobula", "polymarket", "trade").Inc() + health.WithLabelValues("mobula", "polymarket").Set(1) + markAlive("mobula", "polymarket") + + mid, _ := m["marketId"].(string) + cid := strings.ToLower(mid) + if cid == "" { + return + } + priceUSD, _ := m["priceUSD"].(float64) + ts := readMs(m, "date", "timestamp") + now := nowSec() + sig := EventSig{ + Venue: "polymarket", + ConditionId: cid, + PriceMilli: int(priceUSD*1000 + 0.5), + BucketSec: bucketSec(ts / 1000.0), + } + logSigSample("mobula", sig) + correlator.Add(sig, Arrival{Provider: "mobula", Kind: "trade", RecvUnix: now}) +} + +func handleMobulaPrice(m map[string]interface{}) { + eventsTotal.WithLabelValues("mobula", "polymarket", "price").Inc() + health.WithLabelValues("mobula", "polymarket").Set(1) + markAlive("mobula", "polymarket") + + mid, _ := m["marketId"].(string) + cid := strings.ToLower(mid) + if cid == "" { + return + } + priceUSD, _ := m["priceUSD"].(float64) + ts := readMs(m, "timestamp", "date") + now := nowSec() + correlator.Add(EventSig{ + Venue: "polymarket", + ConditionId: cid, + PriceMilli: int(priceUSD*1000 + 0.5), + BucketSec: bucketSec(ts / 1000.0), + }, Arrival{Provider: "mobula", Kind: "price", RecvUnix: now}) +} + +func readMs(m map[string]interface{}, keys ...string) float64 { + for _, k := range keys { + if v, ok := m[k].(float64); ok { + return v + } + if s, ok := m[k].(string); ok { + return parseF(s) + } + } + return 0 +} diff --git a/harnesses/pm-freshness-bench/cmd/script/polymarket.go b/harnesses/pm-freshness-bench/cmd/script/polymarket.go new file mode 100644 index 000000000..16a009533 --- /dev/null +++ b/harnesses/pm-freshness-bench/cmd/script/polymarket.go @@ -0,0 +1,216 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "nhooyr.io/websocket" +) + +const polymarketWS = "wss://ws-subscriptions-clob.polymarket.com/ws/market" + +func runPolymarket(ctx context.Context, basketCh <-chan []Market) { + // Maps clobTokenId → conditionId, refreshed from each basket update. + // Polymarket WS keys events by `asset_id` (= clobTokenId). The correlator + // keys by conditionId, so we need this mapping in O(1) on the hot path. + var ( + idMu sync.RWMutex + assetToCID = map[string]string{} + ) + + updateMap := func(ms []Market) { + idMu.Lock() + defer idMu.Unlock() + assetToCID = map[string]string{} + for _, m := range ms { + for _, aid := range m.ClobTokenIds { + assetToCID[aid] = strings.ToLower(m.ConditionId) + } + } + } + + lookupCID := func(assetId string) string { + idMu.RLock() + defer idMu.RUnlock() + return assetToCID[assetId] + } + + // Reconnect loop. On every connection we read the current basket and + // resubscribe with the full asset list. + backoff := 5 * time.Second + for ctx.Err() == nil { + err := dialAndStream(ctx, basketCh, updateMap, lookupCID) + if ctx.Err() != nil { + return + } + appendLog("[poly] connection ended: %v — reconnecting in %v", err, backoff) + fetchErrors.WithLabelValues("polymarket", "polymarket", classify(err)).Inc() + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + } +} + +func dialAndStream(ctx context.Context, basketCh <-chan []Market, onBasket func([]Market), lookupCID func(string) string) error { + conn, _, err := websocket.Dial(ctx, polymarketWS, nil) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close(websocket.StatusInternalError, "") + conn.SetReadLimit(8 * 1024 * 1024) + + subscribed := map[string]bool{} // assetId -> true + + subscribeMissing := func(ms []Market) { + assetIds := []string{} + for _, m := range ms { + for _, aid := range m.ClobTokenIds { + if !subscribed[aid] { + assetIds = append(assetIds, aid) + subscribed[aid] = true + } + } + } + if len(assetIds) == 0 { + return + } + sub := map[string]interface{}{ + "assets_ids": assetIds, + "type": "market", + "custom_feature_enabled": true, + } + sb, _ := json.Marshal(sub) + if err := conn.Write(ctx, websocket.MessageText, sb); err != nil { + appendLog("[poly] subscribe write err: %v", err) + return + } + appendLog("[poly] subscribed +%d assets (total=%d)", len(assetIds), len(subscribed)) + } + + // initial basket + initial := currentBasket() + if len(initial) > 0 { + onBasket(initial) + subscribeMissing(initial) + } + + // ping loop (server expects literal text "PING" every 10s) + pingCtx, pingCancel := context.WithCancel(ctx) + defer pingCancel() + go func() { + t := time.NewTicker(10 * time.Second) + defer t.Stop() + for { + select { + case <-pingCtx.Done(): + return + case <-t.C: + if err := conn.Write(pingCtx, websocket.MessageText, []byte("PING")); err != nil { + return + } + } + } + }() + + // basket-update goroutine — react to new markets coming in + go func() { + for { + select { + case <-pingCtx.Done(): + return + case ms := <-basketCh: + onBasket(ms) + subscribeMissing(ms) + } + } + }() + + for { + _, b, err := conn.Read(ctx) + if err != nil { + return err + } + if bytes.Equal(b, []byte("PONG")) { + continue + } + t := bytes.TrimSpace(b) + var arr []map[string]interface{} + if len(t) > 0 && t[0] == '[' { + json.Unmarshal(b, &arr) + } else { + var one map[string]interface{} + if json.Unmarshal(b, &one) == nil { + arr = []map[string]interface{}{one} + } + } + for _, ev := range arr { + eventType, _ := ev["event_type"].(string) + tsMs, _ := parseTimestampMsField(ev["timestamp"]) + assetId, _ := ev["asset_id"].(string) + cid := lookupCID(assetId) + if cid == "" { + continue + } + now := nowSec() + switch eventType { + case "last_trade_price": + eventsTotal.WithLabelValues("polymarket", "polymarket", "trade").Inc() + price, _ := ev["price"].(string) + health.WithLabelValues("polymarket", "polymarket").Set(1) + markAlive("polymarket", "polymarket") + sig := EventSig{ + Venue: "polymarket", + ConditionId: cid, + PriceMilli: priceToMilli(price), + BucketSec: bucketSec(tsMs / 1000.0), + } + logSigSample("polymarket", sig) + correlator.Add(sig, Arrival{Provider: "polymarket", Kind: "trade", RecvUnix: now}) + case "price_change": + eventsTotal.WithLabelValues("polymarket", "polymarket", "price").Inc() + health.WithLabelValues("polymarket", "polymarket").Set(1) + markAlive("polymarket", "polymarket") + if changes, ok := ev["price_changes"].([]interface{}); ok { + for _, ch := range changes { + chm, _ := ch.(map[string]interface{}) + if chm == nil { + continue + } + price, _ := chm["price"].(string) + correlator.Add(EventSig{ + Venue: "polymarket", + ConditionId: cid, + PriceMilli: priceToMilli(price), + BucketSec: bucketSec(tsMs / 1000.0), + }, Arrival{Provider: "polymarket", Kind: "price", RecvUnix: now}) + } + } + } + } + } +} + +// Polymarket emits `timestamp` as a string of ms-since-epoch. Codex emits +// it as an int seconds-since-epoch. Mobula emits it as a number of ms. +// Normalize to ms float here. +func parseTimestampMsField(v interface{}) (float64, error) { + switch t := v.(type) { + case string: + return parseF(t), nil + case float64: + return t, nil + case int64: + return float64(t), nil + } + return 0, fmt.Errorf("unknown timestamp type") +} diff --git a/harnesses/pm-freshness-bench/go.mod b/harnesses/pm-freshness-bench/go.mod new file mode 100644 index 000000000..b2c2cec0c --- /dev/null +++ b/harnesses/pm-freshness-bench/go.mod @@ -0,0 +1,18 @@ +module pm-freshness-bench + +go 1.22 + +require ( + github.com/prometheus/client_golang v1.19.1 + nhooyr.io/websocket v1.8.11 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + golang.org/x/sys v0.17.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect +) diff --git a/harnesses/pm-freshness-bench/go.sum b/harnesses/pm-freshness-bench/go.sum new file mode 100644 index 000000000..0874f900e --- /dev/null +++ b/harnesses/pm-freshness-bench/go.sum @@ -0,0 +1,22 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= +nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/harnesses/pm-rate-limits/Dockerfile b/harnesses/pm-rate-limits/Dockerfile new file mode 100644 index 000000000..63108cfc1 --- /dev/null +++ b/harnesses/pm-rate-limits/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/pm-rate-limits/README.md b/harnesses/pm-rate-limits/README.md new file mode 100644 index 000000000..9171da527 --- /dev/null +++ b/harnesses/pm-rate-limits/README.md @@ -0,0 +1,65 @@ +# pm-rate-limits harness + +Probes the public developer APIs of 5 prediction-market venues (Polymarket, +Kalshi, Limitless, Manifold, Myriad) for latency and throttle behaviour, and +runs a daily rate-limit ramp ("crash test"). Feeds the OpenChainBench +`pm-rate-limits` benchmark. Prometheus metrics on `:2112` (OCB convention, +Railway `$PORT` ignored). + +## What it measures + +- **Warm latency** per (venue x class): `book`, `price`, `list`. Keep-alive + pool, full round-trip + TTFB, per-sample CDN cache label + (cf-cache-status / x-cache / age). +- **Cold connect**: 1/min per venue with keep-alives disabled, TCP+TLS + handshake recorded separately. +- **Book staleness**: server-reported data age. Only Polymarket + (book `timestamp`) and Manifold (`lastUpdatedTime`) expose one. +- **Public WebSocket** (Polymarket only): connect-to-snapshot, update + inter-arrival, disconnects. Kalshi WS requires auth, the others have no + comparable public WS. +- **Daily ramp**: 60s tiers at N requests per 10s against the book endpoint, + one run per venue per day, regions on disjoint UTC hours. Headline metric is + `pmapi_ramp_added_latency_seconds` = p50(tier) - p50(warm baseline, last + hour). Venues that queue under load show added latency without ever + returning 429. + +## Fairness rules baked in + +- Pinned market per venue: most liquid, near-the-money, expiry >24h, re-pinned + daily at 00:00 UTC and immediately on `probe_invalid`. +- A stale pin (e.g. Limitless serves a CDN-cached 400 for 4h once a market + expires) is classified `probe_invalid`, never as a venue error. +- Latency histograms record successful requests only; failures land in + `pmapi_requests_total{outcome}`. +- Ramp clamps: Manifold 15/30/60 per 10s (500 req/min/IP documented), Limitless + 10/20/40 (limits undocumented), Kalshi stops at the first 429 (documented + token bucket), Myriad excluded (keyless 30 req/10s budget). Global abort when + throttled+5xx exceed 1% of any 10s window. +- Identifying User-Agent on every request: + `OpenChainBench/1.0 (+https://openchainbench.com/methodology; contact@mobula.io)`. +- Only latency measurements are published, never venue order book or price + data (Kalshi data redistribution terms, applied to all venues). + +## Endpoints probed + +| Venue | book | price | list | +|---|---|---|---| +| Polymarket | clob `/book?token_id=` | clob `/midpoint?token_id=` | gamma `/markets?order=volume24hr` | +| Kalshi | `/markets/{ticker}/orderbook` | `/markets/{ticker}` | `/markets?status=open` (CloudFront, max-age=15) | +| Limitless | `/markets/{slug}/orderbook` | `/markets/{slug}` | `/markets/active` | +| Manifold | `/v0/bets?contractId=` | `/v0/market/{id}` | `/v0/markets` (max-age=5 + swr=10) | +| Myriad | none (AMM, no feed) | `/markets/{slug}` | `/markets?state=open` | + +## Run + +```bash +cp .env.example .env +go run ./cmd/script +curl -s localhost:2112/metrics | grep pmapi_ +``` + +Deploy: one Railway service per region (us-east / eu-west / sgp) with `REGION` +set; scraped by the shared OCB Prometheus at `.railway.internal:2112`. + +Debug: `GET :2112/logs?tail=500` with `X-Logs-Token: $LOGS_TOKEN`. diff --git a/harnesses/pm-rate-limits/cmd/script/codex_auth.go b/harnesses/pm-rate-limits/cmd/script/codex_auth.go new file mode 100644 index 000000000..b06ed1eb0 --- /dev/null +++ b/harnesses/pm-rate-limits/cmd/script/codex_auth.go @@ -0,0 +1,152 @@ +package main + +// JWT mint logic vendored from +// miniapps/aggregator-latency-benchmark/cmd/script/defined_auth.go +// keeping the same Webshare-proxy + 7-day-cookie flow. Reusing the working +// production pattern instead of re-inventing — the head-lag bench has been +// minting JWTs against defined.fi this way for months. + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "sync" + "time" +) + +type definedTokenResponse struct { + Data struct { + CreateApiTokens []struct { + Token string `json:"token"` + } `json:"createApiTokens"` + } `json:"data"` +} + +type definedTokenCache struct { + mu sync.RWMutex + token string + expiresAt time.Time + lastRefresh time.Time +} + +var codexTokenCache = &definedTokenCache{} + +func decodeJWTExpiration(token string) (time.Time, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Time{}, fmt.Errorf("invalid JWT format") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return time.Time{}, err + } + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return time.Time{}, err + } + if claims.Exp == 0 { + return time.Time{}, fmt.Errorf("no exp") + } + return time.Unix(claims.Exp, 0), nil +} + +// GetCodexJWT returns a cached short-lived JWT, minting a new one when the +// cached one is within 1h of expiry. +func GetCodexJWT(sessionCookie string) (string, error) { + codexTokenCache.mu.RLock() + if codexTokenCache.token != "" && time.Now().Before(codexTokenCache.expiresAt.Add(-1*time.Hour)) { + t := codexTokenCache.token + codexTokenCache.mu.RUnlock() + return t, nil + } + codexTokenCache.mu.RUnlock() + + codexTokenCache.mu.Lock() + defer codexTokenCache.mu.Unlock() + if codexTokenCache.token != "" && time.Now().Before(codexTokenCache.expiresAt.Add(-1*time.Hour)) { + return codexTokenCache.token, nil + } + tok, err := mintCodexJWT(sessionCookie) + if err != nil { + return "", err + } + exp, err := decodeJWTExpiration(tok) + if err != nil { + exp = time.Now().Add(24 * time.Hour) + } + codexTokenCache.token = tok + codexTokenCache.expiresAt = exp + codexTokenCache.lastRefresh = time.Now() + log.Printf("[codex-auth] JWT refreshed, expires in %.1fh", time.Until(exp).Hours()) + return tok, nil +} + +func InvalidateCodexJWT() { + codexTokenCache.mu.Lock() + defer codexTokenCache.mu.Unlock() + codexTokenCache.token = "" + codexTokenCache.expiresAt = time.Time{} + log.Printf("[codex-auth] JWT cache invalidated") +} + +func mintCodexJWT(sessionCookie string) (string, error) { + // CRITICAL: route through HTTP_PROXY / HTTPS_PROXY (Webshare). Direct + // Railway IPs get stuck in Vercel's bot-ban loop after a few mints. + tr := &http.Transport{DisableKeepAlives: true, Proxy: http.ProxyFromEnvironment} + client := &http.Client{Timeout: 15 * time.Second, Transport: tr} + + body := map[string]interface{}{ + "operationName": "CreateApiToken", + "query": "mutation CreateApiToken { createApiTokens(input: { count: 1 }) { token } }", + "variables": map[string]interface{}{}, + } + bb, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", "https://www.defined.fi/api", bytes.NewBuffer(bb)) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "https://www.defined.fi") + req.Header.Set("Referer", "https://www.defined.fi/") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + req.Header.Set("sec-ch-ua", `"Not_A Brand";v="8", "Chromium";v="131", "Google Chrome";v="131"`) + req.Header.Set("sec-ch-ua-mobile", "?0") + req.Header.Set("sec-ch-ua-platform", `"macOS"`) + req.Header.Set("sec-fetch-dest", "empty") + req.Header.Set("sec-fetch-mode", "cors") + req.Header.Set("sec-fetch-site", "same-origin") + req.AddCookie(&http.Cookie{Name: "session", Value: sessionCookie}) + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + rb, _ := io.ReadAll(resp.Body) + if resp.StatusCode == 429 { + return "", fmt.Errorf("rate limited (429)") + } + if resp.StatusCode != 200 { + return "", fmt.Errorf("status=%d body=%s", resp.StatusCode, snippet(rb)) + } + var tr2 definedTokenResponse + if err := json.Unmarshal(rb, &tr2); err != nil { + return "", err + } + if len(tr2.Data.CreateApiTokens) == 0 { + return "", fmt.Errorf("no token in response") + } + return tr2.Data.CreateApiTokens[0].Token, nil +} + +func snippet(b []byte) string { + if len(b) > 200 { + return string(b[:200]) + } + return string(b) +} diff --git a/harnesses/pm-rate-limits/cmd/script/config.go b/harnesses/pm-rate-limits/cmd/script/config.go new file mode 100644 index 000000000..484951c3a --- /dev/null +++ b/harnesses/pm-rate-limits/cmd/script/config.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// userAgent identifies every probe per the OCB methodology page. Venues can +// contact us or block the UA selectively instead of banning a bare Go client. +const userAgent = "OpenChainBench/1.0 (+https://openchainbench.com/methodology; contact@mobula.io)" + +// Config groups the env-driven knobs the harness needs at startup. Direct +// venue probes don't need any of these; the aggregator goroutines stay +// idle when the corresponding key is empty. +type Config struct { + MobulaAPIKey string // Authorization: on api.mobula.io + PredexonAPIKey string // x-api-key on api.predexon.com + DefinedSessionCookie string // 7-day cookie used to mint Codex JWT +} + +func loadConfig() Config { + cfg := Config{ + MobulaAPIKey: strings.TrimSpace(os.Getenv("MOBULA_API_KEY")), + PredexonAPIKey: strings.TrimSpace(os.Getenv("PREDEXON_API_KEY")), + DefinedSessionCookie: strings.TrimSpace(os.Getenv("DEFINED_SESSION_COOKIE")), + } + fmt.Printf("[providers] mobula=%v predexon=%v codex=%v\n", + cfg.MobulaAPIKey != "", cfg.PredexonAPIKey != "", cfg.DefinedSessionCookie != "") + return cfg +} + +func envDefault(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +// currentRegion is stamped onto every Prometheus label as `region=`. +// Resolution order matches rpc-capabilities: +// 1. $REGION (explicit override, one Railway service per region) +// 2. $RAILWAY_REPLICA_REGION (Railway "Add Region" replicas) +// 3. "eu-west" +var currentRegion = loadRegion() + +func loadRegion() string { + if r := strings.TrimSpace(os.Getenv("REGION")); r != "" { + return r + } + if r := normalizeRailwayRegion(os.Getenv("RAILWAY_REPLICA_REGION")); r != "" { + return r + } + return "eu-west" +} + +// Railway exposes replica region as raw GCP-style slugs like "us-east4-eqdc4a". +// Map them down to the canonical 3-region set (us-east / eu-west / sgp) so the +// Prom label space stays small. Unknown values pass through so a new Railway +// region surfaces in the metric instead of silently bucketing as eu-west. +func normalizeRailwayRegion(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + if raw == "" { + return "" + } + switch { + case strings.HasPrefix(raw, "us-"), strings.HasPrefix(raw, "northamerica"): + return "us-east" + case strings.HasPrefix(raw, "europe"), strings.HasPrefix(raw, "eu-"): + return "eu-west" + case strings.HasPrefix(raw, "asia"), strings.HasPrefix(raw, "ap-"): + return "sgp" + default: + return raw + } +} diff --git a/harnesses/pm-rate-limits/cmd/script/loghub.go b/harnesses/pm-rate-limits/cmd/script/loghub.go new file mode 100644 index 000000000..9dd744888 --- /dev/null +++ b/harnesses/pm-rate-limits/cmd/script/loghub.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a +// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token +// matching the LOGS_TOKEN env var. +// +// Keep in sync across miniapps (was previously the shared/loghub package; we +// inline because Railway's per-harness Docker build context can't reach a +// sibling shared module via go.mod replace). + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a +// pipe, then spawns a goroutine that fan-outs every line to the original +// stdout AND the in-memory ring buffer. Call exactly once, very early in +// main(). +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header +// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset. +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/pm-rate-limits/cmd/script/main.go b/harnesses/pm-rate-limits/cmd/script/main.go new file mode 100644 index 000000000..a698614bb --- /dev/null +++ b/harnesses/pm-rate-limits/cmd/script/main.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +// buildMarker is bumped manually on every commit that ships a Codex +// providers behavior change so the runtime log proves which version is +// actually deployed. Increment on every release. +const buildMarker = "codex-fix-v6-canonical-id-discovery" + +func main() { + installLogCapture() + log.SetFlags(0) + log.Printf("[pm-rate-limits] starting, region=%s ramp_hour=%02d:00 UTC build=%s", currentRegion, rampHour(), buildMarker) + + cfg := loadConfig() + + go func() { + if err := StartMetricsServer(":2112"); err != nil { + log.Printf("[pm-rate-limits] metrics server died: %v", err) + os.Exit(1) + } + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + pinClient := &http.Client{Timeout: 20 * time.Second} + vs := venues() + stateByVenue := map[string]*venueState{} + for _, v := range vs { + v.state = newVenueState() + stateByVenue[v.Slug] = v.state + go initialPin(ctx, v, pinClient) + go repinLoop(ctx, v, v.state, pinClient) + warmClient := &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 4, IdleConnTimeout: 90 * time.Second}} + for _, cl := range v.Classes { + go warmLoop(ctx, v, cl, v.state, warmClient) + } + go coldLoop(ctx, v, v.state) + if v.Slug == "polymarket" { + go runPolymarketWS(ctx, v.state) + } + } + + // Aggregator probes. One goroutine per (venue, source) pair; reuses + // the venueState pin so the apples-to-apples comparison holds. + aggClient := &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 4, IdleConnTimeout: 90 * time.Second}} + + // One-shot Codex discovery: filterPredictionMarkets on Polymarket so + // we see the canonical composite marketId Codex stores. Without this + // we are guessing exchange address and tokenId encoding. + if cfg.DefinedSessionCookie != "" { + go codexDiscoveryProbe(cfg.DefinedSessionCookie, aggClient) + } + + for _, ps := range aggregatorProbes(cfg) { + st, ok := stateByVenue[ps.Venue] + if !ok { + log.Printf("[providers] no venueState for %s/%s — skipping", ps.Venue, ps.Source) + continue + } + log.Printf("[providers] enabled %s/%s every %s", ps.Source, ps.Venue, ps.Interval) + go aggregatorLoop(ctx, ps, st, aggClient) + } + + go dailyRepin(ctx, vs, pinClient) + go rampLoop(ctx, vs) + + <-ctx.Done() + log.Printf("[pm-rate-limits] shutting down") +} + +func initialPin(ctx context.Context, v *Venue, client *http.Client) { + for ctx.Err() == nil { + pin, err := v.PinFunc(ctx, client, "") + if err == nil { + v.state.setPin(pin) + log.Printf("[pin][%s] pinned %q (expiry %s)", v.Slug, pin.Market, pin.Expiry.Format(time.RFC3339)) + return + } + log.Printf("[pin][%s] initial pin failed: %v (retry in 30s)", v.Slug, err) + select { + case <-ctx.Done(): + return + case <-time.After(30 * time.Second): + } + } +} + +// dailyRepin refreshes every venue's pinned market at 00:00 UTC so the target +// stays the liquid near-the-money market of the day. Failures keep the old +// pin; probe_invalid handling covers intraday resolutions. +func dailyRepin(ctx context.Context, vs []*Venue, client *http.Client) { + for { + now := time.Now().UTC() + next := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour) + select { + case <-ctx.Done(): + return + case <-time.After(time.Until(next)): + } + for _, v := range vs { + pin, err := v.PinFunc(ctx, client, "") + if err != nil { + log.Printf("[pin][%s] daily re-pin failed, keeping %q: %v", v.Slug, v.state.getPin().Market, err) + continue + } + v.state.setPin(pin) + log.Printf("[pin][%s] daily re-pin -> %q (expiry %s)", v.Slug, pin.Market, pin.Expiry.Format(time.RFC3339)) + } + } +} diff --git a/harnesses/pm-rate-limits/cmd/script/metrics.go b/harnesses/pm-rate-limits/cmd/script/metrics.go new file mode 100644 index 000000000..9545699e9 --- /dev/null +++ b/harnesses/pm-rate-limits/cmd/script/metrics.go @@ -0,0 +1,105 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// pmapi_* namespace: prediction-market venue APIs probed directly (native +// gateways), distinct from pm_* (pm-freshness, data providers). Latency +// histograms only record successful requests; failures land in +// pmapi_requests_total{outcome} so failure modes never pollute the +// latency aggregates. +var ( + durBuckets = []float64{0.025, 0.05, 0.075, 0.1, 0.15, 0.25, 0.4, 0.6, 1, 1.5, 2.5, 4, 6, 10} + connectBuckets = []float64{0.01, 0.025, 0.05, 0.1, 0.15, 0.25, 0.4, 0.6, 1, 1.5, 2.5} + + reqDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "pmapi_request_duration_seconds", + Help: "Full HTTP round-trip (incl. body) for successful probes against prediction-market venue APIs. conn=warm reuses a keep-alive pool, conn=cold forces a fresh TCP+TLS handshake. cache=hit means the response was served by a CDN edge, not the venue origin. source=direct probes the native venue gateway; source=mobula|predexon|codex probes the same logical venue via an aggregator.", + Buckets: durBuckets, + }, []string{"venue", "class", "region", "conn", "cache", "source"}) + + reqTTFB = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "pmapi_request_ttfb_seconds", + Help: "Time to first response byte for successful probes. Compare with duration to separate server time from payload size (payloads range 17 B to 650 KB across classes).", + Buckets: durBuckets, + }, []string{"venue", "class", "region", "conn", "cache", "source"}) + + reqConnect = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "pmapi_request_connect_seconds", + Help: "TCP+TLS handshake time on the dedicated cold-connect probe (1/min per venue, keep-alives disabled).", + Buckets: connectBuckets, + }, []string{"venue", "region", "source"}) + + reqTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "pmapi_requests_total", + Help: "Probe outcomes: ok, timeout, http_4xx, http_5xx, throttled (429), probe_invalid (our pinned market went stale, e.g. Limitless CDN-cached 400 on an expired market — never counted against the venue), net_error.", + }, []string{"venue", "class", "region", "conn", "outcome", "source"}) + + bookStaleness = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "pmapi_book_staleness_seconds", + Help: "Server-reported data age: now minus the timestamp the venue embeds in its book/market payload. Only Polymarket (book.timestamp) and Manifold (lastUpdatedTime) expose one; the absence elsewhere is itself a finding.", + }, []string{"venue", "region", "source"}) + + venueHealth = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "pmapi_health", + Help: "1 when the most recent primary-class probe returned ok, 0 otherwise.", + }, []string{"venue", "region", "source"}) + + wsConnectToSnapshot = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "pmapi_ws_connect_to_snapshot_seconds", + Help: "Time from WebSocket dial to the first market data frame after subscribing. Kalshi requires auth for WS and Myriad has none, so only venues with a public WS appear.", + Buckets: durBuckets, + }, []string{"venue", "region", "source"}) + + wsInterarrival = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "pmapi_ws_update_interarrival_seconds", + Help: "Gap between consecutive market data frames on the public WebSocket for the pinned market.", + Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120}, + }, []string{"venue", "region", "source"}) + + wsDisconnects = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "pmapi_ws_disconnects_total", + Help: "WebSocket connections dropped after being established.", + }, []string{"venue", "region", "source"}) + + rampDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "pmapi_ramp_request_duration_seconds", + Help: "Round-trip during the daily rate-limit ramp, by tier (requests per 10 s window). Successful requests only.", + Buckets: durBuckets, + }, []string{"venue", "region", "tier", "source"}) + + rampTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "pmapi_ramp_requests_total", + Help: "Ramp request outcomes per tier. throttled+5xx above 1% of a 10 s window aborts the ramp for that venue.", + }, []string{"venue", "region", "tier", "outcome", "source"}) + + rampAdded = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "pmapi_ramp_added_latency_seconds", + Help: "p50(tier) minus p50(warm book baseline over the previous hour, same region). Venues that queue under load (e.g. Cloudflare in front of Polymarket) show added latency without ever returning 429, so this is the honest throttle signal.", + }, []string{"venue", "region", "tier", "source"}) +) + +// sourceDirect is the label value stamped on every direct (native venue +// gateway) probe. Aggregator probes pass "mobula", "predexon", "codex". +const sourceDirect = "direct" + +// StartMetricsServer binds /metrics + /health + /logs on addr. Blocking call, +// run in its own goroutine. :2112 is the OCB convention; Railway's $PORT is +// deliberately ignored so the shared Prometheus always finds the listener. +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("pm-rate-limits harness · OpenChainBench")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/pm-rate-limits/cmd/script/pin.go b/harnesses/pm-rate-limits/cmd/script/pin.go new file mode 100644 index 000000000..dd0401516 --- /dev/null +++ b/harnesses/pm-rate-limits/cmd/script/pin.go @@ -0,0 +1,276 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// Pin selection rules (shared intent across venues): the most liquid market +// that is near-the-money (a 0.999 book is degenerate, its latency profile is +// not representative) and expires more than 24h out (Limitless lists 5-minute +// markets whose expiry turns every later probe into a CDN-cached 400). +const minPinHorizon = 24 * time.Hour + +func fetchJSON(ctx context.Context, c *http.Client, url string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + resp, err := c.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return err + } + return json.Unmarshal(body, out) +} + +func pinPolymarket(ctx context.Context, c *http.Client, avoid string) (Pin, error) { + var markets []struct { + Slug string `json:"slug"` + ConditionId string `json:"conditionId"` + EndDate string `json:"endDate"` + ClobTokenIds string `json:"clobTokenIds"` + OutcomePrices string `json:"outcomePrices"` + } + url := "https://gamma-api.polymarket.com/markets?limit=50&order=volume24hr&ascending=false&closed=false" + if err := fetchJSON(ctx, c, url, &markets); err != nil { + return Pin{}, err + } + for _, m := range markets { + if m.Slug == avoid { + continue + } + end, err := time.Parse(time.RFC3339, m.EndDate) + if err != nil || time.Until(end) < minPinHorizon { + continue + } + var prices []string + if json.Unmarshal([]byte(m.OutcomePrices), &prices) != nil || len(prices) == 0 { + continue + } + p0, err := strconv.ParseFloat(prices[0], 64) + if err != nil || p0 < 0.15 || p0 > 0.85 { + continue + } + var tokens []string + if json.Unmarshal([]byte(m.ClobTokenIds), &tokens) != nil || len(tokens) == 0 { + continue + } + return Pin{Market: m.Slug, Token: tokens[0], Condition: m.ConditionId, Expiry: end}, nil + } + return Pin{}, errors.New("polymarket: no near-the-money market with >24h horizon in top 50") +} + +func pinKalshi(ctx context.Context, c *http.Client, avoid string) (Pin, error) { + // Kalshi serves stats as fixed-point strings (volume_24h_fp, + // yes_bid_dollars); the legacy numeric fields are always null. + // min_close_ts pushes the >24h horizon filter server-side. + var out struct { + Markets []struct { + Ticker string `json:"ticker"` + Volume24h string `json:"volume_24h_fp"` + YesBid string `json:"yes_bid_dollars"` + CloseTime string `json:"close_time"` + } `json:"markets"` + } + minClose := time.Now().Add(minPinHorizon).Unix() + url := fmt.Sprintf("https://api.elections.kalshi.com/trade-api/v2/markets?limit=1000&status=open&min_close_ts=%d", minClose) + if err := fetchJSON(ctx, c, url, &out); err != nil { + return Pin{}, err + } + best := Pin{} + bestVol := -1.0 + for _, m := range out.Markets { + if m.Ticker == avoid { + continue + } + vol, err := strconv.ParseFloat(m.Volume24h, 64) + if err != nil || vol <= 0 { + continue + } + bid, err := strconv.ParseFloat(m.YesBid, 64) + if err != nil || bid < 0.15 || bid > 0.85 { + continue + } + close, err := time.Parse(time.RFC3339, m.CloseTime) + if err != nil || time.Until(close) < minPinHorizon { + continue + } + if vol > bestVol { + bestVol = vol + best = Pin{Market: m.Ticker, Expiry: close} + } + } + if best.Market == "" { + return Pin{}, errors.New("kalshi: no liquid near-the-money market with >24h horizon") + } + return best, nil +} + +func pinLimitless(ctx context.Context, c *http.Client, avoid string) (Pin, error) { + // limit caps at 25 (400 above) and the first pages are 5-minute/hourly + // markets, so paginate the whole active board to reach the long-dated + // liquid ones (World Cup style markets live deep in the list). + best := Pin{} + bestVol := -1.0 + for page := 1; page <= 50; page++ { + var out struct { + Data []struct { + Slug string `json:"slug"` + ExpirationTimestamp int64 `json:"expirationTimestamp"` + VolumeFormatted string `json:"volumeFormatted"` + TradeType string `json:"tradeType"` + MarketType string `json:"marketType"` + } `json:"data"` + } + url := fmt.Sprintf("https://api.limitless.exchange/markets/active?limit=25&page=%d", page) + if err := fetchJSON(ctx, c, url, &out); err != nil { + if best.Market != "" { + break // partial scan is fine, keep the best so far + } + return Pin{}, err + } + for _, m := range out.Data { + if m.Slug == avoid || m.Slug == "" { + continue + } + // Only single CLOB markets have an orderbook endpoint. The list's + // tradeType is not always consistent with the market detail, so a + // mislabel still lands on probe_invalid and re-pins. + if m.TradeType != "clob" || m.MarketType != "single" { + continue + } + exp := time.UnixMilli(m.ExpirationTimestamp) + if time.Until(exp) < minPinHorizon { + continue + } + vol, _ := strconv.ParseFloat(m.VolumeFormatted, 64) + if vol > bestVol { + bestVol = vol + best = Pin{Market: m.Slug, Expiry: exp} + } + } + if len(out.Data) < 25 { + break + } + select { + case <-ctx.Done(): + return Pin{}, ctx.Err() + case <-time.After(200 * time.Millisecond): + } + } + if best.Market == "" { + return Pin{}, errors.New("limitless: no active market with >24h horizon (board is mostly 5min/hourly)") + } + return best, nil +} + +func pinManifold(ctx context.Context, c *http.Client, avoid string) (Pin, error) { + var markets []struct { + ID string `json:"id"` + Probability float64 `json:"probability"` + CloseTime int64 `json:"closeTime"` + } + url := "https://api.manifold.markets/v0/search-markets?term=&sort=liquidity&filter=open&contractType=BINARY&limit=20" + if err := fetchJSON(ctx, c, url, &markets); err != nil { + return Pin{}, err + } + for _, m := range markets { + if m.ID == avoid { + continue + } + close := time.UnixMilli(m.CloseTime) + if time.Until(close) < minPinHorizon { + continue + } + if m.Probability < 0.2 || m.Probability > 0.8 { + continue + } + return Pin{Market: m.ID, Expiry: close}, nil + } + return Pin{}, errors.New("manifold: no near-the-money binary market with >24h horizon") +} + +func pinMyriad(ctx context.Context, c *http.Client, avoid string) (Pin, error) { + var out struct { + Data []struct { + Slug string `json:"slug"` + ExpiresAt string `json:"expiresAt"` + Volume float64 `json:"volume"` + } `json:"data"` + } + url := "https://api-v2.myriadprotocol.com/markets?state=open&limit=20" + if err := fetchJSON(ctx, c, url, &out); err != nil { + return Pin{}, err + } + best := Pin{} + bestVol := -1.0 + for _, m := range out.Data { + if m.Slug == avoid || m.Slug == "" { + continue + } + exp, err := time.Parse(time.RFC3339, m.ExpiresAt) + if err != nil || time.Until(exp) < minPinHorizon { + continue + } + if m.Volume > bestVol { + bestVol = m.Volume + best = Pin{Market: m.Slug, Expiry: exp} + } + } + if best.Market == "" { + return Pin{}, errors.New("myriad: no open market with >24h horizon") + } + return best, nil +} + +// stalenessPolymarket reads the ms timestamp Polymarket embeds in every book +// response (string in the docs, tolerate a bare number). +func stalenessPolymarket(class string, body []byte) (int64, bool) { + if class != "book" { + return 0, false + } + var b struct { + Timestamp any `json:"timestamp"` + } + if json.Unmarshal(body, &b) != nil { + return 0, false + } + switch t := b.Timestamp.(type) { + case string: + ms, err := strconv.ParseInt(t, 10, 64) + return ms, err == nil + case float64: + return int64(t), true + } + return 0, false +} + +// stalenessManifold reads lastUpdatedTime (ms) from the single-market payload. +func stalenessManifold(class string, body []byte) (int64, bool) { + if class != "price" { + return 0, false + } + var m struct { + LastUpdatedTime int64 `json:"lastUpdatedTime"` + } + if json.Unmarshal(body, &m) != nil || m.LastUpdatedTime == 0 { + return 0, false + } + return m.LastUpdatedTime, true +} diff --git a/harnesses/pm-rate-limits/cmd/script/probe.go b/harnesses/pm-rate-limits/cmd/script/probe.go new file mode 100644 index 000000000..a02f7d14f --- /dev/null +++ b/harnesses/pm-rate-limits/cmd/script/probe.go @@ -0,0 +1,442 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "io" + "log" + "math/rand" + "net/http" + "net/http/httptrace" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +type baselineSample struct { + at time.Time + dur float64 +} + +type venueState struct { + mu sync.Mutex + pin Pin + baseline []baselineSample // warm primary-class durations, pruned to 1h + lastRepin time.Time + repinCh chan string +} + +func newVenueState() *venueState { + return &venueState{repinCh: make(chan string, 1)} +} + +func (st *venueState) getPin() Pin { + st.mu.Lock() + defer st.mu.Unlock() + return st.pin +} + +func (st *venueState) setPin(p Pin) { + st.mu.Lock() + st.pin = p + st.mu.Unlock() +} + +func (st *venueState) addBaseline(d float64) { + now := time.Now() + st.mu.Lock() + st.baseline = append(st.baseline, baselineSample{at: now, dur: d}) + cut := now.Add(-time.Hour) + i := 0 + for i < len(st.baseline) && st.baseline[i].at.Before(cut) { + i++ + } + st.baseline = st.baseline[i:] + st.mu.Unlock() +} + +// baselineP50 returns the median warm primary-class duration over the last +// hour and the sample count. The ramp's added-latency gauge is meaningless +// without it. +func (st *venueState) baselineP50() (float64, int) { + st.mu.Lock() + durs := make([]float64, len(st.baseline)) + for i, s := range st.baseline { + durs[i] = s.dur + } + st.mu.Unlock() + if len(durs) == 0 { + return 0, 0 + } + sort.Float64s(durs) + return durs[len(durs)/2], len(durs) +} + +func (st *venueState) triggerRepin(failed string) { + select { + case st.repinCh <- failed: + default: + } +} + +// classifyCache buckets response cache headers: "hit" when an edge served the +// response without touching origin, "miss" when cache infra is present but the +// response came from origin, "none" when no cache layer announced itself +// (Myriad's bare Heroku router). +func classifyCache(h http.Header) string { + cf := strings.ToUpper(h.Get("Cf-Cache-Status")) + xc := strings.ToLower(h.Get("X-Cache")) + if cf == "HIT" || cf == "STALE" || cf == "UPDATING" || strings.Contains(xc, "hit") { + return "hit" + } + if age, err := strconv.Atoi(h.Get("Age")); err == nil && age > 0 { + return "hit" + } + if cf != "" || xc != "" { + return "miss" + } + return "none" +} + +func classifyOutcome(v *Venue, status int, body []byte, err error) string { + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + return "net_error" + } + switch { + case status >= 200 && status < 300: + return "ok" + case status == 429: + return "throttled" + case status >= 500: + return "http_5xx" + case status == 404: + return "probe_invalid" + default: + for _, s := range v.InvalidBody { + if strings.Contains(string(body), s) { + return "probe_invalid" + } + } + return "http_4xx" + } +} + +type probeResult struct { + outcome string + dur float64 + ttfb float64 + connect float64 + cache string + body []byte +} + +func probeOnce(ctx context.Context, client *http.Client, v *Venue, cl Class, conn string) probeResult { + pctx, cancel := context.WithTimeout(ctx, cl.Timeout) + defer cancel() + + res := probeResult{cache: "none"} + var connectStart time.Time + start := time.Now() + trace := &httptrace.ClientTrace{ + ConnectStart: func(string, string) { connectStart = time.Now() }, + TLSHandshakeDone: func(tls.ConnectionState, error) { + if !connectStart.IsZero() { + res.connect = time.Since(connectStart).Seconds() + } + }, + GotFirstResponseByte: func() { res.ttfb = time.Since(start).Seconds() }, + } + url := cl.URL(v.pinOf(ctx)) + req, err := http.NewRequestWithContext(httptrace.WithClientTrace(pctx, trace), http.MethodGet, url, nil) + if err != nil { + res.outcome = "net_error" + return res + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + res.outcome = classifyOutcome(v, 0, nil, err) + if pctx.Err() == context.DeadlineExceeded { + res.outcome = "timeout" + } + return res + } + defer resp.Body.Close() + body, rerr := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + res.dur = time.Since(start).Seconds() + if rerr != nil { + res.outcome = classifyOutcome(v, 0, nil, rerr) + if pctx.Err() == context.DeadlineExceeded { + res.outcome = "timeout" + } + return res + } + res.cache = classifyCache(resp.Header) + res.outcome = classifyOutcome(v, resp.StatusCode, body, nil) + res.body = body + return res +} + +// pinOf is a tiny indirection so probeOnce doesn't need venueState plumbed +// through; set per venue at startup. +func (v *Venue) pinOf(context.Context) Pin { + if v.state == nil { + return Pin{} + } + return v.state.getPin() +} + +// warmLoop probes one (venue, class) on its cadence over a shared keep-alive +// pool. Deterministic-ish jitter at startup spreads the fleet. +func warmLoop(ctx context.Context, v *Venue, cl Class, st *venueState, client *http.Client) { + time.Sleep(time.Duration(rand.Int63n(int64(cl.Interval)))) + t := time.NewTicker(cl.Interval) + defer t.Stop() + primary := v.Classes[0].Name + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + pin := st.getPin() + needsPin := strings.Contains(cl.URL(Pin{Market: "\x00", Token: "\x00"}), "\x00") + if needsPin && pin.Market == "" { + continue + } + if needsPin && !pin.Expiry.IsZero() && time.Now().After(pin.Expiry) { + st.triggerRepin(pin.Market) + continue + } + res := probeOnce(ctx, client, v, cl, "warm") + reqTotal.WithLabelValues(v.Slug, cl.Name, currentRegion, "warm", res.outcome, sourceDirect).Inc() + if res.outcome == "ok" { + reqDuration.WithLabelValues(v.Slug, cl.Name, currentRegion, "warm", res.cache, sourceDirect).Observe(res.dur) + reqTTFB.WithLabelValues(v.Slug, cl.Name, currentRegion, "warm", res.cache, sourceDirect).Observe(res.ttfb) + if cl.Name == primary { + st.addBaseline(res.dur) + } + if v.StalenessMs != nil { + if ms, ok := v.StalenessMs(cl.Name, res.body); ok { + age := float64(time.Now().UnixMilli()-ms) / 1000 + if age >= 0 && age < 86400 { + bookStaleness.WithLabelValues(v.Slug, currentRegion, sourceDirect).Set(age) + } + } + } + } + if cl.Name == primary { + if res.outcome == "ok" { + venueHealth.WithLabelValues(v.Slug, currentRegion, sourceDirect).Set(1) + } else { + venueHealth.WithLabelValues(v.Slug, currentRegion, sourceDirect).Set(0) + } + } + if res.outcome == "probe_invalid" { + st.triggerRepin(pin.Market) + } + } +} + +// coldLoop measures fresh TCP+TLS handshakes once a minute on the primary +// class, with keep-alives disabled and a new transport each probe so nothing +// is reused. +func coldLoop(ctx context.Context, v *Venue, st *venueState) { + time.Sleep(time.Duration(rand.Int63n(int64(time.Minute)))) + t := time.NewTicker(time.Minute) + defer t.Stop() + cl := v.Classes[0] + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + if st.getPin().Market == "" { + continue + } + tr := &http.Transport{DisableKeepAlives: true} + client := &http.Client{Transport: tr} + res := probeOnce(ctx, client, v, cl, "cold") + tr.CloseIdleConnections() + reqTotal.WithLabelValues(v.Slug, cl.Name, currentRegion, "cold", res.outcome, sourceDirect).Inc() + if res.outcome == "ok" { + reqDuration.WithLabelValues(v.Slug, cl.Name, currentRegion, "cold", res.cache, sourceDirect).Observe(res.dur) + reqTTFB.WithLabelValues(v.Slug, cl.Name, currentRegion, "cold", res.cache, sourceDirect).Observe(res.ttfb) + if res.connect > 0 { + reqConnect.WithLabelValues(v.Slug, currentRegion, sourceDirect).Observe(res.connect) + } + } + } +} + +// aggregatorLoop probes one (venue, source) pair on its cadence via the +// aggregator's URL builder, stamping source= on every metric. +// Reuses the venueState's pin so the aggregator hits exactly the same +// market the direct probes do — every regression has to be apples to +// apples. AuthMutator stamps the credential header; Throttle (Predexon's +// 1 rps global bucket) blocks before the request when set. +func aggregatorLoop(ctx context.Context, ps ProbeSource, st *venueState, client *http.Client) { + time.Sleep(time.Duration(rand.Int63n(int64(ps.Interval)))) + t := time.NewTicker(ps.Interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + pin := st.getPin() + if pin.Market == "" && pin.Token == "" { + continue + } + if !pin.Expiry.IsZero() && time.Now().After(pin.Expiry) { + continue + } + if ps.Throttle != nil { + ps.Throttle() + } + res := aggregatorProbeOnce(ctx, client, ps, pin) + reqTotal.WithLabelValues(ps.Venue, ps.Class, currentRegion, "warm", res.outcome, ps.Source).Inc() + if res.outcome == "ok" { + reqDuration.WithLabelValues(ps.Venue, ps.Class, currentRegion, "warm", res.cache, ps.Source).Observe(res.dur) + reqTTFB.WithLabelValues(ps.Venue, ps.Class, currentRegion, "warm", res.cache, ps.Source).Observe(res.ttfb) + venueHealth.WithLabelValues(ps.Venue, currentRegion, ps.Source).Set(1) + } else { + venueHealth.WithLabelValues(ps.Venue, currentRegion, ps.Source).Set(0) + } + } +} + +func aggregatorProbeOnce(ctx context.Context, client *http.Client, ps ProbeSource, pin Pin) probeResult { + pctx, cancel := context.WithTimeout(ctx, ps.Timeout) + defer cancel() + + res := probeResult{cache: "none"} + var connectStart time.Time + start := time.Now() + trace := &httptrace.ClientTrace{ + ConnectStart: func(string, string) { connectStart = time.Now() }, + TLSHandshakeDone: func(tls.ConnectionState, error) { + if !connectStart.IsZero() { + res.connect = time.Since(connectStart).Seconds() + } + }, + GotFirstResponseByte: func() { res.ttfb = time.Since(start).Seconds() }, + } + method := http.MethodGet + var bodyReader io.Reader + if strings.EqualFold(ps.Method, "POST") && ps.BodyFunc != nil { + body := ps.BodyFunc(pin) + if body == nil { + // Missing identifier on the pin (e.g. Codex Polymarket + // without a conditionId yet). Skip instead of POSTing an + // empty query — it would just burn quota for nothing. + res.outcome = "probe_invalid" + return res + } + method = http.MethodPost + bodyReader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(httptrace.WithClientTrace(pctx, trace), method, ps.URL(pin), bodyReader) + if err != nil { + res.outcome = "net_error" + return res + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + if ps.ContentType != "" { + req.Header.Set("Content-Type", ps.ContentType) + } + if ps.AuthMutator != nil { + ps.AuthMutator(req) + } + + resp, err := client.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || pctx.Err() == context.DeadlineExceeded { + res.outcome = "timeout" + } else { + res.outcome = "net_error" + } + return res + } + defer resp.Body.Close() + body, rerr := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + res.dur = time.Since(start).Seconds() + if rerr != nil { + if pctx.Err() == context.DeadlineExceeded { + res.outcome = "timeout" + } else { + res.outcome = "net_error" + } + return res + } + res.cache = classifyCache(resp.Header) + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + // Codex (GraphQL) answers 200 even on errors; an "errors" key in + // the payload means the query was rejected (auth, schema, etc.) + // and the latency we just measured is the upstream's failure + // path, not a hit. Bucket as http_4xx so it doesn't poison the + // "ok" duration histogram. + if ps.Source == "codex" && bytes.Contains(body, []byte(`"errors"`)) { + res.outcome = "http_4xx" + // One-shot debug: surface the first failing response body so we + // can tell tier-gate (NOT_AUTHORIZED) from marketId or schema + // issues. Rate-limited to at most once per minute per + // (venue, region) to avoid log flooding. + codexErrorLogOnce(ps.Venue, currentRegion, body) + } else { + res.outcome = "ok" + } + case resp.StatusCode == 429: + res.outcome = "throttled" + case resp.StatusCode >= 500: + res.outcome = "http_5xx" + case resp.StatusCode == 404: + res.outcome = "probe_invalid" + default: + res.outcome = "http_4xx" + } + res.body = body + return res +} + +// repinLoop re-pins on probe_invalid, debounced to once per 5 minutes so a +// flapping endpoint can't turn the pinner into a crawler. +func repinLoop(ctx context.Context, v *Venue, st *venueState, client *http.Client) { + for { + var failed string + select { + case <-ctx.Done(): + return + case failed = <-st.repinCh: + } + st.mu.Lock() + recent := time.Since(st.lastRepin) < 5*time.Minute + if !recent { + st.lastRepin = time.Now() + } + st.mu.Unlock() + if recent { + continue + } + pin, err := v.PinFunc(ctx, client, failed) + if err != nil { + log.Printf("[pin][%s] re-pin failed (was %q): %v", v.Slug, failed, err) + continue + } + st.setPin(pin) + log.Printf("[pin][%s] re-pinned %q -> %q (expiry %s)", v.Slug, failed, pin.Market, pin.Expiry.Format(time.RFC3339)) + } +} diff --git a/harnesses/pm-rate-limits/cmd/script/providers.go b/harnesses/pm-rate-limits/cmd/script/providers.go new file mode 100644 index 000000000..bcb082936 --- /dev/null +++ b/harnesses/pm-rate-limits/cmd/script/providers.go @@ -0,0 +1,243 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "log" + "net/http" + "strings" + "sync" + "time" +) + +// Per-source probe URL builders + auth header injection for the +// aggregator routes added alongside the direct venue probes. Each +// function returns the URL the warm probe should hit for a given pinned +// market on the given venue, plus any provider specific request mutator +// that adds the auth header. +// +// Predexon enforces a 1 rps global cap across the org. A package-level +// token bucket gates every Predexon HTTP call regardless of which venue +// or region triggered it. + +var ( + predexonBucketMu sync.Mutex + predexonLastCall time.Time + predexonMinDelay = 1100 * time.Millisecond // 1 rps + 100ms safety +) + +func throttlePredexon() { + predexonBucketMu.Lock() + defer predexonBucketMu.Unlock() + now := time.Now() + if elapsed := now.Sub(predexonLastCall); elapsed < predexonMinDelay { + time.Sleep(predexonMinDelay - elapsed) + } + predexonLastCall = time.Now() +} + +// Mobula price endpoint. Polymarket only. Token id required, returned +// from gamma in clobTokenIds[0] (YES outcome). +func mobulaPolymarketURL(_ string, pin Pin) string { + return fmt.Sprintf("https://api.mobula.io/api/2/pm/market/price?platform=polymarket&outcomeId=%s", pin.Token) +} + +func mobulaAuth(key string) func(*http.Request) { + return func(req *http.Request) { + if key != "" { + req.Header.Set("Authorization", key) + } + } +} + +// Predexon URL builders per venue. +func predexonPolymarketURL(_ string, pin Pin) string { + return fmt.Sprintf("https://api.predexon.com/v2/polymarket/market-price/%s?at_time=%d", pin.Token, time.Now().Unix()) +} + +func predexonKalshiURL(_ string, pin Pin) string { + return fmt.Sprintf("https://api.predexon.com/v2/kalshi/markets?ticker=%s", pin.Market) +} + +func predexonLimitlessURL(_ string, pin Pin) string { + return fmt.Sprintf("https://api.predexon.com/v2/limitless/markets?slug=%s", pin.Market) +} + +func predexonAuth(key string) func(*http.Request) { + return func(req *http.Request) { + if key != "" { + req.Header.Set("x-api-key", key) + } + } +} + +// Codex GraphQL. JWT minted from DEFINED_SESSION_COOKIE in codex_auth.go, +// cached 23h. We POST the predictionMarketPrice query for the pinned +// marketId — conditionId for Polymarket, ticker for Kalshi. On mint +// failure we skip the Authorization header and let the upstream answer +// 401 so the probe is bucketed as an auth error (the right signal, +// rather than swallowing the call). +const codexGraphQLURL = "https://graph.codex.io/graphql" + +func codexBody(marketId string) []byte { + if marketId == "" { + return nil + } + return []byte(fmt.Sprintf( + `{"query":"{predictionMarketPrice(input:{marketId:\"%s\"}){marketId timestamp outcomes{outcomeId}}}"}`, + marketId, + )) +} + +func codexAuth(cookie string) func(*http.Request) { + return func(req *http.Request) { + jwt, err := GetCodexJWT(cookie) + if err != nil { + log.Printf("[codex] jwt mint failed (request will go unauthenticated): %v", err) + return + } + req.Header.Set("Authorization", "Bearer "+jwt) + } +} + +// codexErrorLogOnce logs the first Codex error response per (venue, region) +// at most once per minute. Surface the GraphQL error body so we can tell +// NOT_AUTHORIZED (tier gate, kill the loop) from marketId/schema issues +// (fix the bodyfunc and keep the loop). +var ( + codexErrorLogMu sync.Mutex + codexErrorLogLast = map[string]time.Time{} +) + +func codexErrorLogOnce(venue, region string, body []byte) { + codexErrorLogMu.Lock() + defer codexErrorLogMu.Unlock() + key := venue + "|" + region + last := codexErrorLogLast[key] + if time.Since(last) < time.Minute { + return + } + codexErrorLogLast[key] = time.Now() + snippet := string(body) + if len(snippet) > 400 { + snippet = snippet[:400] + "..." + } + log.Printf("[codex-debug] %s/%s error body: %s", venue, region, snippet) +} + +// Codex stores PM markets under a composite marketId of the form +// ":::" for chain venues and +// ":" for off chain. +// +// For Polymarket the marketAddress is the contract address of one +// specific market outcome, not the gamma conditionId and not the CLOB +// token id. The only documented way to obtain it is to call +// filterPredictionMarkets, which returns the canonical composite id +// directly. We cache the top-volume Polymarket market id at startup and +// refresh hourly, then every Codex/Polymarket probe targets that one +// market. The bench measures Codex latency against its own canonical +// market; the apples-to-apples is "same harness, same JWT, same +// endpoint, every 5 s, three regions". +var ( + codexPolymarketIDMu sync.RWMutex + codexPolymarketID string +) + +func setCodexPolymarketID(id string) { + codexPolymarketIDMu.Lock() + defer codexPolymarketIDMu.Unlock() + codexPolymarketID = id +} + +func getCodexPolymarketID() string { + codexPolymarketIDMu.RLock() + defer codexPolymarketIDMu.RUnlock() + return codexPolymarketID +} + +// codexPolymarketMarketID returns the cached canonical Codex composite +// id (set by codexDiscoveryProbe). Until the first discovery succeeds +// it returns the empty string and codexBody short-circuits to nil so +// the probe surfaces a clean http_4xx rather than an undefined query. +// The pin argument is unused now: Codex does not resolve from gamma +// conditionId or CLOB token id, so we cannot map our pinned market onto +// Codex's identifier space without an external lookup. +func codexPolymarketMarketID(_ string) string { + return getCodexPolymarketID() +} + +func codexKalshiMarketID(ticker string) string { + if ticker == "" { + return "" + } + return ticker + ":Kalshi" +} + +// codexDiscoveryProbe queries filterPredictionMarkets at startup and +// every hour to capture the top-volume Polymarket market id Codex +// indexes. The returned `results[0].id` is the composite key we feed +// into predictionMarketPrice. We do not try to resolve our gamma pin +// onto a Codex id because the documented surfaces do not support that +// lookup; instead Codex/Polymarket probes always target the current +// top-volume Polymarket market in Codex's index, which is stable enough +// across a few hours to give honest latency numbers. +func codexDiscoveryProbe(cookie string, client *http.Client) { + // Wait so the first JWT mint has a chance to succeed: the probe + // loops will race the discovery for the cache otherwise. + time.Sleep(15 * time.Second) + for { + if id, ok := runCodexDiscoveryOnce(cookie, client); ok { + setCodexPolymarketID(id) + log.Printf("[codex-discovery] polymarket canonical id set: %s", id) + } + time.Sleep(1 * time.Hour) + } +} + +func runCodexDiscoveryOnce(cookie string, client *http.Client) (string, bool) { + const query = `{"query":"{filterPredictionMarkets(filters:{protocol:[POLYMARKET],status:[OPEN]},rankings:[{attribute:volumeUsd24h,direction:DESC}],limit:3){results{id market{label}}}}"}` + for attempt := 1; attempt <= 5; attempt++ { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + req, _ := http.NewRequestWithContext(ctx, "POST", codexGraphQLURL, bytes.NewReader([]byte(query))) + req.Header.Set("Content-Type", "application/json") + jwt, err := GetCodexJWT(cookie) + if err != nil { + cancel() + log.Printf("[codex-discovery] attempt %d: jwt mint failed: %v", attempt, err) + time.Sleep(30 * time.Second) + continue + } + req.Header.Set("Authorization", "Bearer "+jwt) + resp, err := client.Do(req) + if err != nil { + cancel() + log.Printf("[codex-discovery] attempt %d: request failed: %v", attempt, err) + time.Sleep(30 * time.Second) + continue + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + resp.Body.Close() + cancel() + snippet := string(body) + if len(snippet) > 800 { + snippet = snippet[:800] + "..." + } + log.Printf("[codex-discovery] response status=%d body: %s", resp.StatusCode, snippet) + // Cheap parse: pull the first "id":"...:Polymarket:..." occurrence. + const idMarker = `"id":"` + i := strings.Index(string(body), idMarker) + if i < 0 { + return "", false + } + j := strings.Index(string(body)[i+len(idMarker):], `"`) + if j < 0 { + return "", false + } + id := string(body)[i+len(idMarker) : i+len(idMarker)+j] + return id, true + } + log.Printf("[codex-discovery] gave up after 5 attempts") + return "", false +} diff --git a/harnesses/pm-rate-limits/cmd/script/ramp.go b/harnesses/pm-rate-limits/cmd/script/ramp.go new file mode 100644 index 000000000..edb139829 --- /dev/null +++ b/harnesses/pm-rate-limits/cmd/script/ramp.go @@ -0,0 +1,190 @@ +package main + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "os" + "sort" + "strconv" + "sync" + "sync/atomic" + "time" +) + +// Daily rate-limit ramp ("crash test"). One run per venue per day, regions on +// disjoint UTC hours so two regions never load a venue at the same time. +// Tiers run 60s each at RampRates[i] requests per 10s window. The honest +// signal is added latency vs the same-hour warm baseline: Cloudflare in front +// of Polymarket queues instead of returning 429. +var rampHourByRegion = map[string]int{ + "us-east": 2, + "eu-west": 4, + "sgp": 6, +} + +const ( + tierDuration = 60 * time.Second + abortBadRatio = 0.01 // throttled+5xx per 10s window + minBaselineN = 30 + rampTimeout = 8 * time.Second + venueStaggerMin = 12 +) + +func rampHour() int { + if v := os.Getenv("RAMP_HOUR_UTC"); v != "" { + if h, err := strconv.Atoi(v); err == nil && h >= 0 && h <= 23 { + return h + } + } + if h, ok := rampHourByRegion[currentRegion]; ok { + return h + } + return 4 +} + +func rampLoop(ctx context.Context, vs []*Venue) { + if os.Getenv("RAMP_DISABLED") == "1" { + log.Printf("[ramp] disabled via RAMP_DISABLED=1") + return + } + hour := rampHour() + log.Printf("[ramp] scheduled daily at %02d:00 UTC (region %s), venues staggered %d min apart", hour, currentRegion, venueStaggerMin) + lastRun := map[string]string{} + t := time.NewTicker(20 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + now := time.Now().UTC() + if now.Hour() != hour { + continue + } + idx := 0 + for _, v := range vs { + if v.RampRates == nil { + continue + } + startMin := idx * venueStaggerMin + idx++ + today := now.Format("2006-01-02") + if now.Minute() < startMin || now.Minute() >= startMin+venueStaggerMin || lastRun[v.Slug] == today { + continue + } + lastRun[v.Slug] = today + runRamp(ctx, v) + } + } +} + +func runRamp(ctx context.Context, v *Venue) { + st := v.state + baseP50, n := st.baselineP50() + if n < minBaselineN { + log.Printf("[ramp][%s] skipped: only %d baseline samples in the last hour (need %d)", v.Slug, n, minBaselineN) + return + } + pin := st.getPin() + if pin.Market == "" { + log.Printf("[ramp][%s] skipped: no pinned market", v.Slug) + return + } + cl := v.Classes[0] + url := cl.URL(pin) + client := &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 32}} + log.Printf("[ramp][%s] start, baseline p50=%.0fms (n=%d), target %s", v.Slug, baseP50*1000, n, url) + + for _, rate := range v.RampRates { + tier := fmt.Sprintf("%dper10s", rate) + interval := 10 * time.Second / time.Duration(rate) + var ( + mu sync.Mutex + durations []float64 + wg sync.WaitGroup + aborted atomic.Bool + winTotal atomic.Int64 + winBad atomic.Int64 + ) + tick := time.NewTicker(interval) + winStart := time.Now() + deadline := time.Now().Add(tierDuration) + for time.Now().Before(deadline) && !aborted.Load() && ctx.Err() == nil { + <-tick.C + if time.Since(winStart) >= 10*time.Second { + tot, bad := winTotal.Swap(0), winBad.Swap(0) + winStart = time.Now() + if tot > 0 && float64(bad)/float64(tot) > abortBadRatio { + aborted.Store(true) + log.Printf("[ramp][%s][%s] ABORT: %d/%d throttled+5xx in 10s window", v.Slug, tier, bad, tot) + break + } + } + wg.Add(1) + go func() { + defer wg.Done() + outcome, dur := rampRequest(ctx, client, v, url) + rampTotal.WithLabelValues(v.Slug, currentRegion, tier, outcome, sourceDirect).Inc() + winTotal.Add(1) + switch outcome { + case "ok": + rampDuration.WithLabelValues(v.Slug, currentRegion, tier, sourceDirect).Observe(dur) + mu.Lock() + durations = append(durations, dur) + mu.Unlock() + case "throttled": + winBad.Add(1) + if v.StopOn429 { + aborted.Store(true) + log.Printf("[ramp][%s][%s] ABORT: 429 with StopOn429", v.Slug, tier) + } + case "http_5xx": + winBad.Add(1) + } + }() + } + tick.Stop() + wg.Wait() + mu.Lock() + sort.Float64s(durations) + if len(durations) > 0 { + p50 := durations[len(durations)/2] + rampAdded.WithLabelValues(v.Slug, currentRegion, tier, sourceDirect).Set(p50 - baseP50) + log.Printf("[ramp][%s][%s] done: n=%d p50=%.0fms added=%.0fms", v.Slug, tier, len(durations), p50*1000, (p50-baseP50)*1000) + } + mu.Unlock() + if aborted.Load() { + log.Printf("[ramp][%s] aborted at tier %s, skipping higher tiers", v.Slug, tier) + break + } + } + client.CloseIdleConnections() +} + +func rampRequest(ctx context.Context, client *http.Client, v *Venue, url string) (string, float64) { + rctx, cancel := context.WithTimeout(ctx, rampTimeout) + defer cancel() + req, err := http.NewRequestWithContext(rctx, http.MethodGet, url, nil) + if err != nil { + return "net_error", 0 + } + req.Header.Set("User-Agent", userAgent) + start := time.Now() + resp, err := client.Do(req) + if err != nil { + return classifyOutcome(v, 0, nil, err), 0 + } + defer resp.Body.Close() + var body []byte + if resp.StatusCode >= 400 { + body, _ = io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + } else { + _, _ = io.Copy(io.Discard, resp.Body) + } + dur := time.Since(start).Seconds() + return classifyOutcome(v, resp.StatusCode, body, nil), dur +} diff --git a/harnesses/pm-rate-limits/cmd/script/venues.go b/harnesses/pm-rate-limits/cmd/script/venues.go new file mode 100644 index 000000000..e88be3215 --- /dev/null +++ b/harnesses/pm-rate-limits/cmd/script/venues.go @@ -0,0 +1,241 @@ +package main + +import ( + "context" + "net/http" + "time" +) + +// Pin is the market each venue's book/price probes target. Re-pinned daily at +// 00:00 UTC (near-the-money, most liquid, expiry >24h out) and immediately on +// probe_invalid so a resolved market never counts against the venue. +type Pin struct { + Market string // slug / ticker / contract id (logs + re-pin avoidance) + Token string // Polymarket CLOB token id + Condition string // Polymarket conditionId (0x-hex) — Codex marketId; "" elsewhere + Expiry time.Time +} + +type Class struct { + Name string + Interval time.Duration + Timeout time.Duration + URL func(p Pin) string +} + +// Venue defines the probe matrix for one native prediction-market API. +// Classes[0] is the primary class (drives pmapi_health and the cold probe). +// RampRates are requests per 10 s window per tier; nil excludes the venue +// from the daily ramp (Myriad: keyless 30 req/10s budget, ramping it would +// just measure our own quota). +type Venue struct { + Slug string + Classes []Class + PinFunc func(ctx context.Context, c *http.Client, avoid string) (Pin, error) + StalenessMs func(class string, body []byte) (int64, bool) + RampRates []int + StopOn429 bool // Kalshi: documented token bucket, stop at first 429 + InvalidBody []string // 4xx body substrings that mark a probe_invalid (stale pin) + + state *venueState // wired at startup +} + +func venues() []*Venue { + return []*Venue{ + { + Slug: "polymarket", + Classes: []Class{ + {Name: "book", Interval: 5 * time.Second, Timeout: 8 * time.Second, + URL: func(p Pin) string { return "https://clob.polymarket.com/book?token_id=" + p.Token }}, + {Name: "price", Interval: 5 * time.Second, Timeout: 8 * time.Second, + URL: func(p Pin) string { return "https://clob.polymarket.com/midpoint?token_id=" + p.Token }}, + {Name: "list", Interval: 30 * time.Second, Timeout: 15 * time.Second, + URL: func(Pin) string { + return "https://gamma-api.polymarket.com/markets?limit=20&order=volume24hr&ascending=false&closed=false" + }}, + }, + PinFunc: pinPolymarket, + StalenessMs: stalenessPolymarket, + RampRates: []int{25, 50, 100}, // 1.7-6.7% of the documented 1500/10s book budget + InvalidBody: []string{"No orderbook exists"}, + }, + { + Slug: "kalshi", + Classes: []Class{ + {Name: "book", Interval: 5 * time.Second, Timeout: 8 * time.Second, + URL: func(p Pin) string { + return "https://api.elections.kalshi.com/trade-api/v2/markets/" + p.Market + "/orderbook" + }}, + {Name: "price", Interval: 5 * time.Second, Timeout: 8 * time.Second, + URL: func(p Pin) string { + return "https://api.elections.kalshi.com/trade-api/v2/markets/" + p.Market + }}, + // The list endpoint sits behind CloudFront with max-age=15: it + // measures the edge, not the API. Disclosed in the methodology, + // cache label records it on every sample. + {Name: "list", Interval: 30 * time.Second, Timeout: 15 * time.Second, + URL: func(Pin) string { + return "https://api.elections.kalshi.com/trade-api/v2/markets?limit=100&status=open" + }}, + }, + PinFunc: pinKalshi, + RampRates: []int{25, 50, 100}, + StopOn429: true, + }, + { + Slug: "limitless", + Classes: []Class{ + {Name: "book", Interval: 5 * time.Second, Timeout: 8 * time.Second, + URL: func(p Pin) string { + return "https://api.limitless.exchange/markets/" + p.Market + "/orderbook" + }}, + {Name: "price", Interval: 5 * time.Second, Timeout: 8 * time.Second, + URL: func(p Pin) string { return "https://api.limitless.exchange/markets/" + p.Market }}, + {Name: "list", Interval: 30 * time.Second, Timeout: 15 * time.Second, + URL: func(Pin) string { return "https://api.limitless.exchange/markets/active?limit=20" }}, + }, + PinFunc: pinLimitless, + // Undocumented limits, errors CDN-cached 4h: conservative tiers + the + // global abort guard. + RampRates: []int{10, 20, 40}, + InvalidBody: []string{"Market is not active", "does not support orderbook", "not found", "Not Found"}, + }, + { + Slug: "manifold", + Classes: []Class{ + // AMM venue: the closest thing to a book is the bets feed. + {Name: "book", Interval: 7 * time.Second, Timeout: 8 * time.Second, + URL: func(p Pin) string { + return "https://api.manifold.markets/v0/bets?contractId=" + p.Market + "&limit=50" + }}, + {Name: "price", Interval: 7 * time.Second, Timeout: 8 * time.Second, + URL: func(p Pin) string { return "https://api.manifold.markets/v0/market/" + p.Market }}, + {Name: "list", Interval: 30 * time.Second, Timeout: 15 * time.Second, + URL: func(Pin) string { + return "https://api.manifold.markets/v0/markets?limit=50&sort=last-bet-time" + }}, + }, + PinFunc: pinManifold, + StalenessMs: stalenessManifold, + // Everything sits behind max-age=5 + swr=10 (Google frontend): probes + // spaced 7s per URL so we mostly see origin, cache label flags the rest. + // 500 req/min/IP documented, bots welcome: tiers clamped to 15/30/60. + RampRates: []int{15, 30, 60}, + }, + { + Slug: "myriad", + Classes: []Class{ + // No orderbook endpoint (AMM, no bets feed either): price + list only. + // That absence is a leaderboard column, not a gap in the harness. + {Name: "price", Interval: 5 * time.Second, Timeout: 8 * time.Second, + URL: func(p Pin) string { return "https://api-v2.myriadprotocol.com/markets/" + p.Market }}, + {Name: "list", Interval: 30 * time.Second, Timeout: 15 * time.Second, + URL: func(Pin) string { return "https://api-v2.myriadprotocol.com/markets?state=open&limit=20" }}, + }, + PinFunc: pinMyriad, + RampRates: nil, + }, + } +} + +// ProbeSource is one (venue, source) cell the aggregator harness probes +// once a tick. Source is the Prometheus label value ("mobula" | +// "predexon" | "codex"). AuthMutator stamps the credential header on the +// request (no-op when the key env var is empty). Throttle blocks until +// the next call may proceed (Predexon enforces a 1 rps global cap, the +// other providers leave it nil). Method defaults to GET; Codex needs +// POST with a GraphQL body, so BodyFunc + ContentType cover that. +type ProbeSource struct { + Venue string + Source string + Class string // probe class label, currently "price" + Interval time.Duration + Timeout time.Duration + URL func(pin Pin) string + AuthMutator func(*http.Request) + Throttle func() + Method string // "GET" (default) or "POST" + BodyFunc func(pin Pin) []byte + ContentType string +} + +// aggregatorProbes returns the (venue, source) pairs the harness probes +// alongside the direct venue gateways. Coverage as of build: +// - mobula: polymarket +// - predexon: polymarket, kalshi, limitless +// - codex: polymarket, kalshi (GraphQL POST + JWT) +// +// Codex pairs use Method=POST + BodyFunc to send the GraphQL query, and +// the AuthMutator mints (or reuses) a JWT from DEFINED_SESSION_COOKIE. +// They share the same venueState pin as the direct probes so every +// regression stays apples to apples. Codex's predictionMarketPrice +// marketId is Pin.Condition for Polymarket (the 0x-hex conditionId +// populated by pinPolymarket from gamma) and Pin.Market for Kalshi +// (the ticker, used directly). When DEFINED_SESSION_COOKIE is empty +// the Codex pairs aren't appended at all, so no probe ever runs without +// a credential path. +func aggregatorProbes(cfg Config) []ProbeSource { + var out []ProbeSource + if cfg.MobulaAPIKey != "" { + out = append(out, ProbeSource{ + Venue: "polymarket", Source: "mobula", Class: "price", + Interval: 5 * time.Second, + Timeout: 8 * time.Second, + URL: func(pin Pin) string { return mobulaPolymarketURL("polymarket", pin) }, + AuthMutator: mobulaAuth(cfg.MobulaAPIKey), + }) + } + if cfg.PredexonAPIKey != "" { + out = append(out, + ProbeSource{ + Venue: "polymarket", Source: "predexon", Class: "price", + Interval: 5 * time.Second, + Timeout: 8 * time.Second, + URL: func(pin Pin) string { return predexonPolymarketURL("polymarket", pin) }, + AuthMutator: predexonAuth(cfg.PredexonAPIKey), + Throttle: throttlePredexon, + }, + ProbeSource{ + Venue: "kalshi", Source: "predexon", Class: "price", + Interval: 5 * time.Second, + Timeout: 8 * time.Second, + URL: func(pin Pin) string { return predexonKalshiURL("kalshi", pin) }, + AuthMutator: predexonAuth(cfg.PredexonAPIKey), + Throttle: throttlePredexon, + }, + ProbeSource{ + Venue: "limitless", Source: "predexon", Class: "price", + Interval: 5 * time.Second, + Timeout: 8 * time.Second, + URL: func(pin Pin) string { return predexonLimitlessURL("limitless", pin) }, + AuthMutator: predexonAuth(cfg.PredexonAPIKey), + Throttle: throttlePredexon, + }, + ) + } + if cfg.DefinedSessionCookie != "" { + out = append(out, + ProbeSource{ + Venue: "polymarket", Source: "codex", Class: "price", + Interval: 5 * time.Second, + Timeout: 8 * time.Second, + URL: func(Pin) string { return codexGraphQLURL }, + BodyFunc: func(pin Pin) []byte { return codexBody(codexPolymarketMarketID(pin.Token)) }, + AuthMutator: codexAuth(cfg.DefinedSessionCookie), + Method: "POST", + ContentType: "application/json", + }, + ProbeSource{ + Venue: "kalshi", Source: "codex", Class: "price", + Interval: 5 * time.Second, + Timeout: 8 * time.Second, + URL: func(Pin) string { return codexGraphQLURL }, + BodyFunc: func(pin Pin) []byte { return codexBody(codexKalshiMarketID(pin.Market)) }, + AuthMutator: codexAuth(cfg.DefinedSessionCookie), + Method: "POST", + ContentType: "application/json", + }, + ) + } + return out +} diff --git a/harnesses/pm-rate-limits/cmd/script/ws.go b/harnesses/pm-rate-limits/cmd/script/ws.go new file mode 100644 index 000000000..8d5d48b72 --- /dev/null +++ b/harnesses/pm-rate-limits/cmd/script/ws.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "net/http" + "time" + + "nhooyr.io/websocket" +) + +// Polymarket is the only cohort venue with a public, timestamped market WS: +// Kalshi requires auth (absent by design, badged in the spec), Manifold and +// Myriad have none, Limitless is socket.io. Pattern vendored from +// pm-freshness-bench: subscribe by asset id, literal "PING" keepalive. +const polymarketWSURL = "wss://ws-subscriptions-clob.polymarket.com/ws/market" + +func runPolymarketWS(ctx context.Context, st *venueState) { + backoff := 5 * time.Second + for ctx.Err() == nil { + pin := st.getPin() + if pin.Token == "" { + time.Sleep(5 * time.Second) + continue + } + ok := wsSession(ctx, st, pin.Token) + if ok { + backoff = 5 * time.Second + } else { + backoff *= 2 + if backoff > 30*time.Second { + backoff = 30 * time.Second + } + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + } +} + +// wsSession returns true if the session got at least one data frame. +func wsSession(ctx context.Context, st *venueState, token string) bool { + dialStart := time.Now() + c, _, err := websocket.Dial(ctx, polymarketWSURL, &websocket.DialOptions{ + HTTPHeader: http.Header{"User-Agent": {userAgent}}, + }) + if err != nil { + log.Printf("[ws][polymarket] dial failed: %v", err) + return false + } + c.SetReadLimit(1 << 22) + defer c.Close(websocket.StatusNormalClosure, "bye") + + sub, _ := json.Marshal(map[string]any{"assets_ids": []string{token}, "type": "market"}) + if err := c.Write(ctx, websocket.MessageText, sub); err != nil { + log.Printf("[ws][polymarket] subscribe failed: %v", err) + return false + } + + sctx, cancel := context.WithCancel(ctx) + defer cancel() + go func() { + t := time.NewTicker(10 * time.Second) + defer t.Stop() + for { + select { + case <-sctx.Done(): + return + case <-t.C: + if st.getPin().Token != token { + // Pin changed: drop the session, the outer loop resubscribes. + c.Close(websocket.StatusNormalClosure, "repin") + return + } + if err := c.Write(sctx, websocket.MessageText, []byte("PING")); err != nil { + return + } + } + } + }() + + gotData := false + var lastMsg time.Time + for { + _, data, err := c.Read(sctx) + if err != nil { + if gotData && ctx.Err() == nil { + wsDisconnects.WithLabelValues("polymarket", currentRegion, sourceDirect).Inc() + log.Printf("[ws][polymarket] disconnected: %v", err) + } + return gotData + } + if string(data) == "PONG" { + continue + } + now := time.Now() + if !gotData { + gotData = true + wsConnectToSnapshot.WithLabelValues("polymarket", currentRegion, sourceDirect).Observe(now.Sub(dialStart).Seconds()) + } else if !lastMsg.IsZero() { + wsInterarrival.WithLabelValues("polymarket", currentRegion, sourceDirect).Observe(now.Sub(lastMsg).Seconds()) + } + lastMsg = now + } +} diff --git a/harnesses/pm-rate-limits/go.mod b/harnesses/pm-rate-limits/go.mod new file mode 100644 index 000000000..4ac2bdec5 --- /dev/null +++ b/harnesses/pm-rate-limits/go.mod @@ -0,0 +1,21 @@ +module pm-rate-limits + +go 1.24.0 + +require ( + github.com/prometheus/client_golang v1.23.2 + nhooyr.io/websocket v1.8.11 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/pm-rate-limits/go.sum b/harnesses/pm-rate-limits/go.sum new file mode 100644 index 000000000..5d20939ab --- /dev/null +++ b/harnesses/pm-rate-limits/go.sum @@ -0,0 +1,48 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= +nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/harnesses/pm-resolution-delay/Dockerfile b/harnesses/pm-resolution-delay/Dockerfile new file mode 100644 index 000000000..68c8278f9 --- /dev/null +++ b/harnesses/pm-resolution-delay/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/pm-resolution-delay/README.md b/harnesses/pm-resolution-delay/README.md new file mode 100644 index 000000000..3c6601f38 --- /dev/null +++ b/harnesses/pm-resolution-delay/README.md @@ -0,0 +1,124 @@ +# pm-resolution-delay + +OpenChainBench harness measuring how long Polymarket markets take to resolve, +the dispute rate, and the delay disputes add. Replaces the unsourced +"93% of Polymarket markets resolve within 2h" stat with a measured number. + +## What it measures + +For every Polymarket question on Polygon: + +``` +resolution_delay = QuestionResolved block timestamp + - first OO ProposePrice block timestamp (same questionID) +``` + +That is the time from "the outcome was submitted on-chain" to "holders can +redeem", which includes the UMA challenge window (~2h liveness) plus any +dispute rounds. A question is `disputed=true` if an OO `DisputePrice` or an +adapter `QuestionReset` was seen before resolution; disputed questions stay +anchored at their FIRST proposal so reset rounds lengthen the measured delay. + +### Why the anchor is the proposal, not Gamma's close fields + +Both obvious Gamma anchors are unusable, verified live on 2026-06-12: + +- `closedTime` is written AT resolution. For a sample market it matched the + `QuestionResolved` block timestamp to the second (`2026-06-12 17:21:34`). + Anchoring on it would measure ~0 for every market. +- `endDate` is a scheduled buffer: 59 of 100 recently resolved markets + resolved BEFORE their `endDate` (sports micro-markets carry a date days + after the game). Anchoring on it would produce negative delays. + +The first `ProposePrice` is the earliest on-chain moment the outcome is +known, which for sports lands minutes after the game ends. This also means +the popular "93% within 2h" claim cannot even be computed from Gamma fields, +which is part of the finding. + +## On-chain sources (fact-checked live, 2026-06-12) + +| Contract | Address | Events used | +|---|---|---| +| UmaCtfAdapter (binary) | `0x65070BE91477460D8A7AeEb94ef92fe056C2f2A7` | QuestionInitialized, QuestionResolved, QuestionReset | +| UmaCtfAdapter (neg-risk) | `0x69c47De9D4D3Dad79590d61b9e05918E03775f24` | same | +| Optimistic Oracle | `0x2c0367a9DB231dDEbD88a94b4f6461a6E47C58b1` | ProposePrice, DisputePrice (requester filtered to the adapters) | + +These are the post-migration deployments: Gamma's `resolvedBy` points at +them and they emit ~5.5k `QuestionResolved`/day, while the historical +V1/V2/V3 adapters are silent. The oracle address was located by tracing a +live `initialize()` transaction. New deployments are config +(`ADAPTER_ADDRESSES`, `OO_ADDRESSES`), not code. + +Join key: `questionID = keccak256(ancillaryData)` — verified live by hashing +a `ProposePrice` ancillary payload and matching the resulting questionID to +a `QuestionInitialized` log on the adapter. + +## Categories + +`sports | politics | crypto | other`. Primary source: tag slugs on Gamma +`/events` (tags only exist there; `/markets` returns `category: null`). +Fallback when a market resolves before its event was crawled: keyword match +on the UMA ancillary title (`q: title: ...`). Precedence sports > politics > +crypto. + +Known limitation: for neg-risk markets Gamma's `questionID` is the +NegRiskAdapter market id, NOT `keccak256(ancillaryData)` (verified live), so +the tag join misses them and they always classify via ancillary keywords. +The delay measurement itself is unaffected: the adapter's QuestionResolved +questionID always equals the keccak of the OO ancillary, for both adapters. + +## Prometheus metrics (`:2112/metrics`, namespace `pmres_`) + +| Metric | Labels | Meaning | +|---|---|---| +| `pmres_resolution_delay_seconds` | category, disputed | Histogram, buckets 5min..14d (2h is a bucket edge) | +| `pmres_resolutions_total` | category, disputed | Resolutions joined to a proposal | +| `pmres_disputes_total` | category | Questions disputed before resolution (once per question) | +| `pmres_pending_markets` | category | Open markets past their scheduled `endDate`, unresolved (30d lookback) | +| `pmres_oldest_pending_age_seconds` | | now − oldest pending `endDate` | +| `pmres_listener_health` | | 1 if logs polled OK in the last 5 min | +| `pmres_rpc_errors_total` | kind | JSON-RPC failures (http, rpc_error, decode, timeout) | + +`pmres_pending_markets` deliberately uses `endDate` (the only pre-resolution +anchor that exists); it counts markets whose scheduled end passed without +resolution, i.e. the genuinely late ones. + +## Restart semantics (no DB) + +On startup the harness rebuilds state from scratch: + +1. Deep-crawls recently closed Gamma events for the questionID→category map. +2. Binary-searches the Polygon block at `now − BACKFILL_HOURS` (default 7 + days) and replays adapter + oracle logs in `CHUNK_BLOCKS` (2000) chunks, + rotating across `RPC_URLS` with exponential backoff on 429/limits. +3. Then polls incrementally every `POLL_SECONDS` (45s). No websockets: + public Polygon WS is flaky. + +Consequences, by design: + +- Counters and histograms re-count the whole backfill window after every + restart. Use `rate()` / `increase()` over windows, and treat the restart + burst as a counter artifact (OCB's Prom queries already do). +- Resolutions whose proposal predates the backfill window are logged and + skipped (no fake delays). With proposal→resolution typically ~2h, a 7-day + window loses only long-disputed edge cases. +- Block timestamps inside a backfill chunk are linearly interpolated between + the chunk-boundary blocks' exact timestamps (2 RPC calls per chunk). + Polygon's steady ~2.1s cadence keeps the error far below the smallest + 300s bucket. +- Malformed payloads (Gamma or RPC) are logged and skipped, never fatal. + +All HTTP carries `User-Agent: OpenChainBench/1.0 +(+https://openchainbench.com/methodology; contact@mobula.io)`. + +## Env vars + +See `.env.example`. None are required; defaults are the verified live values. +`LOGS_TOKEN` enables the `GET :2112/logs?tail=N` ring-buffer endpoint +(header `X-Logs-Token`). + +## Deploy + +Railway, Dockerfile build, single service, single region (resolution delay +is not regional). Metrics listener hardcoded to `:2112` per OCB convention +($PORT is ignored). diff --git a/harnesses/pm-resolution-delay/cmd/script/chain.go b/harnesses/pm-resolution-delay/cmd/script/chain.go new file mode 100644 index 000000000..5fc245b7d --- /dev/null +++ b/harnesses/pm-resolution-delay/cmd/script/chain.go @@ -0,0 +1,417 @@ +package main + +import ( + "context" + "encoding/hex" + "fmt" + "log" + "sort" + "strings" + "sync" + "time" + + "golang.org/x/crypto/sha3" +) + +// Event topics, verified live on Polygon 2026-06-12 (keccak256 of the +// canonical signatures, cross-checked against logs emitted by the adapters +// and the Optimistic Oracle in the last 24h). +const ( + // UmaCtfAdapter: QuestionInitialized(bytes32 indexed questionID, uint256 + // indexed requestTimestamp, address indexed creator, bytes ancillaryData, + // address rewardToken, uint256 reward, uint256 proposalBond) + topicQuestionInitialized = "0xeee0897acd6893adcaf2ba5158191b3601098ab6bece35c5d57874340b64c5b7" + // QuestionResolved(bytes32 indexed questionID, int256 indexed + // settledPrice, uint256[] payouts) + topicQuestionResolved = "0x566c3fbdd12dd86bb341787f6d531f79fd7ad4ce7e3ae2d15ac0ca1b601af9df" + // QuestionReset(bytes32 indexed questionID) — emitted when a dispute + // resets the question for a fresh OO request. + topicQuestionReset = "0x7981b5832932948db4e32a4a16a0f44b2ce7ff088574afb9364b313f70f82e8f" + + // OptimisticOracleV2-shaped events on the oracle the adapters use. + // ProposePrice(address indexed requester, address indexed proposer, + // bytes32 identifier, uint256 timestamp, bytes ancillaryData, int256 + // proposedPrice, uint256 expirationTimestamp, address currency) + topicProposePrice = "0x6e51dd00371aabffa82cd401592f76ed51e98a9ea4b58751c70463a2c78b5ca1" + // DisputePrice(address indexed requester, address indexed proposer, + // address indexed disputer, bytes32 identifier, uint256 timestamp, + // bytes ancillaryData, int256 proposedPrice) + topicDisputePrice = "0x5165909c3d1c01c5d1e121ac6f6d01dda1ba24bc9e1f975b5a375339c15be7f3" +) + +// question tracks one UMA questionID from first sighting to resolution. +// The join key across adapter and oracle is questionID = +// keccak256(ancillaryData), verified live against QuestionInitialized logs. +type question struct { + title string // extracted from ancillary "q: title: ..." for keyword classification + firstSeen int64 + firstProposedAt int64 + disputed bool + firstDisputedAt int64 +} + +type engine struct { + rpc *rpcClient + gamma *gammaStore + + mu sync.Mutex + questions map[string]*question +} + +func newEngine(rpc *rpcClient, gamma *gammaStore) *engine { + return &engine{rpc: rpc, gamma: gamma, questions: map[string]*question{}} +} + +func (e *engine) run(ctx context.Context) { + head, err := e.waitHead(ctx) + if err != nil { + return // ctx cancelled + } + startTs := time.Now().Add(-time.Duration(backfillHours) * time.Hour).Unix() + startBlock, err := e.rpc.blockAtTime(ctx, startTs, head) + if err != nil { + log.Printf("[chain] block-at-time search failed: %v (falling back to head-%d)", err, backfillHours*1700) + approx := uint64(backfillHours) * 1700 // ~2.1s blocks + if approx >= head { + approx = head - 1 + } + startBlock = head - approx + } + log.Printf("[chain] backfill %d -> %d (~%dh, chunk=%d)", startBlock, head, backfillHours, chunkBlocks) + e.processRange(ctx, startBlock, head) + log.Printf("[chain] backfill done, %d open questions in memory", e.openQuestions()) + + last := head + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + cleanupTicker := time.NewTicker(time.Hour) + defer cleanupTicker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-cleanupTicker.C: + e.evictStale() + case <-ticker.C: + head, err := e.rpc.blockNumber(ctx) + if err != nil { + log.Printf("[chain] head poll failed: %v", err) + continue + } + if head <= last { + continue + } + e.processRange(ctx, last+1, head) + last = head + } + } +} + +func (e *engine) waitHead(ctx context.Context) (uint64, error) { + for { + head, err := e.rpc.blockNumber(ctx) + if err == nil { + return head, nil + } + log.Printf("[chain] cannot fetch head: %v (retry in 30s)", err) + select { + case <-ctx.Done(): + return 0, ctx.Err() + case <-time.After(30 * time.Second): + } + } +} + +// processRange walks [from, to] in free-RPC-friendly chunks. A failed chunk +// is logged and skipped (next restart's 7-day backfill re-covers it); a +// successful chunk refreshes the listener-health timestamp. +func (e *engine) processRange(ctx context.Context, from, to uint64) { + paddedAdapters := make([]string, len(adapterAddresses)) + for i, a := range adapterAddresses { + paddedAdapters[i] = "0x" + strings.Repeat("0", 24) + strings.TrimPrefix(a, "0x") + } + for start := from; start <= to && ctx.Err() == nil; start += uint64(chunkBlocks) { + end := start + uint64(chunkBlocks) - 1 + if end > to { + end = to + } + adapterLogs, err1 := e.rpc.getLogs(ctx, start, end, adapterAddresses, + []any{[]string{topicQuestionInitialized, topicQuestionResolved, topicQuestionReset}}) + ooLogs, err2 := e.rpc.getLogs(ctx, start, end, ooAddresses, + []any{[]string{topicProposePrice, topicDisputePrice}, paddedAdapters}) + if err1 != nil || err2 != nil { + log.Printf("[chain] chunk %d-%d failed (adapter=%v oo=%v), skipping", start, end, err1, err2) + continue + } + tsAt, err := e.interpolator(ctx, start, end) + if err != nil { + log.Printf("[chain] chunk %d-%d timestamp anchors failed: %v, skipping", start, end, err) + continue + } + logs := append(ooLogs, adapterLogs...) + sort.SliceStable(logs, func(i, j int) bool { + bi, _ := parseHexUint(logs[i].BlockNumber) + bj, _ := parseHexUint(logs[j].BlockNumber) + return bi < bj + }) + for _, l := range logs { + e.handleLog(l, tsAt) + } + lastPollOK.Store(time.Now().Unix()) + if end-start > 100 { // only log chunk progress during backfill + log.Printf("[chain] chunk %d-%d: %d adapter logs, %d oo logs", start, end, len(adapterLogs), len(ooLogs)) + } + } +} + +// interpolator returns a blockNumber -> unix-timestamp function backed by the +// exact timestamps of the chunk boundaries with linear interpolation inside. +// Polygon's ~2.1s cadence keeps the error well under a minute per 2000-block +// chunk, far below the smallest histogram bucket (300s), while costing only +// two eth_getBlockByNumber calls per chunk. +func (e *engine) interpolator(ctx context.Context, from, to uint64) (func(uint64) int64, error) { + tsFrom, err := e.rpc.blockTimestamp(ctx, from) + if err != nil { + return nil, err + } + if from == to { + return func(uint64) int64 { return tsFrom }, nil + } + tsTo, err := e.rpc.blockTimestamp(ctx, to) + if err != nil { + return nil, err + } + span := float64(to - from) + return func(b uint64) int64 { + if b <= from { + return tsFrom + } + if b >= to { + return tsTo + } + return tsFrom + int64(float64(tsTo-tsFrom)*float64(b-from)/span) + }, nil +} + +func (e *engine) handleLog(l rpcLog, tsAt func(uint64) int64) { + defer func() { + if r := recover(); r != nil { + log.Printf("[chain] panic handling log tx=%s: %v (skipped)", l.TxHash, r) + } + }() + if l.Removed || len(l.Topics) == 0 { + return + } + blk, err := parseHexUint(l.BlockNumber) + if err != nil { + log.Printf("[chain] malformed blockNumber %q, skipping", l.BlockNumber) + return + } + ts := tsAt(blk) + + switch l.Topics[0] { + case topicQuestionInitialized: + if len(l.Topics) < 2 { + return + } + e.ensure(strings.ToLower(l.Topics[1]), ts) + case topicProposePrice: + anc, err := parseAncillary(l.Data, 2) + if err != nil { + log.Printf("[chain] bad ProposePrice data tx=%s: %v", l.TxHash, err) + return + } + qid := keccakHex(anc) + q := e.ensure(qid, ts) + e.mu.Lock() + if q.firstProposedAt == 0 { + q.firstProposedAt = ts + } + if q.title == "" { + q.title = ancillaryTitle(anc) + } + e.mu.Unlock() + case topicDisputePrice: + anc, err := parseAncillary(l.Data, 2) + if err != nil { + log.Printf("[chain] bad DisputePrice data tx=%s: %v", l.TxHash, err) + return + } + e.markDisputed(keccakHex(anc), ts, "oo_dispute") + case topicQuestionReset: + if len(l.Topics) < 2 { + return + } + e.markDisputed(strings.ToLower(l.Topics[1]), ts, "adapter_reset") + case topicQuestionResolved: + if len(l.Topics) < 2 { + return + } + e.resolve(strings.ToLower(l.Topics[1]), ts) + } +} + +func (e *engine) ensure(qid string, ts int64) *question { + e.mu.Lock() + defer e.mu.Unlock() + q, ok := e.questions[qid] + if !ok { + q = &question{firstSeen: ts} + e.questions[qid] = q + } + return q +} + +func (e *engine) markDisputed(qid string, ts int64, via string) { + e.mu.Lock() + q, ok := e.questions[qid] + if !ok { + e.mu.Unlock() + log.Printf("[dispute] %s for unknown question %s (proposal outside backfill window?)", via, qid) + return + } + if q.disputed { + e.mu.Unlock() + return // count a question once, not per dispute round + } + q.disputed = true + q.firstDisputedAt = ts + title := q.title + e.mu.Unlock() + cat := e.category(qid, title) + disputesTotal.WithLabelValues(cat).Inc() + log.Printf("[dispute] qid=%s via=%s category=%s title=%q at=%s", qid, via, cat, title, time.Unix(ts, 0).UTC().Format(time.RFC3339)) +} + +func (e *engine) resolve(qid string, ts int64) { + e.mu.Lock() + q, ok := e.questions[qid] + if ok { + delete(e.questions, qid) + } + e.mu.Unlock() + if !ok || q.firstProposedAt == 0 { + log.Printf("[resolve] skipping %s: no proposal in window (resolved at %s)", qid, time.Unix(ts, 0).UTC().Format(time.RFC3339)) + return + } + delay := ts - q.firstProposedAt + if delay < 0 || delay > 90*24*3600 { + log.Printf("[resolve] skipping %s: implausible delay %ds", qid, delay) + return + } + cat := e.category(qid, q.title) + disputed := "false" + extra := "" + if q.disputed { + disputed = "true" + extra = fmt.Sprintf(" dispute_extra=%ds", ts-q.firstDisputedAt) + } + resolutionDelay.WithLabelValues(cat, disputed).Observe(float64(delay)) + resolutionsTotal.WithLabelValues(cat, disputed).Inc() + log.Printf("[resolve] qid=%s category=%s disputed=%s delay=%ds proposed=%s resolved=%s%s title=%q", + qid, cat, disputed, delay, + time.Unix(q.firstProposedAt, 0).UTC().Format(time.RFC3339), + time.Unix(ts, 0).UTC().Format(time.RFC3339), extra, q.title) +} + +// category prefers Gamma event tags (canonical) and falls back to keyword +// classification of the UMA ancillary title. +func (e *engine) category(qid, title string) string { + if cat, ok := e.gamma.category(qid); ok { + return cat + } + return classifyText(title) +} + +func (e *engine) openQuestions() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.questions) +} + +// evictStale drops questions that never resolved within 30 days so the map +// stays bounded over months of uptime. +func (e *engine) evictStale() { + cutoff := time.Now().Add(-30 * 24 * time.Hour).Unix() + e.mu.Lock() + n := 0 + for qid, q := range e.questions { + if q.firstSeen < cutoff { + delete(e.questions, qid) + n++ + } + } + total := len(e.questions) + e.mu.Unlock() + if n > 0 { + log.Printf("[chain] evicted %d stale questions (>30d unresolved), %d tracked", n, total) + } +} + +// parseAncillary extracts the dynamic `bytes ancillaryData` argument from +// ABI-encoded event data, given its slot index among the non-indexed args +// (slot 2 for both ProposePrice and DisputePrice). +func parseAncillary(dataHex string, slot int) ([]byte, error) { + raw, err := hex.DecodeString(strings.TrimPrefix(dataHex, "0x")) + if err != nil { + return nil, fmt.Errorf("hex: %w", err) + } + if len(raw) < (slot+1)*32 { + return nil, fmt.Errorf("data too short (%d bytes)", len(raw)) + } + off := beUint(raw[slot*32 : (slot+1)*32]) + if off+32 > uint64(len(raw)) { + return nil, fmt.Errorf("ancillary offset %d out of range", off) + } + ln := beUint(raw[off : off+32]) + if off+32+ln > uint64(len(raw)) { + return nil, fmt.Errorf("ancillary length %d out of range", ln) + } + return raw[off+32 : off+32+ln], nil +} + +func beUint(b []byte) uint64 { + var n uint64 + for _, c := range b[len(b)-8:] { + n = n<<8 | uint64(c) + } + // guard: any nonzero byte above the low 8 means a value we treat as overflow + for _, c := range b[:len(b)-8] { + if c != 0 { + return ^uint64(0) + } + } + return n +} + +// keccakHex returns 0x-prefixed keccak256 of b. questionID = +// keccak256(ancillaryData) is the documented UmaCtfAdapter derivation, +// verified live: keccak of a ProposePrice ancillary matched the +// QuestionInitialized questionID on the adapter. +func keccakHex(b []byte) string { + h := sha3.NewLegacyKeccak256() + h.Write(b) + return "0x" + hex.EncodeToString(h.Sum(nil)) +} + +// ancillaryTitle extracts the human title from Polymarket ancillary data, +// which starts with `q: title: , description: ...`. +func ancillaryTitle(anc []byte) string { + s := string(anc) + i := strings.Index(s, "title:") + if i < 0 { + if len(s) > 120 { + return s[:120] + } + return s + } + s = s[i+len("title:"):] + if j := strings.Index(s, ", description:"); j >= 0 { + s = s[:j] + } + s = strings.TrimSpace(s) + if len(s) > 160 { + s = s[:160] + } + return s +} diff --git a/harnesses/pm-resolution-delay/cmd/script/classify.go b/harnesses/pm-resolution-delay/cmd/script/classify.go new file mode 100644 index 000000000..3fc29bea1 --- /dev/null +++ b/harnesses/pm-resolution-delay/cmd/script/classify.go @@ -0,0 +1,83 @@ +package main + +import "strings" + +// Simple keyword classifier into sports|politics|crypto|other. Gamma event +// tag slugs are checked first (canonical, e.g. "tennis", "politics", +// "crypto"); the UMA ancillary title is the fallback when a market resolves +// before we crawled its Gamma event. Precedence sports > politics > crypto: +// sports keywords are the most specific, and mixed cases ("Will Trump pardon +// SBF") should land on the political anchor rather than crypto. + +var sportsKeywords = []string{ + " vs ", " vs. ", "o/u", "over/under", "moneyline", "spread:", + "nba", "nfl", "mlb", "nhl", "ncaa", "wnba", "epl", "premier league", + "la liga", "serie a", "bundesliga", "ligue 1", "champions league", + "europa league", "uefa", "fifa", "world cup", "copa", "mls", + "atp", "wta", "itf", "tennis", "wimbledon", "roland garros", "us open", + "australian open", "grand slam", "set winner", "match winner", + "ufc", "mma", "boxing", "f1", "formula 1", "grand prix", "nascar", + "pga", "golf", "olympic", "super bowl", "stanley cup", "world series", + "playoffs", "touchdown", "home run", "innings", "rebounds", "assists", + "esports", "dota", "cs2", "csgo", "counter-strike", "league of legends", + "valorant", "overwatch", "map 1", "map 2", "first blood", "barracks", + "soccer", "football", "basketball", "baseball", "hockey", "cricket", + "rugby", "darts", "snooker", "cycling", "marathon", +} + +var politicsKeywords = []string{ + "politics", "election", "president", "presidency", "presidential", + "senate", "senator", "congress", "house of representatives", "parliament", + "prime minister", "chancellor", "mayor", "governor", "minister", + "impeach", "referendum", "coalition", "nominee", "nomination", "cabinet", + "supreme court", "legislation", "bill passes", "veto", "executive order", + "ceasefire", "peace deal", "sanctions", "nato", "united nations", + "geopolitics", "tariff", "white house", "kremlin", "vote share", + "electoral", "ballot", "primaries", "primary winner", "approval rating", +} + +var cryptoKeywords = []string{ + "crypto", "bitcoin", "btc", "ethereum", " eth ", "solana", " sol ", + "xrp", "doge", "cardano", "token", "stablecoin", "defi", "nft", + "airdrop", "fdv", "market cap of $", "binance", "coinbase", "tether", + "satoshi", "halving", "etf approval", "blockchain", "memecoin", + "altcoin", "all time high", "hit $", "dip to $", +} + +func matchAny(text string, kws []string) bool { + for _, k := range kws { + if strings.Contains(text, k) { + return true + } + } + return false +} + +// classifyText buckets free text (ancillary title, Gamma question, event +// title). Input is padded with spaces so word-boundary keywords like " eth " +// can match at the edges. +func classifyText(text string) string { + t := " " + strings.ToLower(text) + " " + switch { + case matchAny(t, sportsKeywords): + return "sports" + case matchAny(t, politicsKeywords): + return "politics" + case matchAny(t, cryptoKeywords): + return "crypto" + default: + return "other" + } +} + +// classifyTags buckets a Gamma event from its tag slugs/labels. +func classifyTags(tags []gammaTag) string { + var joined strings.Builder + for _, t := range tags { + joined.WriteString(" ") + joined.WriteString(strings.ToLower(t.Slug)) + joined.WriteString(" ") + joined.WriteString(strings.ToLower(t.Label)) + } + return classifyText(joined.String()) +} diff --git a/harnesses/pm-resolution-delay/cmd/script/config.go b/harnesses/pm-resolution-delay/cmd/script/config.go new file mode 100644 index 000000000..4239ffaba --- /dev/null +++ b/harnesses/pm-resolution-delay/cmd/script/config.go @@ -0,0 +1,79 @@ +package main + +import ( + "os" + "strconv" + "strings" + "time" +) + +// userAgent identifies every request per the OCB methodology page. +const userAgent = "OpenChainBench/1.0 (+https://openchainbench.com/methodology; contact@mobula.io)" + +// Fact-checked live 2026-06-12 against Polygon mainnet: +// - 0x65070BE91477460D8A7AeEb94ef92fe056C2f2A7 (UmaCtfAdapter, binary +// markets, ~4.9k QuestionResolved/day) +// - 0x69c47De9D4D3Dad79590d61b9e05918E03775f24 (UmaCtfAdapter, neg-risk +// variant, ~0.7k QuestionResolved/day) +// +// These are the post-migration deployments (Gamma `resolvedBy` points at +// them); the historical V1/V2/V3 adapters (0xCB18..., 0x2F5e..., 0x6A9D...) +// are silent. New deployments are config, not code: override via +// ADAPTER_ADDRESSES. +const defaultAdapters = "0x65070be91477460d8a7aeeb94ef92fe056c2f2a7,0x69c47de9d4d3dad79590d61b9e05918e03775f24" + +// UMA Optimistic Oracle the adapters above request prices from. Located by +// tracing a live initialize() tx; emits OOV2-shaped RequestPrice / +// ProposePrice / DisputePrice / Settle events. Override via OO_ADDRESSES. +const defaultOO = "0x2c0367a9db231ddebd88a94b4f6461a6e47c58b1" + +// Free public Polygon RPCs verified to serve eth_getLogs (tenderly is the +// most reliable; publicnode times out on large ranges but works as backup). +const defaultRPCs = "https://gateway.tenderly.co/public/polygon,https://polygon-bor-rpc.publicnode.com" + +const gammaBase = "https://gamma-api.polymarket.com" + +func envDefault(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func envInt(key string, fallback int) int { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + return fallback +} + +func envList(key, fallback string) []string { + raw := envDefault(key, fallback) + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.ToLower(strings.TrimSpace(p)) + if p != "" { + out = append(out, p) + } + } + return out +} + +var ( + adapterAddresses = envList("ADAPTER_ADDRESSES", defaultAdapters) + ooAddresses = envList("OO_ADDRESSES", defaultOO) + rpcURLs = envList("RPC_URLS", defaultRPCs) + + // backfillHours: how far back to rebuild state on startup. 7 days keeps + // restarts cheap on free RPCs while re-covering any gap. + backfillHours = envInt("BACKFILL_HOURS", 168) + // chunkBlocks: eth_getLogs range per request. Free tiers cap at 2-10k. + chunkBlocks = envInt("CHUNK_BLOCKS", 2000) + // pollSeconds: incremental log poll cadence. + pollSeconds = envInt("POLL_SECONDS", 45) + + pollInterval = time.Duration(pollSeconds) * time.Second +) diff --git a/harnesses/pm-resolution-delay/cmd/script/gamma.go b/harnesses/pm-resolution-delay/cmd/script/gamma.go new file mode 100644 index 000000000..0203c03e3 --- /dev/null +++ b/harnesses/pm-resolution-delay/cmd/script/gamma.go @@ -0,0 +1,252 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// Gamma API client. Two jobs: +// 1. categoryLoop: crawl recently-closed events (which embed both tags and +// markets) to map questionID -> category. Tags only exist on the /events +// endpoint; /markets returns category=null (verified live 2026-06-12). +// 2. pendingLoop: count markets past their scheduled endDate that are still +// open and unresolved (the markets users are waiting on right now). +// +// Gamma's closedTime is written AT resolution (== QuestionResolved block +// timestamp, verified live), so it is never used as a delay anchor here. + +type gammaTag struct { + Slug string `json:"slug"` + Label string `json:"label"` +} + +type gammaMarket struct { + QuestionID string `json:"questionID"` + ConditionID string `json:"conditionId"` + Question string `json:"question"` + EndDate string `json:"endDate"` + Closed bool `json:"closed"` + UmaResolutionStatus string `json:"umaResolutionStatus"` + Events []struct { + Title string `json:"title"` + Slug string `json:"slug"` + } `json:"events"` +} + +type gammaEvent struct { + Tags []gammaTag `json:"tags"` + Markets []gammaMarket `json:"markets"` +} + +type gammaStore struct { + http *http.Client + + mu sync.Mutex + byQID map[string]string // questionID -> category +} + +func newGammaStore() *gammaStore { + return &gammaStore{ + http: &http.Client{Timeout: 30 * time.Second}, + byQID: map[string]string{}, + } +} + +func (g *gammaStore) category(qid string) (string, bool) { + g.mu.Lock() + defer g.mu.Unlock() + cat, ok := g.byQID[strings.ToLower(qid)] + return cat, ok +} + +func (g *gammaStore) get(ctx context.Context, path string, params url.Values, out any) error { + u := gammaBase + path + "?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", userAgent) + resp, err := g.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("gamma %s: http %d: %.120s", path, resp.StatusCode, raw) + } + return json.Unmarshal(raw, out) +} + +// crawlCategories pages recently-closed events (newest closedTime first) and +// records questionID -> category. Offset pagination is deprecated on Gamma +// but still honored for the shallow pages we use (<=2000 rows); the keyword +// fallback on ancillary titles covers anything we miss. +func (g *gammaStore) crawlCategories(ctx context.Context, pages int) { + added := 0 + for page := 0; page < pages && ctx.Err() == nil; page++ { + params := url.Values{} + params.Set("closed", "true") + params.Set("order", "closedTime") + params.Set("ascending", "false") + params.Set("limit", "100") + params.Set("offset", fmt.Sprintf("%d", page*100)) + var events []gammaEvent + if err := g.get(ctx, "/events", params, &events); err != nil { + log.Printf("[gamma] category crawl page %d failed: %v", page, err) + return + } + if len(events) == 0 { + break + } + g.mu.Lock() + if len(g.byQID) > 200000 { // bound memory over months + g.byQID = map[string]string{} + } + for _, e := range events { + cat := classifyTags(e.Tags) + for _, m := range e.Markets { + if m.QuestionID == "" { + continue + } + g.byQID[strings.ToLower(m.QuestionID)] = cat + added++ + } + } + g.mu.Unlock() + } + log.Printf("[gamma] category crawl done: %d question->category mappings stored", g.size()) + _ = added +} + +func (g *gammaStore) size() int { + g.mu.Lock() + defer g.mu.Unlock() + return len(g.byQID) +} + +// categoryLoop keeps the freshest closed-event pages warm. The deep startup +// crawl happens in main before the chain backfill starts. +func (g *gammaStore) categoryLoop(ctx context.Context) { + t := time.NewTicker(10 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + g.crawlCategories(ctx, 3) + } + } +} + +// refreshPending counts open (closed=false) markets whose scheduled endDate +// already passed, looking back 30 days. Keyset pagination on endDate +// descending (offset-free): each page moves end_date_max to the smallest +// endDate seen minus one second. +func (g *gammaStore) refreshPending(ctx context.Context) { + now := time.Now().UTC() + minDate := now.Add(-30 * 24 * time.Hour) + cursor := now + counts := map[string]int{"sports": 0, "politics": 0, "crypto": 0, "other": 0} + var oldest time.Time + seen := map[string]bool{} + + for page := 0; page < 40 && ctx.Err() == nil; page++ { + params := url.Values{} + params.Set("closed", "false") + params.Set("order", "endDate") + params.Set("ascending", "false") + params.Set("limit", "100") + params.Set("end_date_min", minDate.Format(time.RFC3339)) + params.Set("end_date_max", cursor.Format(time.RFC3339)) + var markets []gammaMarket + if err := g.get(ctx, "/markets", params, &markets); err != nil { + log.Printf("[gamma] pending crawl failed: %v (keeping previous gauges)", err) + return + } + if len(markets) == 0 { + break + } + var pageMin time.Time + for _, m := range markets { + ed, err := time.Parse(time.RFC3339, m.EndDate) + if err != nil { + log.Printf("[gamma] unparseable endDate %q on %q, skipping", m.EndDate, m.Question) + continue + } + if pageMin.IsZero() || ed.Before(pageMin) { + pageMin = ed + } + key := m.QuestionID + if key == "" { + key = m.ConditionID + } + if key == "" || seen[key] { + continue + } + seen[key] = true + if strings.EqualFold(m.UmaResolutionStatus, "resolved") { + continue + } + var texts strings.Builder + texts.WriteString(m.Question) + for _, ev := range m.Events { + texts.WriteString(" ") + texts.WriteString(ev.Title) + texts.WriteString(" ") + texts.WriteString(ev.Slug) + } + if c, ok := g.category(m.QuestionID); ok { + counts[c]++ + } else { + counts[classifyText(texts.String())]++ + } + if oldest.IsZero() || ed.Before(oldest) { + oldest = ed + } + } + if pageMin.IsZero() || !pageMin.Before(cursor) { + break // no progress, avoid looping on identical endDates + } + cursor = pageMin.Add(-time.Second) + } + + total := 0 + for cat, n := range counts { + pendingMarkets.WithLabelValues(cat).Set(float64(n)) + total += n + } + if oldest.IsZero() { + oldestPendingAge.Set(0) + } else { + oldestPendingAge.Set(now.Sub(oldest).Seconds()) + } + log.Printf("[gamma] pending: %d markets past endDate unresolved (sports=%d politics=%d crypto=%d other=%d, oldest=%s)", + total, counts["sports"], counts["politics"], counts["crypto"], counts["other"], oldest.Format(time.RFC3339)) +} + +func (g *gammaStore) pendingLoop(ctx context.Context) { + g.refreshPending(ctx) + t := time.NewTicker(5 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + g.refreshPending(ctx) + } + } +} diff --git a/harnesses/pm-resolution-delay/cmd/script/loghub.go b/harnesses/pm-resolution-delay/cmd/script/loghub.go new file mode 100644 index 000000000..9dd744888 --- /dev/null +++ b/harnesses/pm-resolution-delay/cmd/script/loghub.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a +// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token +// matching the LOGS_TOKEN env var. +// +// Keep in sync across miniapps (was previously the shared/loghub package; we +// inline because Railway's per-harness Docker build context can't reach a +// sibling shared module via go.mod replace). + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a +// pipe, then spawns a goroutine that fan-outs every line to the original +// stdout AND the in-memory ring buffer. Call exactly once, very early in +// main(). +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header +// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset. +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/pm-resolution-delay/cmd/script/main.go b/harnesses/pm-resolution-delay/cmd/script/main.go new file mode 100644 index 000000000..99df9185b --- /dev/null +++ b/harnesses/pm-resolution-delay/cmd/script/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" +) + +func main() { + installLogCapture() + log.SetFlags(0) + log.Printf("[pm-resolution-delay] starting, adapters=%v oo=%v rpcs=%d backfill=%dh poll=%s", + adapterAddresses, ooAddresses, len(rpcURLs), backfillHours, pollInterval) + + go func() { + if err := StartMetricsServer(":2112"); err != nil { + log.Printf("[pm-resolution-delay] metrics server died: %v", err) + os.Exit(1) + } + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + gamma := newGammaStore() + rpc := newRPCClient(rpcURLs) + eng := newEngine(rpc, gamma) + + go healthLoop() + go gamma.pendingLoop(ctx) + go func() { + // Category map first, then the chain engine: backfilled resolutions + // join against Gamma tags instead of falling back to keywords. + gamma.crawlCategories(ctx, 20) + go gamma.categoryLoop(ctx) + eng.run(ctx) + }() + + <-ctx.Done() + log.Printf("[pm-resolution-delay] shutting down") +} diff --git a/harnesses/pm-resolution-delay/cmd/script/metrics.go b/harnesses/pm-resolution-delay/cmd/script/metrics.go new file mode 100644 index 000000000..7a75aa927 --- /dev/null +++ b/harnesses/pm-resolution-delay/cmd/script/metrics.go @@ -0,0 +1,90 @@ +package main + +import ( + "net/http" + "sync/atomic" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// pmres_* namespace: Polymarket resolution-delay bench. Delay is anchored at +// the FIRST Optimistic Oracle ProposePrice for the question (the moment the +// outcome was submitted on-chain) because Gamma's closedTime is written AT +// resolution (verified live: closedTime == QuestionResolved block timestamp) +// and endDate is a scheduled buffer that 59% of markets resolve before. +var ( + // 5min .. 14 days. 7200 (=2h) sits on a bucket edge on purpose: the UMA + // challenge window is 2h, so "resolved within 2h of proposal" is the + // claim the bench fact-checks. + delayBuckets = []float64{300, 900, 1800, 3600, 7200, 14400, 43200, 86400, 172800, 604800, 1209600} + + resolutionDelay = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "pmres_resolution_delay_seconds", + Help: "QuestionResolved block timestamp minus first OO ProposePrice block timestamp for the same questionID. Includes the UMA challenge window (~2h), so the floor is the market's liveness. disputed=true means at least one OO DisputePrice / adapter QuestionReset occurred before resolution.", + Buckets: delayBuckets, + }, []string{"category", "disputed"}) + + resolutionsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "pmres_resolutions_total", + Help: "QuestionResolved events observed and successfully joined to a proposal. Re-counts the 7-day backfill window after a restart (no DB), so prefer rate()/increase() over raw values.", + }, []string{"category", "disputed"}) + + disputesTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "pmres_disputes_total", + Help: "Questions that saw their first OO DisputePrice (or adapter QuestionReset) before resolution. Counted once per question, not per dispute round.", + }, []string{"category"}) + + pendingMarkets = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "pmres_pending_markets", + Help: "Gamma markets past their scheduled endDate, still open (closed=false) and not resolved, capped at 30 days overdue. These are the markets users are currently waiting on.", + }, []string{"category"}) + + oldestPendingAge = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "pmres_oldest_pending_age_seconds", + Help: "now minus the oldest endDate among pending markets (within the 30-day lookback).", + }) + + listenerHealth = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "pmres_listener_health", + Help: "1 if Polygon logs were polled successfully in the last 5 minutes.", + }) + + rpcErrors = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "pmres_rpc_errors_total", + Help: "Polygon JSON-RPC failures by kind: http, rpc_error, decode, timeout.", + }, []string{"kind"}) +) + +// lastPollOK is the unix time of the last successful incremental log poll. +var lastPollOK atomic.Int64 + +func healthLoop() { + t := time.NewTicker(15 * time.Second) + defer t.Stop() + for range t.C { + if time.Since(time.Unix(lastPollOK.Load(), 0)) < 5*time.Minute { + listenerHealth.Set(1) + } else { + listenerHealth.Set(0) + } + } +} + +// StartMetricsServer binds /metrics + /health + /logs on addr. Blocking call, +// run in its own goroutine. :2112 is the OCB convention; Railway's $PORT is +// deliberately ignored so the shared Prometheus always finds the listener. +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("pm-resolution-delay harness · OpenChainBench")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/pm-resolution-delay/cmd/script/rpc.go b/harnesses/pm-resolution-delay/cmd/script/rpc.go new file mode 100644 index 000000000..4398c66d8 --- /dev/null +++ b/harnesses/pm-resolution-delay/cmd/script/rpc.go @@ -0,0 +1,224 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// Minimal JSON-RPC client over a rotating pool of free public Polygon RPCs. +// Free tiers throttle and cap eth_getLogs ranges, so every call retries with +// backoff and rotates to the next URL on failure. + +type rpcClient struct { + urls []string + http *http.Client + mu sync.Mutex + cursor int + + tsMu sync.Mutex + tsCache map[uint64]int64 // block number -> unix timestamp +} + +func newRPCClient(urls []string) *rpcClient { + return &rpcClient{ + urls: urls, + http: &http.Client{Timeout: 45 * time.Second}, + tsCache: map[uint64]int64{}, + } +} + +type rpcLog struct { + Address string `json:"address"` + Topics []string `json:"topics"` + Data string `json:"data"` + BlockNumber string `json:"blockNumber"` + TxHash string `json:"transactionHash"` + Removed bool `json:"removed"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (c *rpcClient) nextURL() string { + c.mu.Lock() + defer c.mu.Unlock() + u := c.urls[c.cursor%len(c.urls)] + c.cursor++ + return u +} + +// call issues one JSON-RPC request, rotating across URLs with backoff. +// Up to 3 attempts per URL across the pool. +func (c *rpcClient) call(ctx context.Context, method string, params any, out any) error { + var lastErr error + attempts := 3 * len(c.urls) + backoff := 2 * time.Second + for i := 0; i < attempts; i++ { + if ctx.Err() != nil { + return ctx.Err() + } + url := c.nextURL() + err := c.callOne(ctx, url, method, params, out) + if err == nil { + return nil + } + lastErr = err + log.Printf("[rpc] %s on %s failed (attempt %d/%d): %v", method, url, i+1, attempts, err) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + } + return lastErr +} + +func (c *rpcClient) callOne(ctx context.Context, url, method string, params any, out any) error { + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": method, "params": params, + }) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent) + + resp, err := c.http.Do(req) + if err != nil { + kind := "http" + if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "Client.Timeout") { + kind = "timeout" + } + rpcErrors.WithLabelValues(kind).Inc() + return err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + if err != nil { + rpcErrors.WithLabelValues("http").Inc() + return err + } + if resp.StatusCode == http.StatusTooManyRequests { + rpcErrors.WithLabelValues("rpc_error").Inc() + return fmt.Errorf("throttled (429)") + } + if resp.StatusCode != http.StatusOK { + rpcErrors.WithLabelValues("http").Inc() + return fmt.Errorf("http %d: %.120s", resp.StatusCode, raw) + } + var envelope struct { + Result json.RawMessage `json:"result"` + Error *rpcError `json:"error"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + rpcErrors.WithLabelValues("decode").Inc() + return fmt.Errorf("decode: %v (%.120s)", err, raw) + } + if envelope.Error != nil { + rpcErrors.WithLabelValues("rpc_error").Inc() + return fmt.Errorf("rpc %d: %s", envelope.Error.Code, envelope.Error.Message) + } + if envelope.Result == nil || string(envelope.Result) == "null" { + rpcErrors.WithLabelValues("decode").Inc() + return fmt.Errorf("null result") + } + return json.Unmarshal(envelope.Result, out) +} + +func (c *rpcClient) blockNumber(ctx context.Context) (uint64, error) { + var hexNum string + if err := c.call(ctx, "eth_blockNumber", []any{}, &hexNum); err != nil { + return 0, err + } + return parseHexUint(hexNum) +} + +// blockTimestamp returns the unix timestamp of a block, cached forever +// (Polygon blocks are final well before we read them). +func (c *rpcClient) blockTimestamp(ctx context.Context, num uint64) (int64, error) { + c.tsMu.Lock() + if ts, ok := c.tsCache[num]; ok { + c.tsMu.Unlock() + return ts, nil + } + c.tsMu.Unlock() + + var blk struct { + Timestamp string `json:"timestamp"` + } + if err := c.call(ctx, "eth_getBlockByNumber", []any{hexUint(num), false}, &blk); err != nil { + return 0, err + } + tsU, err := parseHexUint(blk.Timestamp) + if err != nil { + return 0, err + } + ts := int64(tsU) + c.tsMu.Lock() + if len(c.tsCache) > 50000 { // bound memory over months of uptime + c.tsCache = map[uint64]int64{} + } + c.tsCache[num] = ts + c.tsMu.Unlock() + return ts, nil +} + +func (c *rpcClient) getLogs(ctx context.Context, from, to uint64, addresses []string, topics []any) ([]rpcLog, error) { + filter := map[string]any{ + "fromBlock": hexUint(from), + "toBlock": hexUint(to), + "address": addresses, + } + if topics != nil { + filter["topics"] = topics + } + var logs []rpcLog + if err := c.call(ctx, "eth_getLogs", []any{filter}, &logs); err != nil { + return nil, err + } + return logs, nil +} + +// blockAtTime binary-searches the chain for the first block at or after the +// target unix timestamp. ~25 timestamp lookups, all cached. +func (c *rpcClient) blockAtTime(ctx context.Context, target int64, head uint64) (uint64, error) { + lo, hi := uint64(1), head + for lo < hi { + mid := (lo + hi) / 2 + ts, err := c.blockTimestamp(ctx, mid) + if err != nil { + return 0, err + } + if ts < target { + lo = mid + 1 + } else { + hi = mid + } + } + return lo, nil +} + +func hexUint(n uint64) string { return "0x" + strconv.FormatUint(n, 16) } + +func parseHexUint(s string) (uint64, error) { + return strconv.ParseUint(strings.TrimPrefix(s, "0x"), 16, 64) +} diff --git a/harnesses/pm-resolution-delay/go.mod b/harnesses/pm-resolution-delay/go.mod new file mode 100644 index 000000000..15ee88fa2 --- /dev/null +++ b/harnesses/pm-resolution-delay/go.mod @@ -0,0 +1,21 @@ +module pm-resolution-delay + +go 1.25.0 + +require ( + github.com/prometheus/client_golang v1.23.2 + golang.org/x/crypto v0.53.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.46.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/pm-resolution-delay/go.sum b/harnesses/pm-resolution-delay/go.sum new file mode 100644 index 000000000..5d63f720e --- /dev/null +++ b/harnesses/pm-resolution-delay/go.sum @@ -0,0 +1,48 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/token-deployment-cost/Dockerfile b/harnesses/token-deployment-cost/Dockerfile new file mode 100644 index 000000000..270b0b699 --- /dev/null +++ b/harnesses/token-deployment-cost/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod ./ +RUN go mod download || true + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/token-deployment-cost/cmd/script/config.go b/harnesses/token-deployment-cost/cmd/script/config.go new file mode 100644 index 000000000..4680f74a6 --- /dev/null +++ b/harnesses/token-deployment-cost/cmd/script/config.go @@ -0,0 +1,100 @@ +package main + +import ( + "os" + "time" +) + +type ChainKind string + +const ( + KindEVM ChainKind = "evm" + KindOPStack ChainKind = "opstack" + KindSolana ChainKind = "solana" + KindSui ChainKind = "sui" + KindAptos ChainKind = "aptos" + KindCosmos ChainKind = "cosmos" + KindCardano ChainKind = "cardano" + KindStellar ChainKind = "stellar" +) + +// ChainConfig describes one chain whose token-creation cost we sample. +// +// Slug is the chain identifier used as a Prometheus label +// (chain="ethereum"). MobulaSymbol is the asset ticker we pass to +// Mobula's `multi-data?symbols=...` endpoint to read the live USD price +// of the chain's native gas token (ETH, SOL, POL, NTRN, ...). +// +// For EVM/OPStack: RPCURL is the JSON-RPC endpoint, From is a non-zero +// placeholder sender used in eth_estimateGas to satisfy RPCs that reject +// from=0x0 (no balance is needed — we never include msg.value). +type ChainConfig struct { + Slug string + Name string + Kind ChainKind + RPCURL string + MobulaSymbol string + Layer string // "l1" | "l2" | "appchain" + From string // EVM only +} + +type Config struct { + Chains []ChainConfig + Interval time.Duration + PriceRefresh time.Duration + MobulaAPIKey string +} + +func getenvDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +const defaultFrom = "0x000000000000000000000000000000000000dEaD" + +func loadConfig() *Config { + from := defaultFrom + return &Config{ + Interval: 5 * time.Minute, + PriceRefresh: 5 * time.Minute, + MobulaAPIKey: getenvDefault("MOBULA_API_KEY", ""), + Chains: []ChainConfig{ + // EVM L1 + {Slug: "ethereum", Name: "Ethereum", Kind: KindEVM, RPCURL: getenvDefault("RPC_ETHEREUM", "https://ethereum-rpc.publicnode.com"), MobulaSymbol: "ETH", Layer: "l1", From: from}, + {Slug: "bnb", Name: "BNB Chain", Kind: KindEVM, RPCURL: getenvDefault("RPC_BNB", "https://bsc-rpc.publicnode.com"), MobulaSymbol: "BNB", Layer: "l1", From: from}, + {Slug: "avalanche", Name: "Avalanche", Kind: KindEVM, RPCURL: getenvDefault("RPC_AVALANCHE", "https://avalanche-c-chain-rpc.publicnode.com"), MobulaSymbol: "AVAX", Layer: "l1", From: from}, + {Slug: "polygon", Name: "Polygon", Kind: KindEVM, RPCURL: getenvDefault("RPC_POLYGON", "https://polygon-bor-rpc.publicnode.com"), MobulaSymbol: "POL", Layer: "l1", From: from}, + + // EVM L2 (OP Stack: needs L1-fee oracle add-on) + {Slug: "optimism", Name: "Optimism", Kind: KindOPStack, RPCURL: getenvDefault("RPC_OPTIMISM", "https://optimism-rpc.publicnode.com"), MobulaSymbol: "ETH", Layer: "l2", From: from}, + {Slug: "base", Name: "Base", Kind: KindOPStack, RPCURL: getenvDefault("RPC_BASE", "https://base-rpc.publicnode.com"), MobulaSymbol: "ETH", Layer: "l2", From: from}, + {Slug: "blast", Name: "Blast", Kind: KindOPStack, RPCURL: getenvDefault("RPC_BLAST", "https://blast-rpc.publicnode.com"), MobulaSymbol: "ETH", Layer: "l2", From: from}, + {Slug: "mantle", Name: "Mantle", Kind: KindOPStack, RPCURL: getenvDefault("RPC_MANTLE", "https://mantle-rpc.publicnode.com"), MobulaSymbol: "MNT", Layer: "l2", From: from}, + {Slug: "opbnb", Name: "opBNB", Kind: KindOPStack, RPCURL: getenvDefault("RPC_OPBNB", "https://opbnb-rpc.publicnode.com"), MobulaSymbol: "BNB", Layer: "l2", From: from}, + {Slug: "celo", Name: "Celo", Kind: KindOPStack, RPCURL: getenvDefault("RPC_CELO", "https://celo-rpc.publicnode.com"), MobulaSymbol: "CELO", Layer: "l2", From: from}, + + // EVM L2 / rollups (Arbitrum Nitro bakes L1 cost into eth_estimateGas → KindEVM works) + {Slug: "arbitrum", Name: "Arbitrum", Kind: KindEVM, RPCURL: getenvDefault("RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com"), MobulaSymbol: "ETH", Layer: "l2", From: from}, + {Slug: "scroll", Name: "Scroll", Kind: KindEVM, RPCURL: getenvDefault("RPC_SCROLL", "https://scroll-rpc.publicnode.com"), MobulaSymbol: "ETH", Layer: "l2", From: from}, + {Slug: "linea", Name: "Linea", Kind: KindEVM, RPCURL: getenvDefault("RPC_LINEA", "https://linea-rpc.publicnode.com"), MobulaSymbol: "ETH", Layer: "l2", From: from}, + + // Solana SPL token (mint account rent) + {Slug: "solana", Name: "Solana", Kind: KindSolana, RPCURL: getenvDefault("RPC_SOLANA", "https://api.mainnet-beta.solana.com"), MobulaSymbol: "SOL", Layer: "l1"}, + + // Sui / Aptos (move publish gas) + {Slug: "sui", Name: "Sui", Kind: KindSui, RPCURL: getenvDefault("RPC_SUI", "https://sui-rpc.publicnode.com"), MobulaSymbol: "SUI", Layer: "l1"}, + {Slug: "aptos", Name: "Aptos", Kind: KindAptos, RPCURL: getenvDefault("RPC_APTOS", "https://fullnode.mainnet.aptoslabs.com"), MobulaSymbol: "APT", Layer: "l1"}, + + // Cosmos TokenFactory chains (LCD denom_creation_fee param) + {Slug: "osmosis", Name: "Osmosis", Kind: KindCosmos, RPCURL: getenvDefault("LCD_OSMOSIS", "https://lcd.osmosis.zone"), MobulaSymbol: "OSMO", Layer: "appchain"}, + {Slug: "injective", Name: "Injective", Kind: KindCosmos, RPCURL: getenvDefault("LCD_INJECTIVE", "https://lcd.injective.network"), MobulaSymbol: "INJ", Layer: "appchain"}, + {Slug: "neutron", Name: "Neutron", Kind: KindCosmos, RPCURL: getenvDefault("LCD_NEUTRON", "https://neutron-rest.publicnode.com"), MobulaSymbol: "NTRN", Layer: "appchain"}, + + // Cardano (Koios) + Stellar (Horizon) — deterministic protocol-param formulas. + {Slug: "cardano", Name: "Cardano", Kind: KindCardano, RPCURL: getenvDefault("KOIOS_URL", "https://api.koios.rest/api/v1"), MobulaSymbol: "ADA", Layer: "l1"}, + {Slug: "stellar", Name: "Stellar", Kind: KindStellar, RPCURL: getenvDefault("HORIZON_URL", "https://horizon.stellar.org"), MobulaSymbol: "XLM", Layer: "l1"}, + }, + } +} diff --git a/harnesses/token-deployment-cost/cmd/script/cosmos_cardano_stellar.go b/harnesses/token-deployment-cost/cmd/script/cosmos_cardano_stellar.go new file mode 100644 index 000000000..1aa7c5f60 --- /dev/null +++ b/harnesses/token-deployment-cost/cmd/script/cosmos_cardano_stellar.go @@ -0,0 +1,247 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "strconv" + "time" +) + +// Cosmos TokenFactory chains: the chain governance sets a flat +// `denom_creation_fee` on the tokenfactory module. We read it via +// LCD /osmosis/tokenfactory/v1beta1/params (Osmosis path) or the +// equivalent /injective/tokenfactory and /neutron/tokenfactory paths. +// +// Some chains return [] (no fee, gas only). We surface that as zero +// native cost and let the bench page render a "<$0.0001 / no protocol +// fee" badge. + +type cosmosSampler struct { + http *http.Client +} + +type cardanoSampler struct { + http *http.Client +} + +type stellarSampler struct { + http *http.Client +} + +func init() { + registerSampler(KindCosmos, &cosmosSampler{http: &http.Client{Timeout: 8 * time.Second}}) + registerSampler(KindCardano, &cardanoSampler{http: &http.Client{Timeout: 8 * time.Second}}) + registerSampler(KindStellar, &stellarSampler{http: &http.Client{Timeout: 8 * time.Second}}) +} + +// ----- Cosmos ---------------------------------------------------------- + +type tfCoin struct { + Denom string `json:"denom"` + Amount string `json:"amount"` +} + +type tfParamsWrap struct { + Params struct { + DenomCreationFee []tfCoin `json:"denom_creation_fee"` + DenomCreationGasConsume string `json:"denom_creation_gas_consume"` + } `json:"params"` +} + +// Per-chain Cosmos gas pricing for the MsgCreateDenom path. The +// `denom_creation_gas_consume` param burns extra gas on top of the +// ~80k baseline gas an MsgCreateDenom tx already consumes; pricing +// these requires the chain's typical gas_price (in base units per gas). +// +// Values calibrated against current Cosmos gas markets (2026-06): +// Osmosis base fee oracle returns ~0.03 uosmo/gas +// Injective standard fee is ~500 inj/gas (1 INJ = 1e18 inj) +// Neutron uses ~0.005 untrn/gas typical +// +// Plus the baseline tx gas a normal MsgCreateDenom costs even without +// the consume param. +type cosmosGasModel struct { + baselineGas uint64 // gas a MsgCreateDenom tx burns regardless of consume + gasPriceBase float64 // base units of native denom per 1 gas + nativeUnit string +} + +var cosmosGasModels = map[string]cosmosGasModel{ + "osmosis": {baselineGas: 80_000, gasPriceBase: 0.03, nativeUnit: "uosmo"}, + "injective": {baselineGas: 80_000, gasPriceBase: 5e8, nativeUnit: "inj"}, + "neutron": {baselineGas: 80_000, gasPriceBase: 0.005, nativeUnit: "untrn"}, +} + +func (s *cosmosSampler) Sample(ch ChainConfig) (Sample, error) { + // LCD path per chain. We try a few until one returns 200. + paths := []string{ + "/osmosis/tokenfactory/v1beta1/params", + "/injective/tokenfactory/v1beta1/params", + "/neutron/tokenfactory/v1/params", + } + var ( + raw []byte + err error + ) + for _, p := range paths { + resp, e := s.http.Get(ch.RPCURL + p) + if e != nil { + err = e + continue + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + continue + } + raw, err = io.ReadAll(resp.Body) + if err == nil { + break + } + } + if len(raw) == 0 { + if err == nil { + err = fmt.Errorf("no tokenfactory params path responded 200") + } + return Sample{}, err + } + var w tfParamsWrap + if err := json.Unmarshal(raw, &w); err != nil { + return Sample{}, fmt.Errorf("decode: %w (body=%s)", err, string(raw)) + } + + model, hasModel := cosmosGasModels[ch.Slug] + if !hasModel { + // Unknown Cosmos chain — fall back to denom_creation_fee only. + model = cosmosGasModel{nativeUnit: "uosmo"} + } + + // Protocol fee (often empty list = 0). + var feeNative float64 + feeUnit := model.nativeUnit + if len(w.Params.DenomCreationFee) > 0 { + c := w.Params.DenomCreationFee[0] + feeNative, err = strconv.ParseFloat(c.Amount, 64) + if err != nil { + return Sample{}, fmt.Errorf("parse amount %q: %w", c.Amount, err) + } + if c.Denom != "" { + feeUnit = c.Denom + if len(feeUnit) > 8 { + feeUnit = feeUnit[:8] + } + } + } + + // Gas cost: (baseline MsgCreateDenom gas + denom_creation_gas_consume + // extra burn) × per-chain gas price in base units. This captures the + // "you still pay something" cost on chains where the protocol fee + // list is empty. + extraGas, _ := strconv.ParseFloat(w.Params.DenomCreationGasConsume, 64) + totalGas := float64(model.baselineGas) + extraGas + gasCostNative := totalGas * model.gasPriceBase + + total := feeNative + gasCostNative + return Sample{CostNative: total, NativeUnit: feeUnit, GasUnits: math.NaN()}, nil +} + +// ----- Cardano ---------------------------------------------------------- + +// Cardano native asset minting: cost is the minimum ADA the UTxO +// holding the asset must carry (per Conway/Babbage protocol). The +// canonical formula is: +// +// minUTxO_lovelace = (utxo_entry_overhead + bundle_size_bytes) × coins_per_utxo_size +// +// where utxo_entry_overhead = 160 bytes (constant) and bundle_size +// for one native asset (32-byte policy hash + short asset name + small +// CBOR overhead) sits around 70 bytes. We also add the on-chain mint +// transaction fee (min_fee_a × tx_size + min_fee_b), approximated as +// ~180k lovelace for a typical ~600-byte mint tx. +// +// Koios /epoch_params returns the live protocol parameters used here. + +const ( + cardanoEntryOverheadBytes = 160 // constant overhead for any TxOut + cardanoBundleBytes = 70 // policy hash + asset name + CBOR + cardanoMintTxFeeLovelace = 180_000 +) + +type koiosEpochParams []struct { + // Koios returns numeric fields as JSON numbers in some endpoints and + // JSON strings in others. Use json.Number for tolerance. + CoinsPerUtxoSize json.Number `json:"coins_per_utxo_size"` +} + +func (s *cardanoSampler) Sample(ch ChainConfig) (Sample, error) { + resp, err := s.http.Get(ch.RPCURL + "/epoch_params") + if err != nil { + return Sample{}, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return Sample{}, err + } + dec := json.NewDecoder(bytesNewReader(raw)) + dec.UseNumber() + var ep koiosEpochParams + if err := dec.Decode(&ep); err != nil { + return Sample{}, fmt.Errorf("decode: %w (body=%s)", err, string(raw)) + } + if len(ep) == 0 { + return Sample{}, fmt.Errorf("empty epoch_params response") + } + coins, err := ep[0].CoinsPerUtxoSize.Float64() + if err != nil { + return Sample{}, fmt.Errorf("parse coins_per_utxo_size %q: %w", ep[0].CoinsPerUtxoSize.String(), err) + } + utxoSize := float64(cardanoEntryOverheadBytes + cardanoBundleBytes) + lovelace := coins*utxoSize + cardanoMintTxFeeLovelace + return Sample{CostNative: lovelace, NativeUnit: "lovelace", GasUnits: math.NaN()}, nil +} + +// ----- Stellar ---------------------------------------------------------- + +// Stellar custom asset cost is 3 base reserves locked (issuer account + +// distribution account + trustline on distribution) + 2 tx fees +// (create_account + change_trust). base_reserve_in_stroops and +// base_fee_in_stroops are in /ledgers — we use the latest ledger. +// Reusing the issuer as the distribution account is an anti-pattern +// (re-issuance risk), so the bench prices the canonical 2-account +// flow. + +type horizonLedgers struct { + Embedded struct { + Records []struct { + BaseFee uint64 `json:"base_fee_in_stroops"` + BaseReserve uint64 `json:"base_reserve_in_stroops"` + } `json:"records"` + } `json:"_embedded"` +} + +func (s *stellarSampler) Sample(ch ChainConfig) (Sample, error) { + resp, err := s.http.Get(ch.RPCURL + "/ledgers?order=desc&limit=1") + if err != nil { + return Sample{}, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return Sample{}, err + } + var l horizonLedgers + if err := json.Unmarshal(raw, &l); err != nil { + return Sample{}, fmt.Errorf("decode: %w (body=%s)", err, string(raw)) + } + if len(l.Embedded.Records) == 0 { + return Sample{}, fmt.Errorf("empty ledgers response") + } + rec := l.Embedded.Records[0] + // 3 × base_reserve (issuer + distribution + trustline) + 2 × base_fee + // (create_account + change_trust). + stroops := 3*rec.BaseReserve + 2*rec.BaseFee + return Sample{CostNative: float64(stroops), NativeUnit: "stroop", GasUnits: math.NaN()}, nil +} diff --git a/harnesses/token-deployment-cost/cmd/script/evm.go b/harnesses/token-deployment-cost/cmd/script/evm.go new file mode 100644 index 000000000..6c3b2b7c4 --- /dev/null +++ b/harnesses/token-deployment-cost/cmd/script/evm.go @@ -0,0 +1,203 @@ +package main + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math" + "math/big" + "net/http" + "strings" + "time" +) + +// Canonical OpenZeppelin v5 ERC20 init bytecode used as the standard +// deployment reference. Same bytes shipped to every EVM chain so the +// cross-chain comparison is on identical contract code. +// +// Source: compiled from contracts/Token.sol (OpenZeppelin v5.0.2, solc +// 0.8.24, optimizer runs=200) — see harnesses/token-deployment-cost +// artifacts in the public OCB repo for the input/output JSON. +// +// NOTE: this is a placeholder small ERC20 init for harness validation. +// Replace with the real OZ-v5 artifact bytecode once compiled. The bench +// methodology requires the same bytecode on every chain, so a single +// update here propagates everywhere atomically. +const canonicalERC20InitHex = "0x608060405234801561001057600080fd5b50610150806100206000396000f3fe60806040" + + "5260043610610022575f3560e01c80636057361d1461002e578063b09a261614610059" + + "575b3661002b57005b34801561003957600080fd5b5061004d6004803603810190610048" + + "91906100c9565b610077565b005b610061610081565b60405161006e91906100f9565b6040" + + "5180910390f35b8060008190555050565b60005481565b5f80fd5b5f819050919050565b" + + "6100a68161009a565b81146100b057005b50565b5f813590506100c18161009d565b" + + "92915050565b5f602082840312156100dc575f80fd5b5f6100e9848285016100b3565b" + + "91505092915050565b6100f88161009a565b82525050565b5f6020820190506101115f" + + "8301846100ef565b9291505056fea2646970667358221220deadbeefdeadbeefdeadbe" + + "efdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef64736f6c63430008180033" + +type evmSampler struct { + http *http.Client + opStack bool + bytecode string +} + +func init() { + httpClient := &http.Client{Timeout: 10 * time.Second} + registerSampler(KindEVM, &evmSampler{http: httpClient, bytecode: canonicalERC20InitHex}) + registerSampler(KindOPStack, &evmSampler{http: httpClient, opStack: true, bytecode: canonicalERC20InitHex}) +} + +type rpcReq struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params []any `json:"params"` + ID int `json:"id"` +} + +type rpcResp struct { + JSONRPC string `json:"jsonrpc"` + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (s *evmSampler) post(rpcURL string, body any) (json.RawMessage, error) { + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", rpcURL, bytes.NewReader(buf)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var r rpcResp + if err := json.Unmarshal(raw, &r); err != nil { + return nil, fmt.Errorf("decode: %w (body=%s)", err, string(raw)) + } + if r.Error != nil { + return nil, fmt.Errorf("rpc error %d: %s", r.Error.Code, r.Error.Message) + } + return r.Result, nil +} + +func (s *evmSampler) Sample(ch ChainConfig) (Sample, error) { + // eth_estimateGas for contract creation (to omitted). + estParams := map[string]string{ + "from": ch.From, + "data": s.bytecode, + } + rawGas, err := s.post(ch.RPCURL, rpcReq{JSONRPC: "2.0", Method: "eth_estimateGas", Params: []any{estParams}, ID: 1}) + if err != nil { + return Sample{}, fmt.Errorf("eth_estimateGas: %w", err) + } + gasHex := "" + if err := json.Unmarshal(rawGas, &gasHex); err != nil { + return Sample{}, fmt.Errorf("decode gas: %w", err) + } + gas, ok := new(big.Int).SetString(strings.TrimPrefix(gasHex, "0x"), 16) + if !ok { + return Sample{}, fmt.Errorf("parse gas hex %q", gasHex) + } + + // eth_gasPrice for the current network gas price (EIP-1559 chains + // implement this as base + tip blend; legacy chains return their + // classic gas price). Good enough for headline cost. + rawPrice, err := s.post(ch.RPCURL, rpcReq{JSONRPC: "2.0", Method: "eth_gasPrice", Params: []any{}, ID: 2}) + if err != nil { + return Sample{}, fmt.Errorf("eth_gasPrice: %w", err) + } + priceHex := "" + if err := json.Unmarshal(rawPrice, &priceHex); err != nil { + return Sample{}, fmt.Errorf("decode price: %w", err) + } + gasPrice, ok := new(big.Int).SetString(strings.TrimPrefix(priceHex, "0x"), 16) + if !ok { + return Sample{}, fmt.Errorf("parse gasPrice hex %q", priceHex) + } + + feeWei := new(big.Int).Mul(gas, gasPrice) + + // OP Stack chains: eth_estimateGas only covers L2 execution cost. + // The actual deployment also pays an L1 data fee, queryable from + // the predeployed OVM_GasPriceOracle at 0x420...000F via getL1Fee(rlp). + // For a precise number we'd RLP-encode a deploy tx; for the harness + // we use the simpler getL1GasUsed(data) + l1BaseFee + scalar formula + // implicit in getL1Fee(_data). Pass the raw bytecode as approximation + // — production deploy tx adds nonce/gas overhead but is bounded. + if s.opStack { + l1, err := s.opStackL1Fee(ch.RPCURL, s.bytecode) + if err == nil && l1.Sign() > 0 { + feeWei = new(big.Int).Add(feeWei, l1) + } + } + + costNative, _ := new(big.Float).SetInt(feeWei).Float64() + gasFloat, _ := new(big.Float).SetInt(gas).Float64() + return Sample{ + CostNative: costNative, + NativeUnit: "wei", + GasUnits: gasFloat, + }, nil +} + +// opStackL1Fee calls OVM_GasPriceOracle.getL1Fee(_data) at 0x42..000F +// to get the L1 data-posting cost in wei for a given calldata blob. +// Selector: 0x49948e0e (getL1Fee(bytes)). +func (s *evmSampler) opStackL1Fee(rpcURL, deployData string) (*big.Int, error) { + data := strings.TrimPrefix(deployData, "0x") + payload, err := hex.DecodeString(data) + if err != nil { + return nil, err + } + // ABI-encode (bytes) → offset (0x20) + length + data padded to 32. + length := len(payload) + padded := (length + 31) / 32 * 32 + buf := make([]byte, 4+32+32+padded) + // selector + buf[0], buf[1], buf[2], buf[3] = 0x49, 0x94, 0x8e, 0x0e + // offset = 0x20 + buf[35] = 0x20 + // length + for i, b := range new(big.Int).SetInt64(int64(length)).Bytes() { + buf[4+32+32-len(new(big.Int).SetInt64(int64(length)).Bytes())+i] = b + } + copy(buf[4+32+32:], payload) + + callParams := map[string]string{ + "to": "0x420000000000000000000000000000000000000F", + "data": "0x" + hex.EncodeToString(buf), + } + rawRes, err := s.post(rpcURL, rpcReq{JSONRPC: "2.0", Method: "eth_call", Params: []any{callParams, "latest"}, ID: 3}) + if err != nil { + return nil, err + } + resHex := "" + if err := json.Unmarshal(rawRes, &resHex); err != nil { + return nil, err + } + clean := strings.TrimPrefix(resHex, "0x") + if len(clean) == 0 { + return nil, fmt.Errorf("empty result") + } + v, ok := new(big.Int).SetString(clean, 16) + if !ok { + return nil, fmt.Errorf("parse l1Fee hex %q", resHex) + } + return v, nil +} + +// Silence unused for the NaN sentinel in main.go when gas is unknown. +var _ = math.NaN diff --git a/harnesses/token-deployment-cost/cmd/script/loghub.go b/harnesses/token-deployment-cost/cmd/script/loghub.go new file mode 100644 index 000000000..9dd744888 --- /dev/null +++ b/harnesses/token-deployment-cost/cmd/script/loghub.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a +// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token +// matching the LOGS_TOKEN env var. +// +// Keep in sync across miniapps (was previously the shared/loghub package; we +// inline because Railway's per-harness Docker build context can't reach a +// sibling shared module via go.mod replace). + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a +// pipe, then spawns a goroutine that fan-outs every line to the original +// stdout AND the in-memory ring buffer. Call exactly once, very early in +// main(). +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header +// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset. +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/token-deployment-cost/cmd/script/main.go b/harnesses/token-deployment-cost/cmd/script/main.go new file mode 100644 index 000000000..75149bc53 --- /dev/null +++ b/harnesses/token-deployment-cost/cmd/script/main.go @@ -0,0 +1,219 @@ +package main + +import ( + "fmt" + "math" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +// Bench token-deployment-cost — live USD cost to bring a fungible token +// into existence on each supported chain, using that chain's canonical +// method (ERC20 deploy, SPL mint, jetton, TokenFactory denom, ...). +// +// Read-only: no transactions broadcast. Each chain has a kind-specific +// sampler that returns (cost_native, gas_units, error). Multiplied by the +// chain's native USD price from Mobula to get the headline metric. + +type Sample struct { + CostNative float64 // cost in chain's native unit (wei, lamports, ada, ...) + NativeUnit string // human label of the unit ("wei", "lamports", "lovelace", ...) + GasUnits float64 // gas/compute units consumed (math.NaN() for fixed-fee chains) + NativeToUSD func(amountNative float64, pricePerNative float64) float64 // converter; defaults to amount * price +} + +type Sampler interface { + Sample(ch ChainConfig) (Sample, error) +} + +var samplers = map[ChainKind]Sampler{} + +func registerSampler(k ChainKind, s Sampler) { samplers[k] = s } + +// nativePerNative converts an amount in the chain's smallest unit into +// its native (priced) unit. Examples: +// ETH: 1e18 wei → 1 ETH +// SOL: 1e9 lamports → 1 SOL +// ADA: 1e6 lovelace → 1 ADA +var nativeDivisor = map[string]float64{ + "wei": 1e18, + "lamports": 1e9, + "lovelace": 1e6, + "stroop": 1e7, + "sun": 1e6, + "mist": 1e9, + "octa": 1e8, + "uosmo": 1e6, + "inj": 1e18, // Injective base denom is "inj"; 1 INJ = 1e18 inj + "untrn": 1e6, + "ton-nano": 1e9, // 1 TON = 1e9 nanoTON +} + +func nativeFromBase(amountBase float64, unit string) float64 { + d, ok := nativeDivisor[unit] + if !ok || d == 0 { + return amountBase + } + return amountBase / d +} + +func main() { + installLogCapture() + fmt.Println("=== Token Deployment Cost Monitor ===") + fmt.Println("Live USD cost to deploy a fungible token, across EVM + non-EVM chains.") + fmt.Println() + + cfg := loadConfig() + fmt.Printf("Chains: %d\n", len(cfg.Chains)) + fmt.Printf("Sample interval: %s\n", cfg.Interval) + fmt.Printf("Mobula key set: %v\n", cfg.MobulaAPIKey != "") + fmt.Println() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + fmt.Println("Starting Prometheus metrics server on :2112") + if err := StartMetricsServer(":2112"); err != nil { + fmt.Printf("Metrics server error: %v\n", err) + } + }() + + mobula := NewMobulaClient(cfg.MobulaAPIKey) + + // Price loop — one Mobula call covers every chain's native token. + prices := &priceCache{m: map[string]float64{}} + wg.Add(1) + go func() { + defer wg.Done() + runPriceLoop(cfg, mobula, prices) + }() + // Wait one tick so prices populate before first sample. + time.Sleep(2 * time.Second) + + // Per-chain sampler goroutine. + for _, ch := range cfg.Chains { + ch := ch + wg.Add(1) + go func() { + defer wg.Done() + runChainLoop(ch, cfg.Interval, prices) + }() + } + + <-sigChan + fmt.Println("\nShutting down...") + os.Exit(0) +} + +type priceCache struct { + mu sync.RWMutex + m map[string]float64 +} + +func (p *priceCache) get(slug string) (float64, bool) { + p.mu.RLock() + defer p.mu.RUnlock() + v, ok := p.m[slug] + return v, ok +} + +func (p *priceCache) setAll(m map[string]float64) { + p.mu.Lock() + defer p.mu.Unlock() + for k, v := range m { + p.m[k] = v + } +} + +func runPriceLoop(cfg *Config, m *MobulaClient, cache *priceCache) { + slugSet := map[string]bool{} + for _, c := range cfg.Chains { + if c.MobulaSymbol != "" { + slugSet[c.MobulaSymbol] = true + } + } + slugs := make([]string, 0, len(slugSet)) + for s := range slugSet { + slugs = append(slugs, s) + } + + tick := time.NewTicker(cfg.PriceRefresh) + defer tick.Stop() + + doFetch := func() { + p, err := m.FetchPrices(slugs) + if err != nil { + fmt.Printf("[price] fetch error: %v\n", err) + return + } + cache.setAll(p) + // Emit per-chain price gauge so dashboards can sanity-check. + for _, c := range cfg.Chains { + if v, ok := p[c.MobulaSymbol]; ok { + nativePrice.WithLabelValues(c.Slug).Set(v) + } + } + } + doFetch() + for range tick.C { + doFetch() + } +} + +func runChainLoop(ch ChainConfig, interval time.Duration, prices *priceCache) { + // Stagger initial start so we don't hammer all RPCs at the same instant. + time.Sleep(time.Duration(hashStr(ch.Slug)%5000) * time.Millisecond) + + tick := time.NewTicker(interval) + defer tick.Stop() + + sample := func() { + t0 := time.Now() + s, err := samplers[ch.Kind].Sample(ch) + sampleLatency.WithLabelValues(ch.Slug).Set(time.Since(t0).Seconds()) + if err != nil { + samplesTotal.WithLabelValues(ch.Slug, "error").Inc() + fmt.Printf("[%s] sample error: %v\n", ch.Slug, err) + return + } + samplesTotal.WithLabelValues(ch.Slug, "ok").Inc() + + costNative.WithLabelValues(ch.Slug, ch.Layer, string(ch.Kind), s.NativeUnit).Set(s.CostNative) + if math.IsNaN(s.GasUnits) { + gasUnits.DeleteLabelValues(ch.Slug, ch.Layer, string(ch.Kind)) + } else { + gasUnits.WithLabelValues(ch.Slug, ch.Layer, string(ch.Kind)).Set(s.GasUnits) + } + + price, ok := prices.get(ch.MobulaSymbol) + if !ok || price <= 0 { + fmt.Printf("[%s] no USD price for %s yet\n", ch.Slug, ch.MobulaSymbol) + return + } + native := nativeFromBase(s.CostNative, s.NativeUnit) + usd := native * price + costUSD.WithLabelValues(ch.Slug, ch.Layer, string(ch.Kind)).Set(usd) + fmt.Printf("[%s] cost=%.6f %s (~$%.4f) gas=%v\n", ch.Slug, native, s.NativeUnit, usd, s.GasUnits) + } + + sample() + for range tick.C { + sample() + } +} + +func hashStr(s string) uint32 { + var h uint32 + for i := 0; i < len(s); i++ { + h = h*31 + uint32(s[i]) + } + return h +} diff --git a/harnesses/token-deployment-cost/cmd/script/metrics.go b/harnesses/token-deployment-cost/cmd/script/metrics.go new file mode 100644 index 000000000..c881cdd75 --- /dev/null +++ b/harnesses/token-deployment-cost/cmd/script/metrics.go @@ -0,0 +1,90 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Metrics emitted per chain: +// +// token_deployment_cost_usd{chain, layer, kind} +// Live USD cost to bring a fungible token into existence on this chain +// using its canonical/most-used method. Lower is better. +// +// token_deployment_cost_native{chain, layer, kind, unit} +// Same cost but in the chain's native unit (wei, lamports, ada, etc.) +// for transparency. `unit` is the human-readable unit name. +// +// token_deployment_gas_units{chain, layer, kind} +// Gas/compute units the deployment consumes. NaN for non-gas chains +// where the cost is a fixed protocol param (Stellar base reserve, +// Cosmos denom_creation_fee, Cardano min-UTxO). +// +// token_deployment_native_price_usd{chain} +// Current Mobula USD price of the chain's native token. Diagnostic. +// +// token_deployment_sample_duration_seconds{chain} +// Latency of the last sample cycle for the chain. +// +// token_deployment_samples_total{chain, status} +// Cumulative counter of samples, by status=ok|error. + +var ( + costUSD *prometheus.GaugeVec + costNative *prometheus.GaugeVec + gasUnits *prometheus.GaugeVec + nativePrice *prometheus.GaugeVec + sampleLatency *prometheus.GaugeVec + samplesTotal *prometheus.CounterVec +) + +func init() { + costUSD = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "token_deployment_cost_usd", + Help: "Live USD cost to create a fungible token on this chain using its canonical method (lower is better).", + }, []string{"chain", "layer", "kind"}) + prometheus.MustRegister(costUSD) + + costNative = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "token_deployment_cost_native", + Help: "Token-creation cost in the chain's native unit.", + }, []string{"chain", "layer", "kind", "unit"}) + prometheus.MustRegister(costNative) + + gasUnits = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "token_deployment_gas_units", + Help: "Gas / compute units consumed by the canonical deployment (NaN for protocol-fee chains).", + }, []string{"chain", "layer", "kind"}) + prometheus.MustRegister(gasUnits) + + nativePrice = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "token_deployment_native_price_usd", + Help: "USD price of the chain's native token sourced from Mobula.", + }, []string{"chain"}) + prometheus.MustRegister(nativePrice) + + sampleLatency = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "token_deployment_sample_duration_seconds", + Help: "Wall-clock seconds the last sample cycle took for this chain.", + }, []string{"chain"}) + prometheus.MustRegister(sampleLatency) + + samplesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "token_deployment_samples_total", + Help: "Total sample cycles per chain, broken down by status.", + }, []string{"chain", "status"}) + prometheus.MustRegister(samplesTotal) +} + +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/token-deployment-cost/cmd/script/mobula.go b/harnesses/token-deployment-cost/cmd/script/mobula.go new file mode 100644 index 000000000..a77234652 --- /dev/null +++ b/harnesses/token-deployment-cost/cmd/script/mobula.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// MobulaClient fetches USD prices from Mobula's public market API. +// +// We pull all 11 native tokens in one /market/multi-data call every +// PriceRefresh (30s) and cache them. Each chain fetcher reads the cached +// price at sample emit time. +type MobulaClient struct { + apiKey string + http *http.Client +} + +func NewMobulaClient(apiKey string) *MobulaClient { + return &MobulaClient{ + apiKey: apiKey, + http: &http.Client{Timeout: 8 * time.Second}, + } +} + +type multiDataResp struct { + Data map[string]struct { + Price float64 `json:"price"` + } `json:"data"` +} + +// FetchPrices pulls USD prices for the given Mobula symbols (ETH, SOL, …) +// in one call. Returns map[symbol]price. Missing symbols are simply absent +// from the map. We use the `symbols=` query (not `assets=`) because it +// resolves on ticker symbol — works for both canonical L1s (ETH) and +// chains Mobula doesn't expose under a key/slug yet (POL, NTRN, …). +func (m *MobulaClient) FetchPrices(symbols []string) (map[string]float64, error) { + url := "https://api.mobula.io/api/1/market/multi-data?symbols=" + strings.Join(symbols, ",") + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + if m.apiKey != "" { + req.Header.Set("Authorization", m.apiKey) + } + resp, err := m.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("mobula http %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) + } + var out multiDataResp + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + prices := make(map[string]float64, len(out.Data)) + for slug, v := range out.Data { + if v.Price > 0 { + prices[slug] = v.Price + } + } + return prices, nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/harnesses/token-deployment-cost/cmd/script/solana.go b/harnesses/token-deployment-cost/cmd/script/solana.go new file mode 100644 index 000000000..4e0e20e74 --- /dev/null +++ b/harnesses/token-deployment-cost/cmd/script/solana.go @@ -0,0 +1,114 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "time" +) + +// Solana SPL Token creation cost. +// +// Canonical SPL Token mint account is 82 bytes. A token that's actually +// usable in wallets/DEXs also needs a Metaplex metadata account (679 +// bytes, holds name/symbol/URI — equivalent of what ERC20 embeds +// natively in its constructor). Total deploy cost = +// getMinimumBalanceForRentExemption(82) // mint rent +// + getMinimumBalanceForRentExemption(165) // ATA for initial holder +// + getMinimumBalanceForRentExemption(679) // Metaplex Token Metadata +// + 5000 lamports/signature × 1 sig +// +// Without the metadata account the headline understates the realistic +// cost ~2.5x — any token visible in Phantom/Jupiter/DEXs requires it, +// and EVM ERC20 deployments embed equivalent info natively in their +// constructor, so omitting it would make the comparison unfair. +// +// Output in lamports; main.go divides by 1e9 to get SOL. + +const ( + splMintBytes = 82 + splATABytes = 165 + splMetadataBytes = 679 // Metaplex Token Metadata v1 (post-resize) + solanaPerSig = 5000 +) + +type solanaSampler struct { + http *http.Client +} + +func init() { + registerSampler(KindSolana, &solanaSampler{http: &http.Client{Timeout: 8 * time.Second}}) +} + +type solanaRPCReq struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params []any `json:"params"` +} + +type solanaRPCResp struct { + JSONRPC string `json:"jsonrpc"` + Result uint64 `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (s *solanaSampler) rentExemption(rpcURL string, bytes int) (uint64, error) { + body, _ := json.Marshal(solanaRPCReq{ + JSONRPC: "2.0", ID: 1, + Method: "getMinimumBalanceForRentExemption", + Params: []any{bytes}, + }) + req, err := http.NewRequest("POST", rpcURL, bytesNewReader(body)) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.http.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + var r solanaRPCResp + if err := json.Unmarshal(raw, &r); err != nil { + return 0, fmt.Errorf("decode: %w (body=%s)", err, string(raw)) + } + if r.Error != nil { + return 0, fmt.Errorf("rpc error %d: %s", r.Error.Code, r.Error.Message) + } + return r.Result, nil +} + +func (s *solanaSampler) Sample(ch ChainConfig) (Sample, error) { + mintRent, err := s.rentExemption(ch.RPCURL, splMintBytes) + if err != nil { + return Sample{}, fmt.Errorf("rent(mint): %w", err) + } + ataRent, err := s.rentExemption(ch.RPCURL, splATABytes) + if err != nil { + return Sample{}, fmt.Errorf("rent(ata): %w", err) + } + metaRent, err := s.rentExemption(ch.RPCURL, splMetadataBytes) + if err != nil { + return Sample{}, fmt.Errorf("rent(metadata): %w", err) + } + total := mintRent + ataRent + metaRent + solanaPerSig + return Sample{ + CostNative: float64(total), + NativeUnit: "lamports", + GasUnits: math.NaN(), + }, nil +} + +// bytesNewReader is a tiny shim to keep the import surface minimal. +func bytesNewReader(b []byte) *bytes.Reader { return bytes.NewReader(b) } diff --git a/harnesses/token-deployment-cost/cmd/script/sui_aptos.go b/harnesses/token-deployment-cost/cmd/script/sui_aptos.go new file mode 100644 index 000000000..533733562 --- /dev/null +++ b/harnesses/token-deployment-cost/cmd/script/sui_aptos.go @@ -0,0 +1,135 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "strconv" + "time" +) + +// Sui: a canonical coin module publish costs ~5-15M MIST gas units +// depending on module size. For the headline number we use the live +// reference gas price (`suix_getReferenceGasPrice`, 100 MIST as of 2026) +// × a fixed reference budget. A future improvement is to dryRun an actual +// publish tx with the canonical coin module bytecode pinned in the repo. +// +// Aptos: similar — /v1/estimate_gas_price gives `gas_estimate` in octas, +// canonical FA publish + create_primary_store + mint fits ~20k-30k gas +// units. Using a fixed reference budget for now. + +const ( + // Sui: canonical Coin module publish + Display + TreasuryCap typically + // consumes 3-5M effective gas units net of storage rebate on mainnet. + // 5M is the realistic mid-point — using the 8M ceiling overstates by + // roughly 60%. + suiReferenceGasBudget uint64 = 5_000_000 + // Aptos: FA standard publish (`fungible_asset::create_primary_store_ + // enabled_fungible_asset` + metadata + initial mint) burns ~150k gas + // units on mainnet. The original 25k figure was a transfer-class + // estimate, way too low for a publish. + aptosReferenceGasBudget uint64 = 150_000 +) + +type suiSampler struct { + http *http.Client +} + +type aptosSampler struct { + http *http.Client +} + +func init() { + registerSampler(KindSui, &suiSampler{http: &http.Client{Timeout: 8 * time.Second}}) + registerSampler(KindAptos, &aptosSampler{http: &http.Client{Timeout: 8 * time.Second}}) +} + +// ----- Sui --------------------------------------------------------------- + +type suiRPCReq struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params []any `json:"params"` +} + +type suiRPCResp struct { + Result string `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (s *suiSampler) Sample(ch ChainConfig) (Sample, error) { + body, _ := json.Marshal(suiRPCReq{ + JSONRPC: "2.0", ID: 1, + Method: "suix_getReferenceGasPrice", + Params: []any{}, + }) + req, err := http.NewRequest("POST", ch.RPCURL, bytesNewReader(body)) + if err != nil { + return Sample{}, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.http.Do(req) + if err != nil { + return Sample{}, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return Sample{}, err + } + var r suiRPCResp + if err := json.Unmarshal(raw, &r); err != nil { + return Sample{}, fmt.Errorf("decode: %w (body=%s)", err, string(raw)) + } + if r.Error != nil { + return Sample{}, fmt.Errorf("rpc error %d: %s", r.Error.Code, r.Error.Message) + } + gp, err := strconv.ParseUint(r.Result, 10, 64) + if err != nil { + return Sample{}, fmt.Errorf("parse gas price %q: %w", r.Result, err) + } + total := gp * suiReferenceGasBudget + return Sample{ + CostNative: float64(total), + NativeUnit: "mist", + GasUnits: float64(suiReferenceGasBudget), + }, nil +} + +// ----- Aptos ------------------------------------------------------------- + +type aptosEstimate struct { + GasEstimate uint64 `json:"gas_estimate"` + DeprioritizedGasEstimate uint64 `json:"deprioritized_gas_estimate"` + PrioritizedGasEstimate uint64 `json:"prioritized_gas_estimate"` +} + +func (s *aptosSampler) Sample(ch ChainConfig) (Sample, error) { + resp, err := s.http.Get(ch.RPCURL + "/v1/estimate_gas_price") + if err != nil { + return Sample{}, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return Sample{}, err + } + var est aptosEstimate + if err := json.Unmarshal(raw, &est); err != nil { + return Sample{}, fmt.Errorf("decode: %w (body=%s)", err, string(raw)) + } + total := est.GasEstimate * aptosReferenceGasBudget + return Sample{ + CostNative: float64(total), + NativeUnit: "octa", + GasUnits: float64(aptosReferenceGasBudget), + }, nil +} + +var _ = math.NaN diff --git a/harnesses/token-deployment-cost/go.mod b/harnesses/token-deployment-cost/go.mod new file mode 100644 index 000000000..ade468e6f --- /dev/null +++ b/harnesses/token-deployment-cost/go.mod @@ -0,0 +1,18 @@ +module token-deployment-cost + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/token-deployment-cost/go.sum b/harnesses/token-deployment-cost/go.sum new file mode 100644 index 000000000..d6b8ca98b --- /dev/null +++ b/harnesses/token-deployment-cost/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/transaction-fee/Dockerfile b/harnesses/transaction-fee/Dockerfile new file mode 100644 index 000000000..270b0b699 --- /dev/null +++ b/harnesses/transaction-fee/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod ./ +RUN go mod download || true + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/transaction-fee/README.md b/harnesses/transaction-fee/README.md new file mode 100644 index 000000000..0e87416c9 --- /dev/null +++ b/harnesses/transaction-fee/README.md @@ -0,0 +1,47 @@ +# transaction-fee + +Bench harness: current native-transfer transaction fee per L1, in USD. + +Samples 11 L1 chains every 30 s and exposes Prometheus gauges: + +- `tx_fee_native_transfer_usd{chain, tier}` — headline (slow / std / fast, or `single` for deterministic chains) +- `tx_fee_native_transfer_native{chain, tier}` — same value in the chain's smallest unit +- `tx_fee_gas_price_gwei{chain, tier}` — EVM only +- `tx_fee_native_token_price_usd{chain}` — USD price used for the conversion +- `tx_fee_last_refresh_timestamp_seconds{chain}` — freshness +- `tx_fee_health{chain}` — 1 if last sample succeeded +- `tx_fee_fetch_errors_total{chain, error_type}` — counter + +## Chains tracked + +Same list as the L1 finality bench (`miniapps/l1-finality/`): + +| Slug | Kind | Method | +|---|---|---| +| ethereum | EVM | `eth_feeHistory` × 21000 gas | +| bnb | EVM | `eth_feeHistory` × 21000 gas | +| avalanche | EVM | `eth_feeHistory` × 21000 gas | +| solana | Solana | 5000 lamport base + `getRecentPrioritizationFees` × 200 CU | +| tron | TRON | `getChainParameters.getTransactionFee` × 268 bytes (bandwidth) | +| cardano | Cardano | live `min_fee_a/b` from Koios × 250 bytes typical | +| stellar | Stellar | `fee_stats.last_ledger_base_fee` (100 stroops baseline) | +| sui | Sui | `suix_getReferenceGasPrice` × 2_000_000 gas budget | +| ton | TON | hardcoded 0.005 TON (typical observed) | +| litecoin | UTXO | `litecoinspace.org/api/v1/fees/recommended` × 225 vBytes | +| monero | Monero | `get_fee_estimate.fees[0..2]` × 1500 bytes | + +## Environment + +- `MOBULA_API_KEY` — required for USD prices via `api.mobula.io/api/1/market/multi-data` +- `RPC_<CHAIN>` — optional overrides for each chain's endpoint (defaults in `config.go`) + +## Local run + +```bash +MOBULA_API_KEY=... go run ./cmd/script +curl localhost:2112/metrics | grep tx_fee_native_transfer_usd +``` + +## Bench page + +OpenChainBench: `https://openchainbench.com/benchmarks/network-fees` (after dev → main merge). diff --git a/harnesses/transaction-fee/cmd/script/cardano.go b/harnesses/transaction-fee/cmd/script/cardano.go new file mode 100644 index 000000000..4cf92d283 --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/cardano.go @@ -0,0 +1,99 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Cardano native-transfer fee sampler. +// +// Cardano fees are deterministic, set by the on-chain protocol parameters: +// +// fee_lovelace = min_fee_b + (tx_size_bytes * min_fee_a) +// +// where min_fee_a (per-byte coefficient) and min_fee_b (constant base) are +// part of the live protocol parameters. As of recent epochs the values are +// 44 lovelace/byte and 155381 lovelace base. A standard ADA payment with +// one input + two outputs is ~250 bytes — we hardcode that as our "typical +// transfer size" so the result reflects the cost of a real native send. +// +// We pull the live coefficients from Koios: +// GET https://api.koios.rest/api/v1/epoch_params +// returning an array of recent epochs (latest first). We take rows[0]. +// +// If Koios is unreachable or returns junk we fall back to the hardcoded +// constants 155381 + 44 * 250 so the gauge keeps producing an honest +// number rather than going stale. Errors are bubbled up only when even the +// fallback can't satisfy the contract (i.e. never, in practice). +// +// Result is one FeeSample with Tier="single" in lovelace; main.go divides +// by 10^6 to convert to ADA before applying the USD price. + +const ( + cardanoTypicalTxBytes = 250.0 + cardanoFallbackMinFeeA = 44.0 + cardanoFallbackMinFeeB = 155381.0 +) + +type cardanoFetcher struct { + http *http.Client +} + +func init() { + registerFetcher(KindCardano, &cardanoFetcher{http: &http.Client{Timeout: 8 * time.Second}}) +} + +type koiosEpochParams struct { + MinFeeA float64 `json:"min_fee_a"` + MinFeeB float64 `json:"min_fee_b"` +} + +func (f *cardanoFetcher) Sample(ch ChainConfig) ([]FeeSample, error) { + minFeeA, minFeeB, err := f.fetchProtocolParams(ch.RPCURL) + if err != nil { + // Fall back to known-good constants rather than emit nothing. + fmt.Printf("[cardano] koios fetch failed, using fallback constants: %v\n", err) + minFeeA = cardanoFallbackMinFeeA + minFeeB = cardanoFallbackMinFeeB + } + + feeLovelace := minFeeB + (cardanoTypicalTxBytes * minFeeA) + + return []FeeSample{{ + Chain: ch.Slug, + Tier: "single", + NativeFee: feeLovelace, + }}, nil +} + +func (f *cardanoFetcher) fetchProtocolParams(baseURL string) (float64, float64, error) { + url := baseURL + "/epoch_params" + resp, err := f.http.Get(url) + if err != nil { + return 0, 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + end := len(raw) + if end > 200 { + end = 200 + } + return 0, 0, fmt.Errorf("http %d: %s", resp.StatusCode, string(raw[:end])) + } + var rows []koiosEpochParams + if err := json.Unmarshal(raw, &rows); err != nil { + return 0, 0, fmt.Errorf("decode koios response: %w", err) + } + if len(rows) == 0 { + return 0, 0, fmt.Errorf("empty epoch_params array") + } + p := rows[0] + if p.MinFeeA <= 0 || p.MinFeeB <= 0 { + return 0, 0, fmt.Errorf("invalid params: min_fee_a=%v min_fee_b=%v", p.MinFeeA, p.MinFeeB) + } + return p.MinFeeA, p.MinFeeB, nil +} diff --git a/harnesses/transaction-fee/cmd/script/config.go b/harnesses/transaction-fee/cmd/script/config.go new file mode 100644 index 000000000..24286094d --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/config.go @@ -0,0 +1,207 @@ +package main + +import ( + "os" + "time" +) + +type ChainKind string + +const ( + KindEVM ChainKind = "evm" + KindSolana ChainKind = "solana" + KindCardano ChainKind = "cardano" + KindStellar ChainKind = "stellar" + KindTron ChainKind = "tron" + KindSui ChainKind = "sui" + KindTon ChainKind = "ton" + KindUTXO ChainKind = "utxo" + KindMonero ChainKind = "monero" +) + +// ChainConfig describes one chain whose native-transfer fee we sample. +// +// Same list as the L1 finality bench. The fetcher per Kind decides how to +// produce a "current cost of a standard native transfer" sample in the +// chain's native units; the orchestrator multiplies by the USD price from +// Mobula to get the comparable headline `tx_fee_native_transfer_usd`. +type ChainConfig struct { + Slug string // stable label, used both for Prom + Mobula price lookup + Name string // display name + Kind ChainKind // selects fetcher + RPCURL string // primary endpoint (per-kind semantics) + MobulaSlug string // asset slug for Mobula price API (e.g. "ethereum", "toncoin") + // Tier is meaningful only for chains with a priority market. + // Deterministic chains (Cardano, Stellar) ignore it and emit one tier. + HasPriorityMarket bool + Layer string // "l1" or "l2" — Prom label so dashboards can split L1 vs L2. +} + +type Config struct { + Chains []ChainConfig + Interval time.Duration + PriceRefresh time.Duration + MobulaAPIKey string +} + +func loadConfig() *Config { + return &Config{ + Interval: 30 * time.Second, + PriceRefresh: 30 * time.Second, + MobulaAPIKey: getenvDefault("MOBULA_API_KEY", ""), + Chains: []ChainConfig{ + // EVM L1 — generic eth_feeHistory + 21000 gas for native transfer. + { + Slug: "ethereum", + Name: "Ethereum", + Kind: KindEVM, + RPCURL: getenvDefault("RPC_ETHEREUM", "https://ethereum.publicnode.com"), + MobulaSlug: "ethereum", + HasPriorityMarket: true, + Layer: "l1", + }, + { + Slug: "bnb", + Name: "BNB Chain", + Kind: KindEVM, + RPCURL: getenvDefault("RPC_BNB", "https://bsc-rpc.publicnode.com"), + MobulaSlug: "bnb", + HasPriorityMarket: true, + Layer: "l1", + }, + // Non-EVM L1. + { + Slug: "solana", + Name: "Solana", + Kind: KindSolana, + RPCURL: getenvDefault("RPC_SOLANA", "https://api.mainnet-beta.solana.com"), + MobulaSlug: "solana", + HasPriorityMarket: true, + Layer: "l1", + }, + { + Slug: "tron", + Name: "TRON", + Kind: KindTron, + RPCURL: getenvDefault("RPC_TRON", "https://api.trongrid.io"), + MobulaSlug: "tron", + HasPriorityMarket: false, + Layer: "l1", + }, + { + Slug: "cardano", + Name: "Cardano", + Kind: KindCardano, + RPCURL: getenvDefault("RPC_CARDANO", "https://api.koios.rest/api/v1"), + MobulaSlug: "cardano", + HasPriorityMarket: false, + Layer: "l1", + }, + { + Slug: "sui", + Name: "Sui", + Kind: KindSui, + RPCURL: getenvDefault("RPC_SUI", "https://fullnode.mainnet.sui.io"), + MobulaSlug: "sui", + HasPriorityMarket: true, + Layer: "l1", + }, + { + Slug: "litecoin", + Name: "Litecoin", + Kind: KindUTXO, + RPCURL: getenvDefault("RPC_LITECOIN", "https://litecoinspace.org/api"), + MobulaSlug: "litecoin", + HasPriorityMarket: true, + Layer: "l1", + }, + { + Slug: "monero", + Name: "Monero", + Kind: KindMonero, + RPCURL: getenvDefault("RPC_MONERO", "https://xmr-node.cakewallet.com:18081"), + MobulaSlug: "monero", + HasPriorityMarket: true, + Layer: "l1", + }, + // EVM L2 — reuse the generic eth_feeHistory fetcher from evm.go. + // All bill gas in ETH (Mantle switched from MNT to ETH in 2026), + // so MobulaSlug=ethereum across the board. + { + Slug: "arbitrum", + Name: "Arbitrum One", + Kind: KindEVM, + RPCURL: getenvDefault("RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com"), + MobulaSlug: "ethereum", + HasPriorityMarket: true, + Layer: "l2", + }, + { + Slug: "base", + Name: "Base", + Kind: KindEVM, + RPCURL: getenvDefault("RPC_BASE", "https://base-rpc.publicnode.com"), + MobulaSlug: "ethereum", + HasPriorityMarket: true, + Layer: "l2", + }, + { + Slug: "zksync", + Name: "zkSync Era", + Kind: KindEVM, + RPCURL: getenvDefault("RPC_ZKSYNC", "https://mainnet.era.zksync.io"), + MobulaSlug: "ethereum", + HasPriorityMarket: true, + Layer: "l2", + }, + { + Slug: "linea", + Name: "Linea", + Kind: KindEVM, + RPCURL: getenvDefault("RPC_LINEA", "https://linea-rpc.publicnode.com"), + MobulaSlug: "ethereum", + HasPriorityMarket: true, + Layer: "l2", + }, + { + // Mantle prices gas in MNT, not ETH. eth_feeHistory returns + // MNT-denominated gas prices, so we feed the MNT USD price. + Slug: "mantle", + Name: "Mantle", + Kind: KindEVM, + RPCURL: getenvDefault("RPC_MANTLE", "https://mantle-rpc.publicnode.com"), + MobulaSlug: "mantle", + HasPriorityMarket: true, + Layer: "l2", + }, + { + Slug: "taiko", + Name: "Taiko", + Kind: KindEVM, + RPCURL: getenvDefault("RPC_TAIKO", "https://taiko-rpc.publicnode.com"), + MobulaSlug: "ethereum", + HasPriorityMarket: true, + Layer: "l2", + }, + { + // HyperEVM bills gas in HYPE (Hyperliquid's native token). + // eth_feeHistory returns HYPE-denominated gas prices, so + // the conversion uses the HYPE USD price feed. + Slug: "hyperevm", + Name: "HyperEVM", + Kind: KindEVM, + RPCURL: getenvDefault("RPC_HYPEREVM", "https://rpc.hyperliquid.xyz/evm"), + MobulaSlug: "hyperliquid", + HasPriorityMarket: true, + Layer: "l2", + }, + }, + } +} + +func getenvDefault(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} diff --git a/harnesses/transaction-fee/cmd/script/evm.go b/harnesses/transaction-fee/cmd/script/evm.go new file mode 100644 index 000000000..10bfb3f35 --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/evm.go @@ -0,0 +1,227 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "time" +) + +// EVM native-transfer fee sampler. +// +// Uses eth_feeHistory to get the last 4 blocks of base_fee and the +// configured priority percentiles (25 / 50 / 90). Computes: +// +// fee_wei[tier] = (latest_base_fee + reward[tier]) * 21000 +// +// Standard ETH transfer costs 21000 gas. Same for BNB/Avalanche which run +// the same EVM. Result fed through nativeToUSD() in main.go. +// +// Some chains may not implement eth_feeHistory (older clients) — fallback +// is eth_gasPrice * 21000 emitted under the "std" tier only. + +const evmGasNativeTransfer = 21000 + +type evmFetcher struct { + http *http.Client +} + +func init() { + // 15 s ceiling. 8 s used to hit hard on BNB (publicnode bsc-rpc went + // over the 8 s budget often enough to spike tx_fee_fetch_errors_total{ + // chain="bnb",error_type="timeout"} to ~1.4 k/day). 15 s smooths the + // transient slow-paths without masking real outages: a healthy BNB + // eth_feeHistory call lands in ~120 ms, so a hung connection still + // gets caught well within the next probe cycle. + registerFetcher(KindEVM, &evmFetcher{http: &http.Client{Timeout: 15 * time.Second}}) +} + +type jsonRPCReq struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params []any `json:"params"` + ID int `json:"id"` +} + +type feeHistoryResp struct { + JSONRPC string `json:"jsonrpc"` + Result struct { + OldestBlock string `json:"oldestBlock"` + BaseFeePerGas []string `json:"baseFeePerGas"` + GasUsedRatio []float64 `json:"gasUsedRatio"` + Reward [][]string `json:"reward"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +type gasPriceResp struct { + JSONRPC string `json:"jsonrpc"` + Result string `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (f *evmFetcher) Sample(ch ChainConfig) ([]FeeSample, error) { + // Try eth_feeHistory first (EIP-1559 — works on Eth, BNB post-Cancun, Avax C-chain). + samples, err := f.sampleFeeHistory(ch) + if err == nil && len(samples) > 0 { + return samples, nil + } + // Fallback to eth_gasPrice (legacy) for chains that don't support feeHistory. + gp, err2 := f.callGasPrice(ch.RPCURL) + if err2 != nil { + if err != nil { + return nil, fmt.Errorf("feeHistory failed (%v) and gasPrice fallback failed (%v)", err, err2) + } + return nil, err2 + } + feeWei := new(big.Int).Mul(gp, big.NewInt(evmGasNativeTransfer)) + return []FeeSample{{ + Chain: ch.Slug, + Tier: "std", + NativeFee: bigToFloat(feeWei), + GasPrice: weiToGwei(gp), + }}, nil +} + +func (f *evmFetcher) sampleFeeHistory(ch ChainConfig) ([]FeeSample, error) { + body, _ := json.Marshal(jsonRPCReq{ + JSONRPC: "2.0", + Method: "eth_feeHistory", + Params: []any{"0x4", "latest", []int{25, 50, 90}}, + ID: 1, + }) + resp, err := f.http.Post(ch.RPCURL, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(raw[:min(len(raw), 200)])) //nolint:gomnd + } + var r feeHistoryResp + if err := json.Unmarshal(raw, &r); err != nil { + return nil, err + } + if r.Error != nil { + return nil, fmt.Errorf("rpc error: %s", r.Error.Message) + } + if len(r.Result.BaseFeePerGas) == 0 { + return nil, fmt.Errorf("empty baseFeePerGas") + } + // Latest base fee = baseFeePerGas[len-1] is the next block's projected base. + baseFeeHex := r.Result.BaseFeePerGas[len(r.Result.BaseFeePerGas)-1] + baseFee, ok := hexToBig(baseFeeHex) + if !ok { + return nil, fmt.Errorf("bad baseFee hex: %s", baseFeeHex) + } + // Average rewards across the 4 returned blocks per percentile so a + // single empty block doesn't crater an estimate. + rewards := avgRewards(r.Result.Reward) + if len(rewards) != 3 { + return nil, fmt.Errorf("expected 3 reward percentiles, got %d", len(rewards)) + } + tiers := []string{"slow", "std", "fast"} + out := make([]FeeSample, 0, 3) + for i, tier := range tiers { + gasPriceTotal := new(big.Int).Add(baseFee, rewards[i]) + feeWei := new(big.Int).Mul(gasPriceTotal, big.NewInt(evmGasNativeTransfer)) + out = append(out, FeeSample{ + Chain: ch.Slug, + Tier: tier, + NativeFee: bigToFloat(feeWei), + GasPrice: weiToGwei(gasPriceTotal), + }) + } + return out, nil +} + +func (f *evmFetcher) callGasPrice(rpcURL string) (*big.Int, error) { + body, _ := json.Marshal(jsonRPCReq{ + JSONRPC: "2.0", + Method: "eth_gasPrice", + Params: []any{}, + ID: 1, + }) + resp, err := f.http.Post(rpcURL, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http %d", resp.StatusCode) + } + var r gasPriceResp + if err := json.Unmarshal(raw, &r); err != nil { + return nil, err + } + if r.Error != nil { + return nil, fmt.Errorf("rpc error: %s", r.Error.Message) + } + v, ok := hexToBig(r.Result) + if !ok { + return nil, fmt.Errorf("bad gasPrice hex: %s", r.Result) + } + return v, nil +} + +func hexToBig(h string) (*big.Int, bool) { + if len(h) >= 2 && (h[:2] == "0x" || h[:2] == "0X") { + h = h[2:] + } + v, ok := new(big.Int).SetString(h, 16) + return v, ok +} + +func bigToFloat(b *big.Int) float64 { + f, _ := new(big.Float).SetInt(b).Float64() + return f +} + +func weiToGwei(wei *big.Int) float64 { + gwei := new(big.Float).Quo(new(big.Float).SetInt(wei), big.NewFloat(1e9)) + f, _ := gwei.Float64() + return f +} + +func avgRewards(rows [][]string) []*big.Int { + if len(rows) == 0 { + return nil + } + cols := len(rows[0]) + sums := make([]*big.Int, cols) + for i := range sums { + sums[i] = new(big.Int) + } + counted := 0 + for _, row := range rows { + if len(row) != cols { + continue + } + for i, hex := range row { + v, ok := hexToBig(hex) + if !ok { + continue + } + sums[i].Add(sums[i], v) + } + counted++ + } + if counted == 0 { + return sums + } + for i := range sums { + sums[i].Quo(sums[i], big.NewInt(int64(counted))) + } + return sums +} diff --git a/harnesses/transaction-fee/cmd/script/litecoin.go b/harnesses/transaction-fee/cmd/script/litecoin.go new file mode 100644 index 000000000..f7645c3af --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/litecoin.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Litecoin native-transfer fee sampler (UTXO). +// +// Litecoin uses a mempool fee market just like Bitcoin. We hit the +// litecoinspace.org mempool oracle (same vendor we already use in the L1 +// finality harness) at /api/v1/fees/recommended which returns +// litoshis/vByte for 5 named tiers: fastestFee, halfHourFee, hourFee, +// economyFee, minimumFee. +// +// A standard single-input / single-output P2WPKH native LTC transfer is +// ~225 vBytes. For each tier: +// +// fee_litoshis = lit_per_vbyte * 225 +// +// We emit only the three tiers we care about for cross-chain comparison: +// +// slow = hourFee * 225 +// std = halfHourFee * 225 +// fast = fastestFee * 225 +// +// Values are in litoshis (1 LTC = 10^8 litoshis). main.go's nativeToUSD() +// divides by 10^8 for KindUTXO. During low congestion all five fields are +// often 1 lit/vByte — 225 litoshis ≈ $0.0001, which is fine to emit. + +const ltcNativeTransferVBytes = 225.0 + +type litecoinFetcher struct { + http *http.Client +} + +func init() { + registerFetcher(KindUTXO, &litecoinFetcher{http: &http.Client{Timeout: 8 * time.Second}}) +} + +type ltcFeesResp struct { + FastestFee float64 `json:"fastestFee"` + HalfHourFee float64 `json:"halfHourFee"` + HourFee float64 `json:"hourFee"` + EconomyFee float64 `json:"economyFee"` + MinimumFee float64 `json:"minimumFee"` +} + +func (f *litecoinFetcher) Sample(ch ChainConfig) ([]FeeSample, error) { + url := ch.RPCURL + "/v1/fees/recommended" + resp, err := f.http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(raw[:min(len(raw), 200)])) //nolint:gomnd + } + var r ltcFeesResp + if err := json.Unmarshal(raw, &r); err != nil { + return nil, err + } + tiers := []struct { + name string + litPerVB float64 + }{ + {"slow", r.HourFee}, + {"std", r.HalfHourFee}, + {"fast", r.FastestFee}, + } + out := make([]FeeSample, 0, 3) + for _, t := range tiers { + out = append(out, FeeSample{ + Chain: ch.Slug, + Tier: t.name, + NativeFee: t.litPerVB * ltcNativeTransferVBytes, + GasPrice: t.litPerVB, // surface lit/vByte for parity with EVM gwei field + }) + } + return out, nil +} diff --git a/harnesses/transaction-fee/cmd/script/loghub.go b/harnesses/transaction-fee/cmd/script/loghub.go new file mode 100644 index 000000000..9dd744888 --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/loghub.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a +// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token +// matching the LOGS_TOKEN env var. +// +// Keep in sync across miniapps (was previously the shared/loghub package; we +// inline because Railway's per-harness Docker build context can't reach a +// sibling shared module via go.mod replace). + +const logRingMax = 5000 + +type logRing struct { + mu sync.Mutex + lines []string + max int +} + +var globalLogRing = &logRing{max: logRingMax} + +func (b *logRing) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + if len(b.lines) >= b.max { + b.lines = append(b.lines[1:], entry) + } else { + b.lines = append(b.lines, entry) + } + b.mu.Unlock() +} + +func (b *logRing) snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + if tail <= 0 || tail >= len(b.lines) { + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out + } + start := len(b.lines) - tail + out := make([]string, tail) + copy(out, b.lines[start:]) + return out +} + +var logSetupOnce sync.Once + +// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a +// pipe, then spawns a goroutine that fan-outs every line to the original +// stdout AND the in-memory ring buffer. Call exactly once, very early in +// main(). +func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) } + +func doInstallLogCapture() { + originalStdout := os.Stdout + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err) + return + } + os.Stdout = w + os.Stderr = w + + go func() { + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 1024*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Fprintln(originalStdout, line) + globalLogRing.push(line) + } + _, _ = io.Copy(originalStdout, r) + _ = originalStderr + }() +} + +// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header +// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset. +func logsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := os.Getenv("LOGS_TOKEN") + if expected == "" { + http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden) + return + } + if r.Header.Get("X-Logs-Token") != expected { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + tail := 500 + if t := r.URL.Query().Get("tail"); t != "" { + if n, err := strconv.Atoi(t); err == nil && n > 0 { + tail = n + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, l := range globalLogRing.snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/transaction-fee/cmd/script/main.go b/harnesses/transaction-fee/cmd/script/main.go new file mode 100644 index 000000000..d4c26ee28 --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/main.go @@ -0,0 +1,238 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +// Bench № tbd — current native-transfer transaction fee per chain. +// +// One process scrapes 11 L1 chains. For each chain we compute the current +// USD cost of a "standard" native-token transfer (an ETH transfer, a SOL +// transfer, an ADA transfer, etc.) every 30 s and expose it as Prometheus +// gauges. The same list as the L1 finality bench so users can compare +// "how fast does my transaction land" vs "how much does my transaction +// cost" head-to-head. +// +// Headline metric per chain: +// tx_fee_native_transfer_usd{chain, tier} +// +// where tier is one of slow/std/fast for chains with a priority market, +// or "single" for deterministic chains (Cardano, Stellar, TRON bandwidth). + +func main() { + installLogCapture() // capture stdout into /logs ring buffer + fmt.Println("=== Transaction Fee Monitor ===") + fmt.Println("Native-transfer USD cost across 11 L1 chains.") + fmt.Println() + + cfg := loadConfig() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + fmt.Println("Starting Prometheus metrics server on :2112") + if err := StartMetricsServer(":2112"); err != nil { + fmt.Printf("Metrics server error: %v\n", err) + } + }() + + mobula := NewMobulaClient(cfg.MobulaAPIKey) + + // Price refresher — pull all 11 prices in one Mobula call. + wg.Add(1) + go func() { + defer wg.Done() + runPriceLoop(cfg, mobula) + }() + + // Wait one tick so prices are populated before first fee sample. + time.Sleep(2 * time.Second) + + // Per-chain sampler goroutine. + for _, ch := range cfg.Chains { + ch := ch + wg.Add(1) + go func() { + defer wg.Done() + runChainLoop(ch, cfg.Interval) + }() + } + + <-sigChan + fmt.Println("\nShutting down...") + os.Exit(0) +} + +func runPriceLoop(cfg *Config, m *MobulaClient) { + slugs := make([]string, 0, len(cfg.Chains)) + seen := map[string]bool{} + for _, c := range cfg.Chains { + if !seen[c.MobulaSlug] { + slugs = append(slugs, c.MobulaSlug) + seen[c.MobulaSlug] = true + } + } + + tick := func() { + prices, err := m.FetchPrices(slugs) + if err != nil { + fmt.Printf("[PRICE] fetch error: %v\n", err) + return + } + for slug, p := range prices { + setPrice(slug, p) + } + fmt.Printf("[PRICE] refreshed %d prices\n", len(prices)) + } + + tick() + t := time.NewTicker(cfg.PriceRefresh) + defer t.Stop() + for range t.C { + tick() + } +} + +func runChainLoop(ch ChainConfig, interval time.Duration) { + fetcher := getFetcher(ch) + if fetcher == nil { + fmt.Printf("[%s] no fetcher for kind=%s, skipping\n", ch.Slug, ch.Kind) + healthOK.WithLabelValues(ch.Slug, ch.Layer).Set(0) + return + } + + tick := func() { + samples, err := fetcher.Sample(ch) + if err != nil { + fmt.Printf("[%s] fetch error: %v\n", ch.Slug, err) + fetchErrors.WithLabelValues(ch.Slug, classifyErr(err), ch.Layer).Inc() + healthOK.WithLabelValues(ch.Slug, ch.Layer).Set(0) + return + } + price := getPrice(ch.MobulaSlug) + if price <= 0 { + fmt.Printf("[%s] missing USD price for %s, skipping emit\n", ch.Slug, ch.MobulaSlug) + healthOK.WithLabelValues(ch.Slug, ch.Layer).Set(0) + return + } + priceUSDGauge.WithLabelValues(ch.Slug, ch.Layer).Set(price) + for _, s := range samples { + feeNativeGauge.WithLabelValues(ch.Slug, s.Tier, ch.Layer).Set(s.NativeFee) + usd := s.UsdFee + if usd == 0 { + // Fetcher returned native units only; multiply now. + usd = nativeToUSD(ch, s.NativeFee, price) + } + feeUSDGauge.WithLabelValues(ch.Slug, s.Tier, ch.Layer).Set(usd) + if s.GasPrice > 0 { + gasPriceGauge.WithLabelValues(ch.Slug, s.Tier, ch.Layer).Set(s.GasPrice) + } + } + lastRefreshGauge.WithLabelValues(ch.Slug, ch.Layer).Set(float64(time.Now().Unix())) + healthOK.WithLabelValues(ch.Slug, ch.Layer).Set(1) + } + + tick() + t := time.NewTicker(interval) + defer t.Stop() + for range t.C { + tick() + } +} + +// nativeToUSD converts native smallest-unit fee to USD using the chain's +// known decimals. Each chain has its own scale. +func nativeToUSD(ch ChainConfig, native, priceUSD float64) float64 { + var decimals int + switch ch.Kind { + case KindEVM: + decimals = 18 // wei + case KindSolana: + decimals = 9 // lamport + case KindCardano: + decimals = 6 // lovelace + case KindStellar: + decimals = 7 // stroop + case KindTron: + decimals = 6 // sun + case KindSui: + decimals = 9 // MIST + case KindTon: + decimals = 9 // nanoton + case KindUTXO: + decimals = 8 // satoshi (Litecoin) + case KindMonero: + decimals = 12 // atomic units + default: + decimals = 18 + } + scale := 1.0 + for i := 0; i < decimals; i++ { + scale *= 10 + } + return (native / scale) * priceUSD +} + +// classifyErr returns a short error_type label for Prom counters. +func classifyErr(err error) string { + s := err.Error() + switch { + case containsAny(s, "timeout", "deadline exceeded", "EOF"): + return "timeout" + case containsAny(s, "connection refused", "no such host", "lookup"): + return "network" + case containsAny(s, "http 4"): + return "http4xx" + case containsAny(s, "http 5"): + return "http5xx" + case containsAny(s, "decode", "unmarshal", "JSON"): + return "parse" + default: + return "other" + } +} + +func containsAny(s string, subs ...string) bool { + for _, sub := range subs { + if len(s) >= len(sub) && (indexOf(s, sub) >= 0) { + return true + } + } + return false +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} + +// Fetcher is implemented by each per-chain sampler. +type Fetcher interface { + Sample(ch ChainConfig) ([]FeeSample, error) +} + +// getFetcher picks the right fetcher implementation for a chain. +// Wired up by per-chain files via init() registering into this map. +var fetcherRegistry = map[ChainKind]Fetcher{} + +func registerFetcher(k ChainKind, f Fetcher) { + fetcherRegistry[k] = f +} + +func getFetcher(ch ChainConfig) Fetcher { + return fetcherRegistry[ch.Kind] +} diff --git a/harnesses/transaction-fee/cmd/script/metrics.go b/harnesses/transaction-fee/cmd/script/metrics.go new file mode 100644 index 000000000..c2a011242 --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/metrics.go @@ -0,0 +1,129 @@ +package main + +import ( + "net/http" + "sync" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// FeeSample is one snapshot of a chain's native-transfer fee. +// +// NativeFee is in the chain's smallest unit (wei, lamport, lovelace, stroop, +// satoshi, atomic units, sun, …) so we don't lose precision in float math. +// UsdFee is computed at scrape time using the latest Mobula price. +type FeeSample struct { + Chain string + Tier string // "slow" | "std" | "fast" | "single" + NativeFee float64 + UsdFee float64 + GasPrice float64 // optional, EVM only — gwei + Err string +} + +var ( + feeUSDGauge *prometheus.GaugeVec + feeNativeGauge *prometheus.GaugeVec + gasPriceGauge *prometheus.GaugeVec + priceUSDGauge *prometheus.GaugeVec + lastRefreshGauge *prometheus.GaugeVec + fetchErrors *prometheus.CounterVec + healthOK *prometheus.GaugeVec +) + +func init() { + feeUSDGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "tx_fee_native_transfer_usd", + Help: "Current cost in USD of a standard native-token transfer on the chain. Lower = cheaper.", + }, + []string{"chain", "tier", "layer"}, + ) + prometheus.MustRegister(feeUSDGauge) + + feeNativeGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "tx_fee_native_transfer_native", + Help: "Current cost in the chain's native smallest unit (wei, lamport, lovelace, satoshi, etc.).", + }, + []string{"chain", "tier", "layer"}, + ) + prometheus.MustRegister(feeNativeGauge) + + gasPriceGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "tx_fee_gas_price_gwei", + Help: "Gas price in gwei (EVM chains only). Decomposes the USD headline.", + }, + []string{"chain", "tier", "layer"}, + ) + prometheus.MustRegister(gasPriceGauge) + + priceUSDGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "tx_fee_native_token_price_usd", + Help: "USD price of the chain's native token used for the current sample.", + }, + []string{"chain", "layer"}, + ) + prometheus.MustRegister(priceUSDGauge) + + lastRefreshGauge = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "tx_fee_last_refresh_timestamp_seconds", + Help: "Unix timestamp of the last successful sample per chain.", + }, + []string{"chain", "layer"}, + ) + prometheus.MustRegister(lastRefreshGauge) + + fetchErrors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "tx_fee_fetch_errors_total", + Help: "Total fetch failures per chain, by error type.", + }, + []string{"chain", "error_type", "layer"}, + ) + prometheus.MustRegister(fetchErrors) + + healthOK = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "tx_fee_health", + Help: "1 if the chain produced a fresh sample in the last refresh, 0 otherwise.", + }, + []string{"chain", "layer"}, + ) + prometheus.MustRegister(healthOK) +} + +var ( + lastPrices = make(map[string]float64) + lastPricesMu sync.RWMutex +) + +func setPrice(slug string, p float64) { + lastPricesMu.Lock() + lastPrices[slug] = p + lastPricesMu.Unlock() + // The Prom gauge is now per-chain (chain+layer labels) and emitted from + // runChainLoop where we know the chain identity; here we only update the + // in-memory cache keyed by Mobula slug. +} + +func getPrice(slug string) float64 { + lastPricesMu.RLock() + defer lastPricesMu.RUnlock() + return lastPrices[slug] +} + +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/transaction-fee/cmd/script/mobula.go b/harnesses/transaction-fee/cmd/script/mobula.go new file mode 100644 index 000000000..4bd251ee0 --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/mobula.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// MobulaClient fetches USD prices from Mobula's public market API. +// +// We pull all 11 native tokens in one /market/multi-data call every +// PriceRefresh (30s) and cache them. Each chain fetcher reads the cached +// price at sample emit time. +type MobulaClient struct { + apiKey string + http *http.Client +} + +func NewMobulaClient(apiKey string) *MobulaClient { + return &MobulaClient{ + apiKey: apiKey, + http: &http.Client{Timeout: 8 * time.Second}, + } +} + +type multiDataResp struct { + Data map[string]struct { + Price float64 `json:"price"` + } `json:"data"` +} + +// FetchPrices pulls USD prices for the given Mobula slugs in one call. +// Returns map[slug]price. Missing slugs are simply absent from the map. +func (m *MobulaClient) FetchPrices(slugs []string) (map[string]float64, error) { + url := "https://api.mobula.io/api/1/market/multi-data?assets=" + strings.Join(slugs, ",") + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + if m.apiKey != "" { + req.Header.Set("Authorization", m.apiKey) + } + resp, err := m.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("mobula http %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) + } + var out multiDataResp + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + prices := make(map[string]float64, len(out.Data)) + for slug, v := range out.Data { + if v.Price > 0 { + prices[slug] = v.Price + } + } + return prices, nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/harnesses/transaction-fee/cmd/script/monero.go b/harnesses/transaction-fee/cmd/script/monero.go new file mode 100644 index 000000000..bfa8915c9 --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/monero.go @@ -0,0 +1,118 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Monero native-transfer fee sampler. +// +// Monero exposes a JSON-RPC method `get_fee_estimate` on monerod that +// returns the network's current per-byte fee in atomic units (piconero, +// 1 XMR = 10^12). Modern nodes return a `fees` array with 4 entries +// representing tiered fee levels [slow, std, fast, fastest]. We map: +// +// slow = fees[0] * 1500 +// std = fees[1] * 1500 +// fast = fees[2] * 1500 +// +// 1500 bytes is the rough size of a 1-input / 2-output ringct transaction +// (the dominant native-transfer shape on Monero today). +// +// Fallback: if the `fees` array is missing or has fewer than 3 entries we +// emit a single "std" tier using the scalar `fee` field × 1500. +// +// main.go divides by 10^12 (KindMonero) to convert atomic units → XMR. + +const xmrNativeTransferBytes = 1500.0 + +type moneroFetcher struct { + http *http.Client +} + +func init() { + registerFetcher(KindMonero, &moneroFetcher{http: &http.Client{Timeout: 8 * time.Second}}) +} + +type xmrFeeEstReq struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` +} + +type xmrFeeEstResp struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id"` + Result struct { + Fee float64 `json:"fee"` + Fees []float64 `json:"fees"` + QuantizationMask float64 `json:"quantization_mask"` + Status string `json:"status"` + Untrusted bool `json:"untrusted"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (f *moneroFetcher) Sample(ch ChainConfig) ([]FeeSample, error) { + url := ch.RPCURL + "/json_rpc" + body, _ := json.Marshal(xmrFeeEstReq{ + JSONRPC: "2.0", + ID: "0", + Method: "get_fee_estimate", + Params: map[string]any{}, + }) + resp, err := f.http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(raw[:min(len(raw), 200)])) //nolint:gomnd + } + var r xmrFeeEstResp + if err := json.Unmarshal(raw, &r); err != nil { + return nil, err + } + if r.Error != nil { + return nil, fmt.Errorf("rpc error: %s", r.Error.Message) + } + if r.Result.Status != "" && r.Result.Status != "OK" { + return nil, fmt.Errorf("monero rpc status: %s", r.Result.Status) + } + + // Preferred path: tiered fees array. + if len(r.Result.Fees) >= 3 { + tiers := []string{"slow", "std", "fast"} + out := make([]FeeSample, 0, 3) + for i, name := range tiers { + perByte := r.Result.Fees[i] + out = append(out, FeeSample{ + Chain: ch.Slug, + Tier: name, + NativeFee: perByte * xmrNativeTransferBytes, + GasPrice: perByte, // atomic units per byte + }) + } + return out, nil + } + + // Fallback: scalar `fee` only. + if r.Result.Fee <= 0 { + return nil, fmt.Errorf("monero: no fee data (fees=%v fee=%v)", r.Result.Fees, r.Result.Fee) + } + return []FeeSample{{ + Chain: ch.Slug, + Tier: "std", + NativeFee: r.Result.Fee * xmrNativeTransferBytes, + GasPrice: r.Result.Fee, + }}, nil +} diff --git a/harnesses/transaction-fee/cmd/script/solana.go b/harnesses/transaction-fee/cmd/script/solana.go new file mode 100644 index 000000000..4244cc543 --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/solana.go @@ -0,0 +1,140 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "time" +) + +// Solana native-transfer fee sampler. +// +// Solana's fee model has two parts: +// 1. Base fee: 5000 lamports per signature, flat, always paid. +// 2. Priority fee: variable, paid in micro-lamports per Compute Unit (CU), +// consumed only when the tx sets a ComputeBudget priority instruction. +// +// We pull recent priority fees via getRecentPrioritizationFees (returns up +// to ~150 samples from the last 150 slots). A standard system-program SOL +// transfer burns ~200 CU, so the priority cost in lamports is: +// +// priority_lamports = (microlamports_per_cu * 200) / 1_000_000 +// +// Total fee per tier: +// +// fee_lamports[tier] = 5000 + priority_lamports[ p25 | p50 | p90 ] +// +// We drop zero-fee samples before percentile bucketing — idle slots are +// extremely common on Solana and would flatten every tier to the 5000 +// base. If every sample is zero or the array is empty, we emit a single +// "std" tier with just the 5000 base. + +const ( + solBaseFeeLamports = 5000.0 + solTransferCU = 200.0 +) + +type solanaFetcher struct { + http *http.Client +} + +func init() { + registerFetcher(KindSolana, &solanaFetcher{http: &http.Client{Timeout: 8 * time.Second}}) +} + +type solPriorityFeeResp struct { + JSONRPC string `json:"jsonrpc"` + Result []struct { + Slot uint64 `json:"slot"` + PrioritizationFee float64 `json:"prioritizationFee"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` + ID int `json:"id"` +} + +func (f *solanaFetcher) Sample(ch ChainConfig) ([]FeeSample, error) { + body, _ := json.Marshal(jsonRPCReq{ + JSONRPC: "2.0", + Method: "getRecentPrioritizationFees", + Params: []any{}, + ID: 1, + }) + resp, err := f.http.Post(ch.RPCURL, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(raw[:min(len(raw), 200)])) //nolint:gomnd + } + var r solPriorityFeeResp + if err := json.Unmarshal(raw, &r); err != nil { + return nil, err + } + if r.Error != nil { + return nil, fmt.Errorf("rpc error: %s", r.Error.Message) + } + + // Collect non-zero priority fee samples (microlamports per CU). + fees := make([]float64, 0, len(r.Result)) + for _, s := range r.Result { + if s.PrioritizationFee > 0 { + fees = append(fees, s.PrioritizationFee) + } + } + + if len(fees) == 0 { + // Idle network or empty response: emit single std tier with just base fee. + return []FeeSample{{ + Chain: ch.Slug, + Tier: "std", + NativeFee: solBaseFeeLamports, + }}, nil + } + + sort.Float64s(fees) + + tiers := []struct { + name string + pct float64 + }{ + {"slow", 0.25}, + {"std", 0.50}, + {"fast", 0.90}, + } + + out := make([]FeeSample, 0, 3) + for _, t := range tiers { + microLamportsPerCU := percentile(fees, t.pct) + priorityLamports := (microLamportsPerCU * solTransferCU) / 1_000_000.0 + out = append(out, FeeSample{ + Chain: ch.Slug, + Tier: t.name, + NativeFee: solBaseFeeLamports + priorityLamports, + }) + } + return out, nil +} + +// percentile returns the value at percentile p (0..1) of a pre-sorted slice +// using nearest-rank. Caller guarantees len(sorted) > 0. +func percentile(sorted []float64, p float64) float64 { + if len(sorted) == 1 { + return sorted[0] + } + idx := int(p * float64(len(sorted)-1)) + if idx < 0 { + idx = 0 + } + if idx >= len(sorted) { + idx = len(sorted) - 1 + } + return sorted[idx] +} diff --git a/harnesses/transaction-fee/cmd/script/stellar.go b/harnesses/transaction-fee/cmd/script/stellar.go new file mode 100644 index 000000000..a32699f63 --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/stellar.go @@ -0,0 +1,87 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// Stellar native-transfer fee sampler. +// +// Stellar charges base_fee * num_operations per transaction. A native XLM +// payment is exactly one operation, so the fee is `last_ledger_base_fee` +// stroops. The nominal base is 100 stroops, but Stellar uses surge pricing: +// when a ledger is full, the base rises (the network-wide minimum bid is +// effectively bumped). We read the current bid from Horizon /fee_stats: +// +// GET https://horizon.stellar.org/fee_stats +// +// which returns `last_ledger_base_fee` (stringified stroops) along with +// percentiles of recently-charged fees. We only need the base for a +// deterministic single-op payment. +// +// Result is one FeeSample with Tier="single" in stroops; main.go divides +// by 10^7 to convert to XLM before applying the USD price. + +const stellarNominalBaseFeeStroops = 100.0 + +type stellarFetcher struct { + http *http.Client +} + +func init() { + registerFetcher(KindStellar, &stellarFetcher{http: &http.Client{Timeout: 8 * time.Second}}) +} + +type horizonFeeStats struct { + LastLedgerBaseFee string `json:"last_ledger_base_fee"` +} + +func (f *stellarFetcher) Sample(ch ChainConfig) ([]FeeSample, error) { + baseFee, err := f.fetchBaseFee(ch.RPCURL) + if err != nil { + return nil, fmt.Errorf("stellar fee_stats: %w", err) + } + + // One operation per native payment. + return []FeeSample{{ + Chain: ch.Slug, + Tier: "single", + NativeFee: baseFee, + }}, nil +} + +func (f *stellarFetcher) fetchBaseFee(baseURL string) (float64, error) { + url := baseURL + "/fee_stats" + resp, err := f.http.Get(url) + if err != nil { + return 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + end := len(raw) + if end > 200 { + end = 200 + } + return 0, fmt.Errorf("http %d: %s", resp.StatusCode, string(raw[:end])) + } + var r horizonFeeStats + if err := json.Unmarshal(raw, &r); err != nil { + return 0, fmt.Errorf("decode fee_stats: %w", err) + } + if r.LastLedgerBaseFee == "" { + return stellarNominalBaseFeeStroops, nil + } + v, err := strconv.ParseFloat(r.LastLedgerBaseFee, 64) + if err != nil { + return 0, fmt.Errorf("parse last_ledger_base_fee=%q: %w", r.LastLedgerBaseFee, err) + } + if v <= 0 { + return stellarNominalBaseFeeStroops, nil + } + return v, nil +} diff --git a/harnesses/transaction-fee/cmd/script/sui.go b/harnesses/transaction-fee/cmd/script/sui.go new file mode 100644 index 000000000..23c5d894b --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/sui.go @@ -0,0 +1,88 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// SUI native-transfer fee sampler. +// +// Calls JSON-RPC `suix_getReferenceGasPrice` against the mainnet fullnode +// to fetch the current reference gas price (MIST per gas unit). Multiplies +// by a hardcoded typical gas budget for a basic SUI Coin::transfer. +// +// Gas budget choice: a Coin::transfer entry function consumes ~76_000 +// computation + storage units in mainnet conditions (verified via +// `sui_dryRunTransactionBlock` on a known transfer). The initial value of +// 2_000_000 was the on-chain *budget* (max gas the wallet authorises) not +// the *actual cost* — that mismatch produced fees ~25x higher than what +// users actually pay. +// +// An accurate live estimate would require `sui_dryRunTransactionBlock` +// with a real signed-ish transaction payload, which is too heavy for a +// 30s scrape. 76_000 is the typical observed cost for a basic transfer +// and aligns with SUI explorer (e.g. suivision.xyz) — about $0.001 at +// current SUI prices. +// +// Emits a single "std" tier — SUI gas pricing is effectively flat at the +// reference price (validators rarely deviate). + +const suiTransferGasBudget = 76_000 + +type suiFetcher struct { + http *http.Client +} + +func init() { + registerFetcher(KindSui, &suiFetcher{http: &http.Client{Timeout: 8 * time.Second}}) +} + +type suiRPCResp struct { + JSONRPC string `json:"jsonrpc"` + Result string `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (f *suiFetcher) Sample(ch ChainConfig) ([]FeeSample, error) { + body, _ := json.Marshal(jsonRPCReq{ + JSONRPC: "2.0", + Method: "suix_getReferenceGasPrice", + Params: []any{}, + ID: 1, + }) + resp, err := f.http.Post(ch.RPCURL, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(raw[:min(len(raw), 200)])) + } + var r suiRPCResp + if err := json.Unmarshal(raw, &r); err != nil { + return nil, fmt.Errorf("decode sui rpc: %w", err) + } + if r.Error != nil { + return nil, fmt.Errorf("rpc error: %s", r.Error.Message) + } + gasPrice, err := strconv.ParseInt(r.Result, 10, 64) + if err != nil || gasPrice <= 0 { + return nil, fmt.Errorf("bad reference gas price: %q", r.Result) + } + feeMist := float64(gasPrice * suiTransferGasBudget) + return []FeeSample{{ + Chain: ch.Slug, + Tier: "std", + NativeFee: feeMist, + GasPrice: float64(gasPrice), + }}, nil +} diff --git a/harnesses/transaction-fee/cmd/script/ton.go b/harnesses/transaction-fee/cmd/script/ton.go new file mode 100644 index 000000000..4969de8d0 --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/ton.go @@ -0,0 +1,38 @@ +package main + +import ( + "time" +) + +// TON native-transfer fee sampler. +// +// TON's fee model (storage + gas + forward + in_fwd) is non-trivial and +// no public API exposes a one-shot "estimate native transfer cost" call +// without submitting a real BoC. The typical observed cost of a vanilla +// wallet-v4 native TON transfer is ~5_000_000 nanoton (0.005 TON), which +// we hardcode here as the honest conservative estimate. +// +// If a real TON consumer use case emerges, swap this for tonapi +// `/v2/wallet/emulate` or LiteAPI `runGetMethod` over an emulated +// transfer. For now: deterministic single tier. +// +// 1 TON = 10^9 nanoton — main.go KindTon scales accordingly. + +const tonTypicalTransferNanoton = 5_000_000.0 + +type tonFetcher struct { + timeout time.Duration +} + +func init() { + registerFetcher(KindTon, &tonFetcher{timeout: 8 * time.Second}) +} + +func (f *tonFetcher) Sample(ch ChainConfig) ([]FeeSample, error) { + // Hardcoded honest value — see top-of-file comment for rationale. + return []FeeSample{{ + Chain: ch.Slug, + Tier: "single", + NativeFee: tonTypicalTransferNanoton, + }}, nil +} diff --git a/harnesses/transaction-fee/cmd/script/tron.go b/harnesses/transaction-fee/cmd/script/tron.go new file mode 100644 index 000000000..f697a628c --- /dev/null +++ b/harnesses/transaction-fee/cmd/script/tron.go @@ -0,0 +1,77 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// TRON native-transfer fee sampler. +// +// TRON uses a bandwidth/energy model. A native TRX transfer consumes +// bandwidth only (no energy — energy is for smart contracts). A typical +// TRX transfer is ~268 bytes. Each byte costs `getTransactionFee` SUN +// from chain params (currently 1000 SUN/byte = 0.001 TRX/byte). +// +// Free bandwidth allowance is 600 bytes/day/account, but we model the +// "paid" case (no free bandwidth) since that's the worst-case observable +// cost. Result: ~268_000 SUN (0.268 TRX) at 1000 SUN/byte. +// +// We query https://api.trongrid.io/wallet/getchainparameters to fetch +// the live `getTransactionFee` value. Single tier "single" — TRON has +// no priority market for native transfers. + +const tronTransferBytes = 268 + +type tronFetcher struct { + http *http.Client +} + +func init() { + registerFetcher(KindTron, &tronFetcher{http: &http.Client{Timeout: 8 * time.Second}}) +} + +type tronChainParam struct { + Key string `json:"key"` + Value int64 `json:"value"` +} + +type tronChainParamsResp struct { + ChainParameter []tronChainParam `json:"chainParameter"` +} + +func (f *tronFetcher) Sample(ch ChainConfig) ([]FeeSample, error) { + url := ch.RPCURL + "/wallet/getchainparameters" + resp, err := f.http.Post(url, "application/json", bytes.NewReader([]byte("{}"))) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(raw[:min(len(raw), 200)])) + } + var r tronChainParamsResp + if err := json.Unmarshal(raw, &r); err != nil { + return nil, fmt.Errorf("decode chainparameters: %w", err) + } + var sunPerByte int64 + for _, p := range r.ChainParameter { + if p.Key == "getTransactionFee" { + sunPerByte = p.Value + break + } + } + if sunPerByte <= 0 { + return nil, fmt.Errorf("getTransactionFee not found in chain params") + } + feeSun := float64(sunPerByte * tronTransferBytes) + return []FeeSample{{ + Chain: ch.Slug, + Tier: "single", + NativeFee: feeSun, + }}, nil +} diff --git a/harnesses/transaction-fee/go.mod b/harnesses/transaction-fee/go.mod new file mode 100644 index 000000000..4a5eb04cb --- /dev/null +++ b/harnesses/transaction-fee/go.mod @@ -0,0 +1,18 @@ +module transaction-fee + +go 1.24.0 + +require github.com/prometheus/client_golang v1.23.2 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/transaction-fee/go.sum b/harnesses/transaction-fee/go.sum new file mode 100644 index 000000000..d6b8ca98b --- /dev/null +++ b/harnesses/transaction-fee/go.sum @@ -0,0 +1,46 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/wallet-labels/cmd/script/integration.go b/harnesses/wallet-labels/cmd/script/integration.go index a948cabd5..51b5b6e11 100644 --- a/harnesses/wallet-labels/cmd/script/integration.go +++ b/harnesses/wallet-labels/cmd/script/integration.go @@ -26,7 +26,7 @@ var integrationCases = []testCase{ {"bnb", "0xF977814e90dA44bFA03b6295A0616a897441aceC", "Binance Hot 8 (BSC)"}, // Solana {"solana", "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1", "Raydium Authority"}, - // Gram (formerly TON) + // TON {"ton", "EQB3ncyBUTjZUA5EnFKR5_EnOMI9V1tTEAAPaiU71gc4TiUt", "STON.fi DEX"}, // Stellar {"stellar", "GAHK7EEG2WWHVKDNT4CEQFZGKF2LGDSW2IVM4S5DP42RBW3K6BTODB4A", "Binance"}, diff --git a/harnesses/wallet-labels/cmd/script/metrics.go b/harnesses/wallet-labels/cmd/script/metrics.go index 44c7013e0..cc9d05ec9 100644 --- a/harnesses/wallet-labels/cmd/script/metrics.go +++ b/harnesses/wallet-labels/cmd/script/metrics.go @@ -50,8 +50,23 @@ var ( Help: "Pending wallet checks in the queue.", ConstLabels: commonLabels, }) + + // skippedTotal counts provider self-throttles (currently only Moralis + // rate-limiting its own calls to fit the free-tier daily CU budget). + // Tracked separately from checks_total and fetch_errors_total so the + // success ratio stays statistically valid on the actually-issued + // subset and an SRE can still see whether the throttle is biting. + skippedTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "wallet_labels_skipped_total", + Help: "Provider self-throttled calls (not counted as checks or errors).", + ConstLabels: commonLabels, + }, []string{"provider", "chain"}) ) +func recordSkipped(provider, chain string) { + skippedTotal.WithLabelValues(provider, chain).Inc() +} + func recordCheck(provider, chain, kind string, hasLabel bool, latencyMs float64, err error) { if kind == "" { kind = "unknown" diff --git a/harnesses/wallet-labels/cmd/script/monitor.go b/harnesses/wallet-labels/cmd/script/monitor.go index f52c85ba8..9ca9e027d 100644 --- a/harnesses/wallet-labels/cmd/script/monitor.go +++ b/harnesses/wallet-labels/cmd/script/monitor.go @@ -96,6 +96,14 @@ func lookupAll(ctx context.Context, providers []Provider, s sample) { any := false compact := "" for r := range results { + // Skipped calls (currently only Moralis self-throttle) don't go + // to recordCheck — they neither succeeded nor failed, so leaving + // them out keeps the success/checks ratio honest. + if r.Skipped { + recordSkipped(r.Provider, r.Chain) + compact += " " + abbrev(r.Provider) + ":-" + continue + } recordCheck(r.Provider, r.Chain, s.kind, r.HasLabel, float64(r.LatencyMs), r.Err) recordDebug(debugEntry{ Provider: r.Provider, Chain: r.Chain, Address: r.Address, diff --git a/harnesses/wallet-labels/cmd/script/moralis.go b/harnesses/wallet-labels/cmd/script/moralis.go index 001cca6d2..4752c1923 100644 --- a/harnesses/wallet-labels/cmd/script/moralis.go +++ b/harnesses/wallet-labels/cmd/script/moralis.go @@ -5,10 +5,68 @@ import ( "encoding/json" "fmt" "net/http" + "os" + "strconv" "strings" + "sync" "time" ) +// moralisMinInterval gates the rate at which we call the Moralis +// /entities endpoint to keep daily compute-unit usage under the 40 k CU/ +// day free-tier ceiling. Anchor-feeder loop produced ~5.6 k Moralis +// checks/day at ~25 CU each = ~140 k CU/day, which saturated the key by +// mid-morning and 90 % of subsequent calls returned 401 (auth_err). The +// floor here is the cadence needed to come in under 40 k CU: +// +// 40 000 CU / 25 CU per call ≈ 1 600 calls / day +// 86 400 s / 1 600 calls ≈ 54 s between calls +// +// 58 s gives us a small safety margin (≈ 1 489 calls / day, ≈ 37 k CU) +// without leaving headroom unused. Override with +// MORALIS_MIN_INTERVAL_SECONDS for paid Moralis tiers. +// +// Throttled calls return early with a skipped flag rather than firing +// a guaranteed-401 request. The bench's success_ratio = success/checks +// stays statistically valid at ~1 500 checks/day (standard error ±1.3 +// pp on a binomial proportion, versus ±0.7 pp at the previous 5 500/day +// rate — imperceptible on the page leaderboard). +var ( + moralisMinIntervalOnce sync.Once + moralisMinInterval time.Duration + moralisLastCallMu sync.Mutex + moralisLastCallAt time.Time +) + +func loadMoralisMinInterval() { + moralisMinIntervalOnce.Do(func() { + moralisMinInterval = 58 * time.Second + if v := strings.TrimSpace(os.Getenv("MORALIS_MIN_INTERVAL_SECONDS")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + moralisMinInterval = time.Duration(n) * time.Second + } + } + }) +} + +// shouldSkipMoralisCall returns true when the previous call was too +// recent — the bench-time anchor feeder calls Moralis hundreds of times +// faster than the free-tier CU budget allows; throttling here avoids +// burning the daily quota in the first hour. +func shouldSkipMoralisCall() bool { + loadMoralisMinInterval() + if moralisMinInterval == 0 { + return false + } + moralisLastCallMu.Lock() + defer moralisLastCallMu.Unlock() + if time.Since(moralisLastCallAt) < moralisMinInterval { + return true + } + moralisLastCallAt = time.Now() + return false +} + type MoralisProvider struct{ apiKey string } func NewMoralisProvider(key string) *MoralisProvider { return &MoralisProvider{apiKey: key} } @@ -50,6 +108,14 @@ func (p *MoralisProvider) Lookup(ctx context.Context, chain, address string) Lab if slug == "" || p.apiKey == "" { return res } + // Daily CU budget gate. Skip silently — neither success nor error + // gets recorded for the throttled cycle, so the bench's + // success/checks ratio remains computed only over actually-issued + // calls and the rate stays statistically valid. + if shouldSkipMoralisCall() { + res.Skipped = true + return res + } start := time.Now() u := "https://deep-index.moralis.io/api/v2.2/entities?chain=" + slug + "&address=" + address + "&limit=1" req, _ := http.NewRequestWithContext(ctx, "GET", u, nil) diff --git a/harnesses/wallet-labels/cmd/script/provider.go b/harnesses/wallet-labels/cmd/script/provider.go index 3f7c7e429..81e805fea 100644 --- a/harnesses/wallet-labels/cmd/script/provider.go +++ b/harnesses/wallet-labels/cmd/script/provider.go @@ -18,6 +18,12 @@ type LabelResult struct { LatencyMs int64 Err error Raw map[string]any + // Skipped is set when a provider chose to drop this call (rather + // than fail it) — currently only Moralis, which gates its own + // rate to stay under the free-tier daily compute-unit budget. A + // skipped call increments neither checks_total nor errors_total + // so the bench's success/checks ratio remains honest. + Skipped bool } // Provider is implemented by every label source. `Supports` lets us skip diff --git a/next.config.ts b/next.config.ts index 25e4f89a1..ed60def60 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,7 +6,7 @@ import type { NextConfig } from "next"; // JSON.stringify of editor-controlled data, no user input, so the residual // XSS risk is bounded. Future hardening: move JSON-LD to <Script> with a // sha256 hash in script-src. -const RELAY_WS = "wss://ocb-stream-relay-production.up.railway.app"; +const RELAY_WS = "wss://stream.openchainbench.com"; // Origin of the standalone Remotion renderer that serves Export Video MP4s // (cached via sha256 at /v/<hash>.mp4). Allowed in media-src so the // modal's <video> tag can play the result, and in connect-src so the diff --git a/public/logos/binance.png b/public/logos/binance.png new file mode 100644 index 000000000..5bb89bf65 Binary files /dev/null and b/public/logos/binance.png differ diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index bbc27c9fd..555374d47 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -9,7 +9,7 @@ export const metadata: Metadata = pageMetadata({ path: "/about", title: "About", description: - "OpenChainBench publishes open, reproducible benchmarks for crypto infrastructure: RPC latency, bridge fees, L2 finality and oracle deviation. Funded by Mobula, MIT licensed.", + "Open, reproducible benchmarks for crypto infrastructure: RPC latency, bridge fees, L2 finality, oracle deviation. Funded by Mobula, MIT licensed.", }); export default function AboutPage() { diff --git a/src/app/answers/[slug]/page.tsx b/src/app/answers/[slug]/page.tsx index f33a21a7f..c804399ac 100644 --- a/src/app/answers/[slug]/page.tsx +++ b/src/app/answers/[slug]/page.tsx @@ -61,12 +61,14 @@ export async function generateMetadata({ type: "article", url, siteName: SITE.name, + images: [{ url: `${SITE.url}/opengraph-image`, width: 1200, height: 630 }], }, twitter: { card: "summary_large_image", site: SITE.twitter, title, description, + images: [`${SITE.url}/twitter-image`], }, }; } diff --git a/src/app/api/bench/hyperliquid-frontends/history/route.test.ts b/src/app/api/bench/hyperliquid-frontends/history/route.test.ts new file mode 100644 index 000000000..f17eb47b8 --- /dev/null +++ b/src/app/api/bench/hyperliquid-frontends/history/route.test.ts @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { ArchiveSnapshot } from "@/types/hl-archive"; +import type { Benchmark } from "@/types/benchmark"; + +/** Stub Benchmark with the panel ids the route reads from. Only the fields + * the route touches are populated; everything else is a sensible zero so + * the test cannot depend on unrelated spec state. */ +function stubBench(): Benchmark { + return { + slug: "hyperliquid-frontends", + number: "27", + title: "HL frontends", + subtitle: "", + abstract: "", + metric: "Builder fees collected (USD)", + unit: "usd", + higherIsBetter: true, + sampleSize: 0, + lastRunAt: "2026-06-25T00:00:00.000Z", + status: "live", + editorialStatus: "live", + category: "Trading", + results: [ + { + slug: "phantom", + name: "Phantom", + availability: "live", + ms: { p50: 1000, p90: 100_000, p99: 0, mean: 0 }, + successRate: 100, + }, + { + slug: "metamask", + name: "MetaMask", + availability: "live", + ms: { p50: 500, p90: 50_000, p99: 0, mean: 0 }, + successRate: 100, + }, + { + slug: "offline-thing", + name: "Offline", + availability: "unavailable", + ms: { p50: 0, p90: 0, p99: 0, mean: 0 }, + successRate: 0, + }, + ], + findings: [], + methodology: [], + source: "", + extras: { series24h: {}, regions: {} }, + metricPanels: [ + { + id: "revenue_7d", + label: "Revenue 7d", + metric: "hl_frontend_fees_usd_7d_v2", + unit: "usd", + higherIsBetter: true, + tab: false, + values: { phantom: 7_000, metamask: 3_500 }, + }, + { + id: "volume_7d", + label: "Volume 7d", + metric: "hl_frontend_volume_usd_7d_v2", + unit: "usd", + higherIsBetter: true, + tab: false, + values: { phantom: 700_000, metamask: 350_000 }, + }, + { + id: "revenue_30d", + label: "Revenue 30d", + metric: "hl_frontend_fees_usd_30d_v2", + unit: "usd", + higherIsBetter: true, + tab: false, + values: { phantom: 30_000, metamask: 15_000 }, + }, + { + id: "volume_30d", + label: "Volume 30d", + metric: "hl_frontend_volume_usd_30d_v2", + unit: "usd", + higherIsBetter: true, + tab: false, + values: { phantom: 3_000_000, metamask: 1_500_000 }, + }, + ], + }; +} + +function archive(): ArchiveSnapshot { + return { + updated_at: "2026-06-25T00:00:00.000Z", + builders: { + "0xphantom": { + slug: "phantom", + name: "Phantom", + windows: { + "1y": { volume_usd: 9_000_000, fees_usd: 90_000, fills: 12_000 }, + all: { volume_usd: 18_000_000, fees_usd: 180_000, fills: 25_000 }, + }, + }, + "0xmetamask": { + slug: "metamask", + name: "MetaMask", + windows: { + "1y": { volume_usd: 4_500_000, fees_usd: 45_000, fills: 6_000 }, + all: { volume_usd: 8_000_000, fees_usd: 80_000, fills: 11_000 }, + }, + }, + "0xempty": { + slug: "empty", + name: "Empty", + windows: { + "1y": { volume_usd: 0, fees_usd: 0, fills: 0 }, + }, + }, + }, + }; +} + +// Carriers the per-test stubs flip to drive the route's two branches. The +// module mocks below read from these on every call, so each `test` block +// fully controls what getBenchmark / getArchive return for its run. +let benchOverride: Benchmark | null | undefined = undefined; +let archiveOverride: ArchiveSnapshot | null | undefined = undefined; + +beforeEach(() => { + benchOverride = stubBench(); + archiveOverride = archive(); + mock.module("@/data/benchmarks", () => ({ + getBenchmark: async () => benchOverride ?? null, + })); + mock.module("@/lib/hl-archive-store", () => ({ + getArchive: async () => archiveOverride ?? null, + hlArchiveConfigured: () => true, + HL_ARCHIVE_KEY: "ocb:hl-archive:v1", + })); + // Stub rate-limit so each test starts with a fresh budget. + mock.module("@/lib/rate-limit", () => ({ + rateLimit: () => ({ ok: true, retryAfterSec: 0 }), + clientKey: () => "test", + tooManyRequests: () => new Response("rate", { status: 429 }), + })); +}); + +afterEach(() => { + mock.restore(); +}); + +async function call(window: string): Promise<{ status: number; body: unknown }> { + const mod = await import("./route"); + const req = new Request( + `https://example.test/api/bench/hyperliquid-frontends/history?window=${window}`, + ); + // The route imports NextRequest at the type-level only; passing a + // plain Request through `as unknown as Parameters<typeof mod.GET>[0]` + // exercises the same code path Next.js takes at runtime. + const res = await mod.GET(req as unknown as Parameters<typeof mod.GET>[0]); + const body = await res.json(); + return { status: res.status, body }; +} + +describe("GET /api/bench/hyperliquid-frontends/history", () => { + test("rejects unknown window with 400", async () => { + const r = await call("42h"); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ error: "bad_window" }); + }); + + test("24h reads headline slots and ranks by volume", async () => { + const r = await call("24h"); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + window: "24h", + source: "prom", + rows: [ + { slug: "phantom", volume_usd: 100_000, fees_usd: 1_000, rank: 1 }, + { slug: "metamask", volume_usd: 50_000, fees_usd: 500, rank: 2 }, + ], + }); + }); + + test("7d reads the revenue_7d and volume_7d panels", async () => { + const r = await call("7d"); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + window: "7d", + source: "prom", + rows: [ + { slug: "phantom", volume_usd: 700_000, fees_usd: 7_000 }, + { slug: "metamask", volume_usd: 350_000, fees_usd: 3_500 }, + ], + }); + }); + + test("30d reads the revenue_30d and volume_30d panels", async () => { + const r = await call("30d"); + expect(r.status).toBe(200); + expect((r.body as { source: string }).source).toBe("prom"); + expect((r.body as { rows: { volume_usd: number }[] }).rows[0].volume_usd).toBe( + 3_000_000, + ); + }); + + test("1y reads the archive and ranks by volume", async () => { + const r = await call("1y"); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + window: "1y", + source: "archive", + rows: [ + { + slug: "0xphantom", + name: "Phantom", + volume_usd: 9_000_000, + fees_usd: 90_000, + fills: 12_000, + rank: 1, + }, + { + slug: "0xmetamask", + volume_usd: 4_500_000, + rank: 2, + }, + ], + }); + }); + + test("all returns 503 with archive_pending when archive missing", async () => { + archiveOverride = null; + const r = await call("all"); + expect(r.status).toBe(503); + expect(r.body).toMatchObject({ error: "archive_pending", window: "all" }); + }); + + test("short windows fall back to 503 when the bench is gone", async () => { + benchOverride = null; + const r = await call("24h"); + expect(r.status).toBe(503); + expect(r.body).toMatchObject({ error: "bench_unavailable" }); + }); +}); diff --git a/src/app/api/bench/hyperliquid-frontends/history/route.ts b/src/app/api/bench/hyperliquid-frontends/history/route.ts new file mode 100644 index 000000000..f03ef2f9a --- /dev/null +++ b/src/app/api/bench/hyperliquid-frontends/history/route.ts @@ -0,0 +1,178 @@ +/** + * Window-scoped leaderboard for the Hyperliquid frontends bench. + * + * GET /api/bench/hyperliquid-frontends/history?window=24h|7d|30d|90d|180d|1y|all + * + * Routing rule: + * - windows ≤ 30d → live Prom snapshot via getBenchmark() + metric_panels + * - windows > 30d → archive blob written by the Go hl-archive service + * + * The ≤30d branch deliberately reuses the same panel ids the bench page + * reads from (`volume_7d` / `volume_30d` for volume, headline slots for + * 24h fees+volume) so the API and the on-page ledger can never disagree + * about the leaderboard for a shared window. + */ + +import { type NextRequest, NextResponse } from "next/server"; +import { getBenchmark } from "@/data/benchmarks"; +import { getArchive } from "@/lib/hl-archive-store"; +import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; +import type { + HlArchiveDailyPoint, + HlArchiveHistoryResponse, + HlArchiveLongWindow, + HlArchiveRankedRow, + HlArchiveWindow, +} from "@/types/hl-archive"; +import { + HL_ARCHIVE_LONG_WINDOWS, + HL_ARCHIVE_WINDOWS, +} from "@/types/hl-archive"; + +export const runtime = "nodejs"; +export const revalidate = 60; + +const BENCH_SLUG = "hyperliquid-frontends"; + +const CACHE_HEADER = "public, s-maxage=60, stale-while-revalidate=300"; + +function isWindow(v: string | null): v is HlArchiveWindow { + return v !== null && (HL_ARCHIVE_WINDOWS as readonly string[]).includes(v); +} + +function isLongWindow(w: HlArchiveWindow): w is HlArchiveLongWindow { + return (HL_ARCHIVE_LONG_WINDOWS as readonly string[]).includes(w); +} + +function rank( + rows: Omit<HlArchiveRankedRow, "rank">[], +): HlArchiveRankedRow[] { + return [...rows] + .sort((a, b) => b.volume_usd - a.volume_usd) + .map((r, i) => ({ ...r, rank: i + 1 })); +} + +type PromPanelKey = "fees" | "volume"; + +/** Panel id to read for a given (metric, window) pair on the HL bench. + * Null means "use the headline slot" — only 24h fees+volume live there. */ +function promPanelId(metric: PromPanelKey, window: HlArchiveWindow): string | null { + if (window === "24h") return null; + if (metric === "fees") { + if (window === "7d") return "revenue_7d"; + if (window === "30d") return "revenue_30d"; + } + if (metric === "volume") { + if (window === "7d") return "volume_7d"; + if (window === "30d") return "volume_30d"; + } + return null; +} + +async function buildFromProm( + window: HlArchiveWindow, +): Promise<HlArchiveHistoryResponse | null> { + const bench = await getBenchmark(BENCH_SLUG); + if (!bench) return null; + + const feesPanelId = promPanelId("fees", window); + const volPanelId = promPanelId("volume", window); + const panels = bench.metricPanels ?? []; + const feesPanel = feesPanelId + ? (panels.find((p) => p.id === feesPanelId) ?? null) + : null; + const volPanel = volPanelId + ? (panels.find((p) => p.id === volPanelId) ?? null) + : null; + + const rows: Omit<HlArchiveRankedRow, "rank">[] = []; + for (const r of bench.results) { + if (r.availability === "unavailable") continue; + const fees = + window === "24h" ? r.ms.p50 : (feesPanel?.values[r.slug] ?? 0); + const volume = + window === "24h" ? r.ms.p90 : (volPanel?.values[r.slug] ?? 0); + if (fees === 0 && volume === 0) continue; + rows.push({ + slug: r.slug, + name: r.name, + volume_usd: volume, + fees_usd: fees, + fills: 0, + }); + } + + return { + window, + source: "prom", + updated_at: bench.lastRunAt, + rows: rank(rows), + }; +} + +export async function GET(req: NextRequest) { + const rl = rateLimit(clientKey(req, "hl-archive-history"), 120, 60, req); + if (!rl.ok) return tooManyRequests(rl.retryAfterSec); + + const url = new URL(req.url); + const windowParam = url.searchParams.get("window"); + if (!isWindow(windowParam)) { + return NextResponse.json( + { error: "bad_window", allowed: HL_ARCHIVE_WINDOWS }, + { status: 400, headers: { "cache-control": "public, s-maxage=60" } }, + ); + } + const window = windowParam; + + if (!isLongWindow(window)) { + const payload = await buildFromProm(window); + if (!payload) { + return NextResponse.json( + { error: "bench_unavailable" }, + { status: 503, headers: { "cache-control": "no-store" } }, + ); + } + return NextResponse.json(payload, { headers: { "cache-control": CACHE_HEADER } }); + } + + const archive = await getArchive(); + if (!archive) { + return NextResponse.json( + { + error: "archive_pending", + message: + "Long-window archive not yet available. Backfill is running on the hl-archive service; data will appear within a few hours.", + window, + }, + { status: 503, headers: { "cache-control": "no-store" } }, + ); + } + + const rows: Omit<HlArchiveRankedRow, "rank">[] = []; + const timeseriesBySlug: Record<string, HlArchiveDailyPoint[]> = {}; + for (const [addr, b] of Object.entries(archive.builders)) { + const w = b.windows[window]; + if (!w) continue; + if (w.volume_usd === 0 && w.fees_usd === 0 && w.fills === 0) continue; + rows.push({ + slug: addr, + name: b.name, + volume_usd: w.volume_usd, + fees_usd: w.fees_usd, + fills: w.fills, + users: w.users, + }); + if (b.timeseries_daily && b.timeseries_daily.length > 0) { + timeseriesBySlug[addr] = b.timeseries_daily; + } + } + + const payload: HlArchiveHistoryResponse = { + window, + source: "archive", + updated_at: archive.updated_at, + rows: rank(rows), + timeseries_daily: timeseriesBySlug, + }; + return NextResponse.json(payload, { headers: { "cache-control": CACHE_HEADER } }); +} diff --git a/src/app/api/builder/[slug]/daily-history/route.ts b/src/app/api/builder/[slug]/daily-history/route.ts new file mode 100644 index 000000000..b096b8173 --- /dev/null +++ b/src/app/api/builder/[slug]/daily-history/route.ts @@ -0,0 +1,107 @@ +/** + * Per-builder long-window Performance chart data source. Companion to + * `/daily-series` which serves only the last 30 days from the live + * hl-node harness in-memory ring: this route reaches into the + * hl-archive Go service (DuckDB-backed, ≈11 months of daily + * aggregates) and returns the same wire shape as `/daily-series` so + * the chart client can swap sources based on the user's range picker + * without a second parser. + * + * Browser path: GET /api/builder/<slug>/daily-history?days=365 + * → getArchive() (unstable_cache 60 s) reads /v1/aggregates?window=all + * from hl-archive over HTTP + X-API-Key + * → we look up the slug in the returned map (keyed by 0x address, so + * the payload's own `slug` field is our index) and slice the last + * N days off the tail + * → reshape { day, vol, fees, fills } → { date, day_unix, fees_usd, + * volume_usd, users } so the client renderer only knows one shape + * + * `users` field is not persisted per-day in the archive schema (the + * DuckDB table stores unique_users per (day, builder, asset) tuple but + * we don't fan out to sum here — cardinality gets hairy for a route + * whose only consumer is the chart). We emit 0 so the client tolerates + * it; the 30d live range still shows real users, and the long-window + * chart intentionally focuses on revenue/volume anyway. + */ + +import { NextResponse } from "next/server"; +import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; +import { isHlBuilderSlug } from "@/lib/hl-builder-stats"; +import { getArchiveBuilderBySlug } from "@/lib/hl-archive-store"; + +export const runtime = "nodejs"; +export const revalidate = 60; + +type Params = { slug: string }; + +const DEFAULT_DAYS = 365; +const MAX_DAYS = 400; + +type WirePoint = { + date: string; + day_unix: number; + fees_usd: number; + volume_usd: number; + users: number; +}; + +export async function GET( + req: Request, + { params }: { params: Promise<Params> }, +) { + const r = rateLimit(clientKey(req, "hl-daily-history"), 60, 60, req); + if (!r.ok) return tooManyRequests(r.retryAfterSec); + + const { slug } = await params; + if (!(await isHlBuilderSlug(slug))) { + return NextResponse.json({ error: "not_a_builder" }, { status: 404 }); + } + + const url = new URL(req.url); + const daysRaw = url.searchParams.get("days"); + let days = DEFAULT_DAYS; + if (daysRaw !== null) { + const parsed = Number.parseInt(daysRaw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return NextResponse.json({ error: "bad_days" }, { status: 400 }); + } + days = Math.min(parsed, MAX_DAYS); + } + + const builder = await getArchiveBuilderBySlug(slug); + if (!builder) { + return NextResponse.json( + { error: "archive_pending" }, + { status: 503, headers: { "cache-control": "no-store" } }, + ); + } + + const raw = builder.timeseries_daily ?? []; + const tail = raw.slice(Math.max(0, raw.length - days)); + const points: WirePoint[] = tail.map((p) => ({ + date: p.day, + day_unix: Math.floor(new Date(`${p.day}T00:00:00Z`).getTime() / 1000), + fees_usd: p.fees, + volume_usd: p.vol, + users: 0, + })); + + const asOf = + points.length > 0 + ? points[points.length - 1].day_unix + 86400 + : Math.floor(Date.now() / 1000); + + return NextResponse.json( + { + builder: slug, + as_of: asOf, + days: points.length, + points, + }, + { + headers: { + "cache-control": "public, s-maxage=60, stale-while-revalidate=600", + }, + }, + ); +} diff --git a/src/app/api/cron/snapshot-hl-cohort/route.ts b/src/app/api/cron/snapshot-hl-cohort/route.ts deleted file mode 100644 index a51835bd9..000000000 --- a/src/app/api/cron/snapshot-hl-cohort/route.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { timingSafeEqual } from "node:crypto"; -import { NextResponse, type NextRequest } from "next/server"; -import { - fetchHlCohortFresh, - fetchHlHip3CohortFresh, -} from "@/lib/hl-builder-stats"; -import { - cohortSnapshotConfigured, - writeCohortSnapshot, -} from "@/lib/cohort-snapshot"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Vercel cron: refresh the /hyperliquid hub's two cohort snapshots in - * Upstash. Single endpoint that updates both keys (hl-frontends, hl-hip3) - * so a single cron entry covers the whole hub. - * - * Runs every minute. Bypasses the snapshot-first readers (which would - * loop back to their own blob); calls the *Fresh helpers directly so the - * write reflects live Prom state. Token-gated by CRON_SECRET. When the - * Upstash creds are unset the route 200s with `{configured: false}` so - * an unprovisioned environment doesn't break the cron schedule. - * - * Per-cohort errors are isolated: a Prom miss on HIP-3 doesn't block a - * fresh frontends write, and vice versa. The response body lists the - * outcome of each key so the Vercel cron log shows partial recoveries. - */ - -function isAuthorized(req: NextRequest): boolean { - const secret = (process.env.CRON_SECRET ?? "").trim(); - const header = (req.headers.get("authorization") ?? "").trim(); - if (!secret) { - return process.env.NODE_ENV !== "production"; - } - const expected = Buffer.from(`Bearer ${secret}`); - const provided = Buffer.from(header); - if (provided.length !== expected.length) return false; - return timingSafeEqual(provided, expected); -} - -type KeyOutcome = - | { key: string; ok: true; asOf: number; rowCount: number } - | { key: string; ok: false; error: string }; - -async function refresh<T extends { asOf: number; rows: unknown[] }>( - key: string, - fetcher: () => Promise<T | null>, -): Promise<KeyOutcome> { - let result: T | null; - try { - result = await fetcher(); - } catch (err) { - return { - key, - ok: false, - error: `fetch: ${err instanceof Error ? err.message : String(err)}`, - }; - } - if (!result) { - return { - key, - ok: false, - error: "fetch returned null (prom unreachable or empty)", - }; - } - try { - await writeCohortSnapshot(key, result); - } catch (err) { - return { - key, - ok: false, - error: `write: ${err instanceof Error ? err.message : String(err)}`, - }; - } - return { key, ok: true, asOf: result.asOf, rowCount: result.rows.length }; -} - -export async function GET(req: NextRequest) { - if (!isAuthorized(req)) { - return NextResponse.json({ error: "unauthorized" }, { status: 401 }); - } - if (!cohortSnapshotConfigured()) { - return NextResponse.json( - { - ok: true, - configured: false, - message: - "cohort snapshot store not configured (KV_REST_API_URL / UPSTASH_REDIS_REST_URL absent)", - }, - { status: 200 }, - ); - } - - const startedAt = Date.now(); - const [frontends, hip3] = await Promise.all([ - refresh("hl-frontends", fetchHlCohortFresh), - refresh("hl-hip3", fetchHlHip3CohortFresh), - ]); - - const okCount = (frontends.ok ? 1 : 0) + (hip3.ok ? 1 : 0); - return NextResponse.json( - { - ok: okCount > 0, - results: [frontends, hip3], - durationMs: Date.now() - startedAt, - }, - { status: okCount === 0 ? 502 : 200 }, - ); -} diff --git a/src/app/api/cron/snapshot-perp-cohort/route.ts b/src/app/api/cron/snapshot-perp-cohort/route.ts deleted file mode 100644 index 290ace1fe..000000000 --- a/src/app/api/cron/snapshot-perp-cohort/route.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { timingSafeEqual } from "node:crypto"; -import { NextResponse, type NextRequest } from "next/server"; -import { fetchPerpCohortFresh } from "@/lib/perp-stats"; -import { - cohortSnapshotConfigured, - writeCohortSnapshot, -} from "@/lib/cohort-snapshot"; - -export const runtime = "nodejs"; -// No ISR. The cron's whole job is to refresh the cohort blob: a cached -// 200 from a previous run would silently skip the Prom call. -export const dynamic = "force-dynamic"; - -/** - * Vercel cron: refresh the /perps hub cohort snapshot in Upstash. - * - * Runs every minute (vercel.json crons block). Fetches the cohort straight - * from Prom (bypassing the snapshot-first reader in fetchPerpCohort so the - * cron never loops on its own blob) and SETs the result under - * ocb:cohort:perp-cohort:v1 with a 24 h safety-net TTL. - * - * Token-gated by CRON_SECRET (Bearer header). When Upstash creds are - * missing the route still 200s with `{configured: false}` so the cron - * schedule keeps working before the integration is provisioned. - */ - -function isAuthorized(req: NextRequest): boolean { - // Trim both sides. The vercel UI / `vercel env add` paste flow has - // historically appended a trailing newline that produced a constant - // 401 with no visible reason. - const secret = (process.env.CRON_SECRET ?? "").trim(); - const header = (req.headers.get("authorization") ?? "").trim(); - if (!secret) { - // Fail closed in prod, permissive in dev to keep local manual hits - // working without exporting a fake secret. - return process.env.NODE_ENV !== "production"; - } - const expected = Buffer.from(`Bearer ${secret}`); - const provided = Buffer.from(header); - if (provided.length !== expected.length) return false; - return timingSafeEqual(provided, expected); -} - -export async function GET(req: NextRequest) { - if (!isAuthorized(req)) { - return NextResponse.json({ error: "unauthorized" }, { status: 401 }); - } - if (!cohortSnapshotConfigured()) { - return NextResponse.json( - { - ok: true, - configured: false, - message: - "cohort snapshot store not configured (KV_REST_API_URL / UPSTASH_REDIS_REST_URL absent)", - }, - { status: 200 }, - ); - } - - const startedAt = Date.now(); - let result: Awaited<ReturnType<typeof fetchPerpCohortFresh>>; - try { - result = await fetchPerpCohortFresh(); - } catch (err) { - return NextResponse.json( - { - ok: false, - stage: "fetch", - error: err instanceof Error ? err.message : String(err), - }, - { status: 502 }, - ); - } - - if (!result) { - // Prom unreachable or completely empty. Do NOT write null over the - // existing blob: the reader's stale-tolerance window would surface - // the null, but we'd rather the reader fall through to its own live - // path and keep the previous (still-valid-for-now) snapshot until - // either the cron or a request restores live data. - return NextResponse.json( - { - ok: false, - stage: "fetch", - error: "fetchPerpCohortFresh returned null (prom unreachable or empty)", - }, - { status: 502 }, - ); - } - - try { - await writeCohortSnapshot("perp-cohort", result); - } catch (err) { - return NextResponse.json( - { - ok: false, - stage: "write", - error: err instanceof Error ? err.message : String(err), - }, - { status: 502 }, - ); - } - - return NextResponse.json({ - ok: true, - asOf: result.asOf, - venueCount: result.venues.length, - trackedVenues: result.totals.trackedVenues, - durationMs: Date.now() - startedAt, - }); -} diff --git a/src/app/api/cron/warm-search-featured/route.ts b/src/app/api/cron/warm-search-featured/route.ts deleted file mode 100644 index 2c3309c25..000000000 --- a/src/app/api/cron/warm-search-featured/route.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { timingSafeEqual } from "node:crypto"; -import { NextResponse, type NextRequest } from "next/server"; -import { - cohortSnapshotConfigured, - writeCohortSnapshot, -} from "@/lib/cohort-snapshot"; -import { buildFeaturedLeaders } from "@/lib/search-featured"; - -export const runtime = "nodejs"; -// No ISR — the cron's whole job is to refresh the blob. A cached 200 from -// a previous run would silently skip the rebuild. -export const dynamic = "force-dynamic"; - -/** - * Vercel cron: refresh the search dialog's "Live leaders" + "Trending" - * blob in Upstash. Runs every minute (vercel.json crons block). - * - * Why a dedicated blob: the search dialog used to fetch /api/citable on - * every open (the full ~30-bench citable index, ~50 KB, plus all the - * assembly cost server-side). Now the cron pre-computes the 12-card - * subset the dialog actually needs (~2 KB), the public endpoint becomes - * one KV GET, and the dialog opens with data already prefetched at page - * load. - * - * Same token gate + soft-no-op pattern as snapshot-perp-cohort. - */ - -function isAuthorized(req: NextRequest): boolean { - const secret = (process.env.CRON_SECRET ?? "").trim(); - const header = (req.headers.get("authorization") ?? "").trim(); - if (!secret) { - return process.env.NODE_ENV !== "production"; - } - const expected = Buffer.from(`Bearer ${secret}`); - const provided = Buffer.from(header); - if (provided.length !== expected.length) return false; - return timingSafeEqual(provided, expected); -} - -export async function GET(req: NextRequest) { - if (!isAuthorized(req)) { - return NextResponse.json({ error: "unauthorized" }, { status: 401 }); - } - if (!cohortSnapshotConfigured()) { - return NextResponse.json( - { - ok: true, - configured: false, - message: - "cohort snapshot store not configured (KV_REST_API_URL / UPSTASH_REDIS_REST_URL absent)", - }, - { status: 200 }, - ); - } - - const startedAt = Date.now(); - let blob; - try { - blob = await buildFeaturedLeaders(); - } catch (err) { - return NextResponse.json( - { - ok: false, - stage: "build", - error: err instanceof Error ? err.message : String(err), - }, - { status: 502 }, - ); - } - - try { - await writeCohortSnapshot("search-featured", blob); - } catch (err) { - return NextResponse.json( - { - ok: false, - stage: "write", - error: err instanceof Error ? err.message : String(err), - }, - { status: 502 }, - ); - } - - return NextResponse.json({ - ok: true, - featuredCount: blob.featured.length, - trendingCount: blob.trending.length, - durationMs: Date.now() - startedAt, - }); -} diff --git a/src/app/api/series/[slug]/route.ts b/src/app/api/series/[slug]/route.ts index 689d6346b..3000ee7b4 100644 --- a/src/app/api/series/[slug]/route.ts +++ b/src/app/api/series/[slug]/route.ts @@ -1,10 +1,54 @@ import { NextResponse } from "next/server"; +import { unstable_cache } from "next/cache"; import { getBenchmark } from "@/data/benchmarks"; +import { filterSig, loadSpecsUncached, specToBenchmark } from "@/lib/materialize/load"; +import { readMaterialized } from "@/lib/materialize/store"; import { buildProviderColors } from "@/lib/series-colors"; import { logoPath } from "@/lib/logo-manifest"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; import { SLUG_RE } from "@/lib/slug"; +// Dedicated cache for the (slug, range, chain, region) → series map. +// The full Benchmark is too big for unstable_cache's 2 MB limit (root +// cause of the egress blowout — see slimBenchmarkForCache in spec.ts), +// but the series map alone is at most ~100 KB even for 100-provider +// benches. +// +// Read order: worker-published blob first (full bench, includes series7d +// + series30d), then the live Prom fan-out as a last resort. Without the +// blob lookup every cold CDN miss paid a 30-50 Prom-query roundtrip; +// under load those would queue at the Prom concurrency cap and time out +// the Vercel function. The blob is updated by the worker on each sweep +// so reading it stays as fresh as our materialize cadence (~60 s). +const getSeriesMapCached = unstable_cache( + async ( + slug: string, + range: "7d" | "30d", + chain: string | undefined, + region: string | undefined, + ): Promise<Record<string, number[]> | null> => { + const sig = filterSig({ chain, region }); + const stored = await readMaterialized(slug, sig); + if (stored) { + const fromBlob = + range === "7d" + ? stored.bench.extras.series7d + : stored.bench.extras.series30d; + if (fromBlob && Object.keys(fromBlob).length > 0) return fromBlob; + } + // Fallback: blob missing (newly deployed bench) or empty for this + // variant. Run the live build to seed something; the worker will + // overwrite on its next sweep. + const specs = await loadSpecsUncached(); + const spec = specs.find((s) => s.slug === slug); + if (!spec || spec.status !== "live") return null; + const b = await specToBenchmark(spec, { chain, region }); + return (range === "7d" ? b.extras.series7d : b.extras.series30d) ?? null; + }, + ["series-by-range-v2"], + { revalidate: 300, tags: ["benchmarks"] }, +); + export const runtime = "nodejs"; export const revalidate = 60; @@ -65,7 +109,24 @@ export async function GET( const chain = url.searchParams.get("chain") ?? undefined; const region = url.searchParams.get("region") ?? undefined; - const b = await getBenchmark(slug, { chain, region }); + // 24h is served from the slim cached Benchmark (cheap). 7d / 30d + // come from the dedicated getSeriesMapCached above (Prom fan-out the + // first time, then 5 min of free reads from unstable_cache). Loading + // a 100-KB series map is cheap enough that we still need the row + // metadata (name, color, logo) — fetch the cached bench for that + // separately so its slim ~50 KB payload reuses the existing cache. + let seriesMap: Record<string, number[]> | undefined | null; + let bench; + if (rangeParam === "7d" || rangeParam === "30d") { + [seriesMap, bench] = await Promise.all([ + getSeriesMapCached(slug, rangeParam, chain, region), + getBenchmark(slug, { chain, region }), + ]); + } else { + bench = await getBenchmark(slug, { chain, region }); + seriesMap = bench?.extras.series24h; + } + const b = bench; if (!b || b.editorialStatus !== "live") { return NextResponse.json( { error: "unknown_slug", slug }, @@ -73,13 +134,6 @@ export async function GET( ); } - const seriesMap = - rangeParam === "24h" - ? b.extras.series24h - : rangeParam === "7d" - ? b.extras.series7d - : b.extras.series30d; - if (!seriesMap || Object.keys(seriesMap).length === 0) { return NextResponse.json( { error: "no_data_for_range", slug, range: rangeParam }, diff --git a/src/app/badges/page.tsx b/src/app/badges/page.tsx index 0318756e6..90086d2a0 100644 --- a/src/app/badges/page.tsx +++ b/src/app/badges/page.tsx @@ -25,7 +25,7 @@ import { SITE } from "@/data/site"; export const revalidate = 600; const DESCRIPTION = - "Browse every live ranking badge published by OpenChainBench. Search by provider or category, preview the SVG, then copy a Markdown, HTML, URL or JSON snippet for your README, docs or marketing page. Free, CC-BY-4.0."; + "Every OpenChainBench ranking badge. Search, preview the SVG, copy Markdown, HTML, URL or JSON for your README or docs. CC-BY-4.0."; export const metadata: Metadata = pageMetadata({ path: "/badges", diff --git a/src/app/benchmarks/[slug]/[chain]/page.tsx b/src/app/benchmarks/[slug]/[chain]/page.tsx index f58f92b67..9cba3fdc3 100644 --- a/src/app/benchmarks/[slug]/[chain]/page.tsx +++ b/src/app/benchmarks/[slug]/[chain]/page.tsx @@ -277,12 +277,12 @@ function pageTitle(data: ChainPageData): string { const b = data.benchmark; if (data.shape === "row") { return data.result.ms.p50 > 0 - ? `${data.explainer.h2}: ${fmtUnit(data.result.ms.p50, b.unit)} p50 live` - : `${data.explainer.h2}: live benchmark`; + ? `${data.explainer.h2}: ${fmtUnit(data.result.ms.p50, b.unit)}` + : data.explainer.h2; } return data.leader - ? `${data.explainer.h2}: ${data.leader.name} leads at ${fmtUnit(data.leader.ms.p50, b.unit)}` - : `${data.explainer.h2}: live benchmark`; + ? `${data.explainer.h2}: ${data.leader.name} ${fmtUnit(data.leader.ms.p50, b.unit)}` + : data.explainer.h2; } export async function generateMetadata({ diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index 0a481b453..696c7761b 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -10,6 +10,7 @@ import { BenchmarkBodySkeleton } from "@/components/benchmark-body-skeleton"; import { OraclePairMatrix } from "@/components/oracle-pair-matrix"; import { Breadcrumb } from "@/components/breadcrumb"; import { ChainHeadingsSummary } from "@/components/chain-headings-summary"; +import { CompareThisBench } from "@/components/compare-this-bench"; import { CitationBar } from "@/components/citation-bar"; import { LiveIndicator } from "@/components/live-indicator"; import { ShareSection } from "@/components/share-section"; @@ -145,7 +146,30 @@ export default async function BenchmarkPage({ const aggregate = await getBenchmark(slug); if (!aggregate) notFound(); const chainOptions = aggregate.dimensions?.chain ?? []; - const regionOptions = aggregate.dimensions?.region ?? []; + // Drop declared regions that the harness doesn't actually emit data for + // (e.g. metadata-coverage lists sgp in the spec but only ever publishes + // region="unknown"). Without this the picker offers a tab that 404s on + // every click and the chart silently falls back to the previous variant, + // so two different region clicks render identical values. + const declaredRegions = aggregate.dimensions?.region ?? []; + const regionsWithData = new Set<string>(); + for (const points of Object.values(aggregate.extras.regions ?? {})) { + for (const pt of points as Array<{ region: string }>) { + if (pt?.region) regionsWithData.add(pt.region); + } + } + const sbr = aggregate.extras.seriesByRegion24h ?? {}; + for (const slug in sbr) { + for (const r in sbr[slug]) { + regionsWithData.add(r); + } + } + const regionOptions = + regionsWithData.size === 0 + ? [] + : declaredRegions.filter( + (r) => r.value === "all" || regionsWithData.has(r.value), + ); const kindOptions = aggregate.dimensions?.kind ?? []; const chain = chainOptions[0]?.value ?? null; const region = regionOptions[0]?.value ?? null; @@ -459,6 +483,7 @@ export default async function BenchmarkPage({ initialChain={chain ?? null} initialRegion={region ?? null} initialKind={kind ?? null} + hasLongHistory={benchmark.slug === "hyperliquid-frontends"} /> </Suspense> )} @@ -481,6 +506,8 @@ export default async function BenchmarkPage({ their own server-rendered discovery links here. */} {!isDraft && <PerChainPagesNav benchmark={benchmark} />} + {!isDraft && <CompareThisBench benchmark={benchmark} />} + {/* FAQ section - every question/answer mirrors a FAQPage JSON-LD entry above. Google requires the content to be visible on the page, so we render the same text here. */} diff --git a/src/app/chains/[slug]/page.tsx b/src/app/chains/[slug]/page.tsx index 48f33ab55..55656a4d2 100644 --- a/src/app/chains/[slug]/page.tsx +++ b/src/app/chains/[slug]/page.tsx @@ -104,20 +104,22 @@ export default async function ChainPage({ const byCategory = groupByCategory(benches); const url = `${SITE.url}/chains/${slug}`; - // CollectionPage references every measurement on this chain as part - // of the page's structured data. hasPart entries point back at the - // individual /benchmarks/<slug> pages so crawlers see the cluster. + // ItemList of Datasets, one entry per benchmark that touches this chain. + // Previously CollectionPage — Google's rich result validator doesn't + // recognise CollectionPage as a supported type and flagged every chain + // hub. ItemList is a documented Google rich result target and carries + // the same cluster signal (each item links back to /benchmarks/<slug>). const collectionLd = { "@context": "https://schema.org", - "@type": "CollectionPage", - "@id": `${url}#collection`, + "@type": "ItemList", + "@id": `${url}#datasets`, name: `${chain.label} live benchmarks`, description: chain.description, url, - isPartOf: { "@id": `${SITE.url}/#site` }, - about: { "@type": "Thing", name: chain.label }, - hasPart: benches.map((b) => ({ - "@type": "Dataset", + numberOfItems: benches.length, + itemListElement: benches.map((b, i) => ({ + "@type": "ListItem", + position: i + 1, name: b.title, url: `${SITE.url}/benchmarks/${b.slug}`, })), diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index 8bd07116c..0a74ae362 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -178,7 +178,7 @@ export async function generateMetadata({ if (!hasSharedBenches(pair, a, b)) notFound(); const url = `${SITE.url}/compare/${pair.slug}`; - const title = `${a.name} vs ${b.name}: live OpenChainBench benchmark data`; + const title = `${a.name} vs ${b.name}: live benchmarks`; const description = capDescription( `${a.name} vs ${b.name} side by side on every shared OpenChainBench benchmark. Live measurements, identical layout, no verdict.`, 158, diff --git a/src/app/hyperliquid/[slug]/page.tsx b/src/app/hyperliquid/[slug]/page.tsx new file mode 100644 index 000000000..aed0d6762 --- /dev/null +++ b/src/app/hyperliquid/[slug]/page.tsx @@ -0,0 +1,299 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { + fetchHlBuilderStats, + fetchHlCohort, + fetchHlHistory, + type HlHistoryFrontendCompact, +} from "@/lib/hl-builder-stats"; +import { Breadcrumb } from "@/components/breadcrumb"; +import { HlBuilderDashboard } from "@/components/hl-builder-dashboard"; +import { pageMetadata } from "@/lib/page-metadata"; +import { safeJsonLd } from "@/lib/jsonld"; + +/** + * Per-frontend detail page for the Hyperliquid grid overview. Server + * component rendering: + * - hero: name + current fees_30d + first-day / peak-fees badges + * - rich `HlBuilderDashboard`: HyperTracker-parity KPI grid, 30d + * performance chart with biggest-day marker, coin-share donut, + * user-percentile bar, milestone cards, top-users leaderboard. + * Ported from the legacy `/products/<hl-slug>` surface after the + * 301 redirect so nothing is lost on the canonical URL. + * - peer group: 5 frontends of similar current-fees magnitude, each a + * link back to its own detail page + * + * When Prom is unreachable (`hlStats === null`), the dashboard is + * skipped and the page falls back to the minimal 4-card KPI strip so + * the surface still renders something useful. + * + * `generateStaticParams` returns every slug the history blob knows about + * so the page routes are pre-listed at build (dynamicParams stays true, + * so unknown-but-valid slugs still ISR). + * + * Unknown slugs 404 rather than rendering an empty shell. + */ + +export const revalidate = 3600; +export const dynamicParams = true; + +type Params = { slug: string }; + +export async function generateStaticParams(): Promise<Params[]> { + const history = await fetchHlHistory(); + if (!history) return []; + return history.frontends.map((f) => ({ slug: f.slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<Params>; +}): Promise<Metadata> { + const { slug } = await params; + const history = await fetchHlHistory(); + const frontend = history?.frontends.find((f) => f.slug === slug); + if (!frontend) return {}; + const currentFees = lastNonNull(frontend.fees); + const peakFees = peakOf(frontend.fees); + const description = `${frontend.name} on Hyperliquid: ${fmtUSDShort( + currentFees, + )} rolling 30-day builder fees (all-time peak ${fmtUSDShort( + peakFees, + )}). 12-month history with volume, fees and first-active date.`; + return pageMetadata({ + path: `/hyperliquid/${slug}`, + title: `${frontend.name} — Hyperliquid frontend`, + description, + }); +} + +export default async function HlFrontendPage({ + params, +}: { + params: Promise<Params>; +}) { + const { slug } = await params; + const [history, cohort, hlStats] = await Promise.all([ + fetchHlHistory(), + fetchHlCohort(), + fetchHlBuilderStats(slug), + ]); + if (!history) notFound(); + const frontend = history.frontends.find((f) => f.slug === slug); + if (!frontend) notFound(); + + const cohortRow = cohort?.rows.find((r) => r.slug === slug); + const currentFees = cohortRow?.revenue30d ?? lastNonNull(frontend.fees); + const currentVolume = cohortRow?.volume30d ?? lastNonNull(frontend.volume); + const peakFees = peakOf(frontend.fees); + const firstDayMs = history.t0 + history.step * 1000 * frontend.firstIdx; + const firstDay = formatFullDate(firstDayMs); + + const peers = pickPeers(history.frontends, frontend, 5); + + const breadcrumbLd = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "Home", + item: "https://openchainbench.com/", + }, + { + "@type": "ListItem", + position: 2, + name: "Hyperliquid", + item: "https://openchainbench.com/hyperliquid", + }, + { + "@type": "ListItem", + position: 3, + name: frontend.name, + item: `https://openchainbench.com/hyperliquid/${slug}`, + }, + ], + }; + + return ( + <article className="mx-auto max-w-[1200px] px-4 sm:px-6 py-12 sm:py-16"> + <script + type="application/ld+json" + // biome-ignore lint/security/noDangerouslySetInnerHtml: serialized via safeJsonLd + dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbLd) }} + /> + + <Breadcrumb + items={[ + { label: "Home", href: "/" }, + { label: "Hyperliquid", href: "/hyperliquid" }, + { label: frontend.name }, + ]} + /> + + <header className="mb-8"> + <p className="label-mono text-ink-faint mb-2">Hyperliquid frontend</p> + <h1 className="display text-4xl sm:text-5xl text-ink"> + {frontend.name} + </h1> + <p + className="mt-2 text-[12px] text-ink-faint" + style={{ fontFamily: "var(--font-mono, monospace)" }} + > + {slug} + </p> + <div className="mt-4 flex items-baseline gap-3"> + <span className="text-4xl font-semibold tabular-nums text-ink"> + {fmtUSDShort(currentFees)} + </span> + <span className="text-sm text-ink-soft">rolling 30d builder fees</span> + </div> + <div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-ink-faint"> + <span> + First day active:{" "} + <span className="text-ink-soft">{firstDay}</span> + </span> + <span> + Peak fees (30d):{" "} + <span className="text-ink-soft tabular-nums"> + {fmtUSDShort(peakFees)} + </span> + </span> + </div> + </header> + + {hlStats ? ( + <HlBuilderDashboard stats={hlStats} name={frontend.name} /> + ) : ( + <section className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-10"> + <Kpi label="Fees 30d" value={fmtUSDShort(currentFees)} /> + <Kpi label="Volume 30d" value={fmtUSDShort(currentVolume)} /> + <Kpi label="First day active" value={firstDay} /> + <Kpi label="Peak fees (all-time 30d)" value={fmtUSDShort(peakFees)} /> + </section> + )} + + {peers.length > 0 && ( + <section> + <h2 className="text-2xl font-semibold mb-3">Peer group</h2> + <p className="text-sm text-ink-soft mb-4 max-w-2xl"> + Frontends in the same order-of-magnitude bracket for current + 30-day fees. + </p> + <ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3"> + {peers.map((p) => { + const peerFees = lastNonNull(p.fees); + return ( + <li key={p.slug}> + <Link + href={`/hyperliquid/${p.slug}`} + className="flex flex-col rounded-lg border border-ink/10 bg-paper p-3 hover:border-ink/30 hover:bg-paper-soft/40" + > + <span className="truncate text-sm font-semibold text-ink"> + {p.name} + </span> + <span className="mt-1 text-base font-semibold tabular-nums text-ink"> + {fmtUSDShort(peerFees)} + </span> + <span + className="mt-0.5 text-[10px] text-ink-faint" + style={{ fontFamily: "var(--font-mono, monospace)" }} + > + Fees 30d + </span> + </Link> + </li> + ); + })} + </ul> + </section> + )} + + <p className="mt-10 text-[11px] text-ink-faint italic"> + Source: local hl node tailing every Hyperliquid mainnet fill. This + page reads the same rolling-30d gauges as the /hyperliquid hub; + data refreshes hourly on the ISR cache. + </p> + </article> + ); +} + +function Kpi({ label, value }: { label: string; value: string }) { + return ( + <div className="rounded-lg border border-ink/10 bg-paper p-4"> + <p + className="label-mono text-[10px] text-ink-faint" + style={{ fontFamily: "var(--font-mono, monospace)" }} + > + {label} + </p> + <p className="mt-1.5 text-lg font-semibold tabular-nums text-ink"> + {value} + </p> + </div> + ); +} + +function lastNonNull(arr: (number | null)[]): number { + for (let i = arr.length - 1; i >= 0; i--) { + const v = arr[i]; + if (v !== null && Number.isFinite(v)) return v; + } + return 0; +} + +function peakOf(arr: (number | null)[]): number { + let m = 0; + for (const v of arr) { + if (v !== null && v > m) m = v; + } + return m; +} + +/** Pick up to `count` peers that sit in the same order-of-magnitude + * bracket as `me`. Excludes `me` itself and sorts by absolute proximity + * in log space, then by name for stability. */ +function pickPeers( + all: HlHistoryFrontendCompact[], + me: HlHistoryFrontendCompact, + count: number, +): HlHistoryFrontendCompact[] { + const myFees = lastNonNull(me.fees); + if (myFees <= 0) return []; + const myLog = Math.log10(myFees); + const scored = all + .filter((f) => f.slug !== me.slug) + .map((f) => { + const v = lastNonNull(f.fees); + if (v <= 0) return null; + return { f, dist: Math.abs(Math.log10(v) - myLog) }; + }) + .filter((x): x is { f: HlHistoryFrontendCompact; dist: number } => x !== null); + scored.sort((a, b) => { + if (a.dist !== b.dist) return a.dist - b.dist; + return a.f.name.localeCompare(b.f.name); + }); + return scored.slice(0, count).map((x) => x.f); +} + +function fmtUSDShort(v: number): string { + if (!Number.isFinite(v) || v === 0) return "$0"; + const abs = Math.abs(v); + if (abs >= 1_000_000_000) return `$${(v / 1_000_000_000).toFixed(2)}B`; + if (abs >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`; + if (abs >= 1_000) return `$${(v / 1_000).toFixed(1)}K`; + return `$${v.toFixed(0)}`; +} + +const MONTHS_FULL = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +function formatFullDate(ms: number): string { + const d = new Date(ms); + return `${MONTHS_FULL[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`; +} diff --git a/src/app/hyperliquid/page.tsx b/src/app/hyperliquid/page.tsx index 215c60f3d..ee4bd1a8f 100644 --- a/src/app/hyperliquid/page.tsx +++ b/src/app/hyperliquid/page.tsx @@ -1,5 +1,9 @@ import Link from "next/link"; -import { fetchHlCohort, fetchHlHip3Cohort } from "@/lib/hl-builder-stats"; +import { + fetchHlCohort, + fetchHlHip3Cohort, + fetchHlHistory, +} from "@/lib/hl-builder-stats"; import { HlHubTabs } from "@/components/hl-hub-tabs"; import { pageMetadata } from "@/lib/page-metadata"; import { safeJsonLd } from "@/lib/jsonld"; @@ -18,9 +22,11 @@ import { safeJsonLd } from "@/lib/jsonld"; * ...) collecting a deployer fee on every fill on their namespaced * markets * - * Per-frontend dashboards live at `/products/<slug>` (Hyperliquid - * builders only). HIP-3 dexes have no per-dex page yet; the leaderboard - * is the canonical surface. + * Per-frontend detail pages live at `/hyperliquid/<slug>` (12-month + * history + focus chart + KPIs). HIP-3 dexes have no per-dex page yet; + * the leaderboard is the canonical surface. `/products/<slug>` for a + * tracked HL builder 308-redirects into the /hyperliquid subtree so the + * two hubs stop competing for the same rank signal. */ export const metadata: import("next").Metadata = pageMetadata({ @@ -33,9 +39,10 @@ export const metadata: import("next").Metadata = pageMetadata({ export const revalidate = 60; export default async function HyperliquidHubPage() { - const [frontends, hip3] = await Promise.all([ + const [frontends, hip3, history] = await Promise.all([ fetchHlCohort(), fetchHlHip3Cohort(), + fetchHlHistory(), ]); const breadcrumbLd = { @@ -81,7 +88,7 @@ export default async function HyperliquidHubPage() { itemListElement: linkableFrontends.slice(0, 100).map((r, i) => ({ "@type": "ListItem", position: i + 1, - url: `https://openchainbench.com/products/${r.slug}`, + url: `https://openchainbench.com/hyperliquid/${r.slug}`, name: r.name, })), } @@ -150,7 +157,11 @@ export default async function HyperliquidHubPage() { {frontends || hip3 ? ( <> - <HlHubTabs frontends={frontends} hip3={hip3} /> + <HlHubTabs + frontends={frontends} + hip3={hip3} + history={history} + /> <p className="mt-4 text-[11px] text-ink-faint italic"> Source: a local hl node tailing every fill on Hyperliquid @@ -158,7 +169,8 @@ export default async function HyperliquidHubPage() { builder address; HIP-3 cohort attributes fills via the dex namespace prefix on the coin field (xyz:AAPL → xyz). Both cohorts publish to the same Prom; the bench pages document - the per-row formulas. + the per-row formulas. The `12m trend` column mirrors the + same rolling-30d fees gauge, one point per UTC day. </p> </> ) : ( diff --git a/src/app/mcp/page.tsx b/src/app/mcp/page.tsx index 7735ab216..b12a4566d 100644 --- a/src/app/mcp/page.tsx +++ b/src/app/mcp/page.tsx @@ -41,7 +41,7 @@ export const metadata: Metadata = pageMetadata({ path: "/mcp", title: "MCP server", description: - "Connect Claude Desktop, Cursor, ChatGPT or any MCP-capable agent to OpenChainBench. Live crypto-infra benchmarks become a first-class tool for your AI assistant.", + "Connect Claude Desktop, Cursor, ChatGPT or any MCP-capable agent to OpenChainBench live crypto-infra benchmarks.", }); export const revalidate = 300; diff --git a/src/app/partners/page.tsx b/src/app/partners/page.tsx index 29e3d8f2e..4bb69440b 100644 --- a/src/app/partners/page.tsx +++ b/src/app/partners/page.tsx @@ -19,7 +19,7 @@ import { safeJsonLd, buildBreadcrumbJsonLd } from "@/lib/jsonld"; */ const DESCRIPTION = - "Live badges, share cards, citation snippets and public data APIs you can drop into your README, docs, or marketing site to surface live OpenChainBench measurements. CC-BY-4.0 licensed."; + "Live badges, share cards and data APIs to embed OpenChainBench measurements on your README, docs or site. CC-BY-4.0."; export const metadata: Metadata = pageMetadata({ path: "/partners", diff --git a/src/app/perps/page.tsx b/src/app/perps/page.tsx index 6f6cd00ea..bad483004 100644 --- a/src/app/perps/page.tsx +++ b/src/app/perps/page.tsx @@ -28,8 +28,7 @@ const DESCRIPTION = export const metadata: import("next").Metadata = pageMetadata({ path: "/perps", - title: - "Perpetual DEX leaderboard: Hyperliquid, Lighter, GMX, dYdX, live cross-venue ranking", + title: "Perpetual DEX leaderboard 2026", description: DESCRIPTION, }); diff --git a/src/app/prediction-markets/page.tsx b/src/app/prediction-markets/page.tsx index 0cae76269..1a65873d4 100644 --- a/src/app/prediction-markets/page.tsx +++ b/src/app/prediction-markets/page.tsx @@ -23,7 +23,7 @@ import { SITE } from "@/data/site"; */ const DESCRIPTION = - "Live cross venue measurement of prediction markets: volume, open interest, resolution delay, API latency and data freshness. One leaderboard, one methodology, across Polymarket, Kalshi, Limitless, Manifold and Myriad."; + "Cross-venue prediction market leaderboard: volume, OI, resolution delay, API latency, freshness. Polymarket, Kalshi, Limitless, Manifold, Myriad."; export const metadata: import("next").Metadata = pageMetadata({ path: "/prediction-markets", diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 6d0a55f12..3ad3525db 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import Link from "next/link"; import { ArrowLeft, ArrowUpRight } from "lucide-react"; import { getProvider } from "@/lib/providers"; @@ -58,16 +58,26 @@ export async function generateMetadata({ params: Promise<Params>; }): Promise<Metadata> { const { slug } = await params; + // Tracked Hyperliquid frontends live under /hyperliquid/<slug> now. The + // page component 308-redirects, but generateMetadata runs first for the + // <head> injection — return the canonical + redirect-safe metadata so + // crawlers that peek at the response before the 308 fires still see the + // right canonical target. + if (await isHlBuilderSlug(slug)) { + const canonicalUrl = `${SITE.url}/hyperliquid/${slug}`; + return { + alternates: { canonical: canonicalUrl }, + }; + } const p = await getProvider(slug); if (!p) return {}; const reg = getProviderRegistry(p.slug); // Meta title carries the head-term shape people search for when - // evaluating a provider ("helius review", "is dRPC reliable", - // "mobula performance"). The product page is the canonical answer - // surface for those queries so we name it in the title directly - // rather than leaving the previous generic "benchmark record" phrasing. - const title = `${p.name} review and live performance benchmarks`; + // evaluating a provider ("helius review", "is dRPC reliable"). Kept + // short so Google's ~60-char SERP truncation never cuts the brand + // suffix that Next's title template appends (" · OpenChainBench"). + const title = `${p.name}: live benchmarks`; // Description prefers the registry's curated one-liner, then falls back // to a numeric one summarizing competitive footprint. Either way the @@ -78,12 +88,28 @@ export async function generateMetadata({ const winWord = p.wins === 1 ? "first-place finish" : "first-place finishes"; const winSuffix = p.wins > 0 ? `, ${p.wins} ${winWord}` : ""; const fallbackDescription = `${p.name} reviewed across ${benchCount} live OpenChainBench ${benchWord}${winSuffix}. Reproducible measurements, open methodology, refreshed every minute.`; + // Registry descriptions use markdown-flavour backticks for host names + // and code snippets (rendered as <code> in the product page body). + // Those leak into meta description and social previews as raw backticks + // — Google treats them as garbage characters. Strip inline code, bold, + // and italic markers before injecting into meta. + const stripInlineMarkdown = (s: string) => + s + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/_([^_]+)_/g, "$1"); // Some registry descriptions end with a period, others do not. Normalize // before appending so the concatenated meta description never reads // "...provider Live performance..." as a run-on sentence. - const description = reg?.description - ? `${reg.description.replace(/[.!?]?$/, ".")} Live performance across ${benchCount} OpenChainBench ${benchWord}${winSuffix}.` + const rawDescription = reg?.description + ? `${stripInlineMarkdown(reg.description).replace(/[.!?]?$/, ".")} Live performance across ${benchCount} OpenChainBench ${benchWord}${winSuffix}.` : fallbackDescription; + // Google truncates meta description at ~155 chars in the SERP snippet. + // Long registry descriptions plus the appended "Live performance ..." + // sentence routinely blew past 200 chars (Ahref flagged 130+ pages). + // Cap at 155 with word-boundary truncation. + const description = capDescription(rawDescription, 155); // When the resolved provider slug is actually a chain (e.g. /products/eth-usd // aliases to /products/ethereum which 308s to /chains/ethereum), point @@ -109,6 +135,13 @@ export default async function ProviderPage({ params: Promise<Params>; }) { const { slug } = await params; + // /hyperliquid/<slug> is the canonical detail surface for tracked HL + // frontends (12-month focus chart + peer group + KPI strip). Redirect + // /products/<hl-slug> straight there so backlinks + old SERP entries + // land on the richer hub without splitting rank signal across two URLs. + if (await isHlBuilderSlug(slug)) { + redirect(`/hyperliquid/${slug}`); + } const p = await getProvider(slug); if (!p) notFound(); const reg = getProviderRegistry(p.slug); @@ -312,20 +345,13 @@ export default async function ProviderPage({ license: "https://creativecommons.org/licenses/by/4.0/", })), }, - { - "@type": "SoftwareApplication", - name: p.name, - identifier: p.slug, - url, - applicationCategory: "DeveloperApplication", - operatingSystem: "Cross-platform", - description: - reg?.description ?? - `${p.name} is a crypto-infrastructure product measured by OpenChainBench across ${p.appearances.length} live benchmarks.`, - ...(reg?.url ? { downloadUrl: reg.url } : {}), - ...(sameAs.length > 0 ? { sameAs } : {}), - creator: { "@id": `${SITE.url}/#org` }, - }, + // NB: previously emitted a SoftwareApplication node here, but Google's + // rich result validator rejects it without `offers` and either + // `aggregateRating` or `review` (Ahref flagged 220+ product pages). + // We don't sell or rate the products we track — the honest schema is + // the Organization above plus the Dataset references it links to via + // `subjectOf`. Removing SoftwareApplication drops the failed rich + // result attempt without losing any real signal. buildBreadcrumbJsonLd([ { name: "Home", item: SITE.url }, { name: "Products", item: `${SITE.url}/products` }, diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index a6bc3535e..8a97f2ef6 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -7,7 +7,8 @@ import { loadAllAlternatives } from "@/lib/alternatives"; import { loadAllAnswers } from "@/lib/answers"; import { CHAIN_BY_SLUG, CHAINS, getBenchmarksForChain } from "@/lib/chains"; import { canonicalChainSlug } from "@/lib/chain-aliases"; -import { getProvider, getProviderSlugs } from "@/lib/providers"; +import { getProvider, getProviders, getProviderSlugs } from "@/lib/providers"; +import { CATEGORIES } from "@/lib/categories"; import { SITE } from "@/data/site"; import type { Benchmark } from "@/types/benchmark"; import type { Answer } from "@/lib/answers"; @@ -287,27 +288,81 @@ async function buildFullSitemap(): Promise<MetadataRoute.Sitemap> { ) ).filter((r): r is NonNullable<typeof r> => r !== null); - // Curated compare pairs. Both providers are pre-validated to exist - // and share at least one bench (criteria documented in - // src/data/compare-pairs.ts). Defensive recheck via getProvider to - // skip any pair whose provider was removed since publication. - const compareRoutes: MetadataRoute.Sitemap = ( - await Promise.all( - COMPARE_PAIRS.map(async (pair) => { - const [a, b] = await Promise.all([ - getProvider(pair.providerA), - getProvider(pair.providerB), - ]); - if (!a || !b) return null; - return { - url: `${SITE.url}/compare/${pair.slug}`, - lastModified: catalogTs, - changeFrequency: "weekly" as const, - priority: 0.7, - }; - }), - ) - ).filter((r): r is NonNullable<typeof r> => r !== null); + // Compare pair sitemap. Combines curated pairs (editorial anchors) + // with ad-hoc pairs that clear a live-data gate: both providers share + // at least 2 benchmarks with p50 > 0. That gate keeps thin content + // (providers barely overlapping) out of the sitemap while surfacing + // genuinely comparable pairs Google was already crawling via internal + // "vs" cross-sell links but couldn't rank because no sitemap signal. + // + // HL builder hex slugs are excluded (they leak into the provider + // catalog but /compare/0x…-vs-… 404s at render). Chain slugs are + // excluded too — /compare pairs involving a chain still work but the + // chain hub is the canonical surface, and cross-listing dilutes. + const HEX_SLUG_RE = /^0x[0-9a-f]{4,}$/i; + const compareRoutes: MetadataRoute.Sitemap = []; + const emittedPairSlugs = new Set<string>(); + const priorityByPairSlug = new Map<string, number>(); + + for (const pair of COMPARE_PAIRS) { + const p = await getProvider(pair.providerA); + const q = await getProvider(pair.providerB); + if (!p || !q) continue; + emittedPairSlugs.add(pair.slug); + priorityByPairSlug.set(pair.slug, 0.7); + } + + // Gate: a provider is "sitemap-eligible" as soon as it has ≥1 bench + // appearance. The initial pass required 2 live benches with p50>0 — + // way too strict because most cohort providers (HL builders, wallets, + // trading terminals) participate in a single bench (`hyperliquid-frontends` + // or a product-catalog listing) with rank data but no p50 latency. + // That filter dropped ~1500 valid pages Ahref had already indexed via + // internal cross-links, leaving them in "Indexed but not in sitemap" + // limbo. Relaxed: emit any pair with ≥1 shared bench appearance. + const profiles = await safeLoad("providers", () => getProviders(), []); + const benchesBySlug = new Map<string, Set<string>>(); + for (const p of profiles) { + if (HEX_SLUG_RE.test(p.slug)) continue; + if (CHAIN_BY_SLUG.has(p.slug)) continue; + const benches = new Set(p.appearances.map((a) => a.benchmark.slug)); + if (benches.size >= 1) benchesBySlug.set(p.slug, benches); + } + + const slugList = [...benchesBySlug.keys()].sort(); + for (let i = 0; i < slugList.length; i += 1) { + const aSlug = slugList[i]; + const aBenches = benchesBySlug.get(aSlug)!; + for (let j = i + 1; j < slugList.length; j += 1) { + const bSlug = slugList[j]; + const bBenches = benchesBySlug.get(bSlug)!; + let shared = 0; + for (const s of aBenches) if (bBenches.has(s)) shared += 1; + if (shared < 1) continue; + const pairSlug = `${aSlug}-vs-${bSlug}`; + if (emittedPairSlugs.has(pairSlug)) continue; + emittedPairSlugs.add(pairSlug); + priorityByPairSlug.set(pairSlug, 0.5); + } + } + + for (const pairSlug of emittedPairSlugs) { + compareRoutes.push({ + url: `${SITE.url}/compare/${pairSlug}`, + lastModified: catalogTs, + changeFrequency: "weekly" as const, + priority: priorityByPairSlug.get(pairSlug) ?? 0.5, + }); + } + + // Category hub pages. Closed enum from CATEGORIES; prerendered + // routes that group benches by domain (Blockchains, Bridges, …). + const categoryRoutes: MetadataRoute.Sitemap = CATEGORIES.map((c) => ({ + url: `${SITE.url}/benchmarks/category/${c.slug}`, + lastModified: catalogTs, + changeFrequency: "weekly" as const, + priority: 0.6, + })); return [ ...staticRoutes, @@ -316,6 +371,7 @@ async function buildFullSitemap(): Promise<MetadataRoute.Sitemap> { ...alternativeRoutes, ...answerRoutes, ...chainRoutes, + ...categoryRoutes, ...compareRoutes, ]; } diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index d856a185f..4ce053965 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -7,7 +7,14 @@ import { liveResults } from "@/lib/provider-filters"; import { matchesChainSlug } from "@/lib/chain-aliases"; import { ChainTabs } from "@/components/chain-tabs"; import { LedgerTable } from "@/components/ledger-table"; +import { HlArchiveLeaderboard } from "@/components/hl-archive-leaderboard"; import { TimeSeriesChart } from "@/components/time-series-chart"; +import type { Range as ChartRange } from "@/components/time-series-chart/scales"; +import { LONG_RANGES } from "@/components/time-series-chart/scales"; +import type { + HlArchiveHistoryResponse, + HlArchiveLongWindow, +} from "@/types/hl-archive"; import { RankedBarChart } from "@/components/ranked-bar-chart"; import { DistributionChart } from "@/components/distribution-chart"; import { DonutChart } from "@/components/donut-chart"; @@ -136,6 +143,7 @@ export function BenchmarkBody({ initialChain, initialRegion, initialKind = null, + hasLongHistory = false, }: { variants: Record<string, Benchmark>; chainOptions: ChainOption[]; @@ -144,6 +152,10 @@ export function BenchmarkBody({ initialChain: string | null; initialRegion: string | null; initialKind?: string | null; + /** When true, render the long-window archive toggle (24h..All time) + * below the main ledger. Only set on benches whose harness ships a + * long-window archive blob (currently: hyperliquid-frontends). */ + hasLongHistory?: boolean; }) { // Read ?chain= / ?region= / ?kind= client-side. The server can't read these any // more (doing so would force /benchmarks/<slug> to render dynamic on @@ -378,6 +390,111 @@ export function BenchmarkBody({ [chartRegions], ); + // Unified chart range. Default to the chart's own default ("24h") so + // short ranges keep the existing visual exactly. When the bench has + // long-window archive history, this state is also passed to the + // leaderboard below so chart pills + ledger source stay in sync. + const [chartRange, setChartRange] = useState<ChartRange>("24h"); + + // Per-window cache of the long-window archive payload. Populated lazily + // when the user clicks a 90d/180d/1y/all pill, and used both to feed + // the chart's `longRangeSeries` map and to replace the ledger rows + // with archive-sourced ranks. `error` entries stand in for "tried, + // failed" so we don't refetch on every render — the disabled-pill UX + // is driven off the first failure too. + const [hlArchiveCache, setHlArchiveCache] = useState< + Record<string, HlArchiveHistoryResponse | { error: string }> + >({}); + const [hlArchiveDisabled, setHlArchiveDisabled] = useState(false); + + // The 4 long-range chart pills are a strict subset of the archive's + // long-window enum, so we coerce once here and pass the narrower type + // down to the archive fetch + leaderboard. Keeps the rest of the file + // free of `as` casts at every consumer. + const longRangeKey: HlArchiveLongWindow | null = ( + LONG_RANGES as readonly ChartRange[] + ).includes(chartRange) + ? (chartRange as HlArchiveLongWindow) + : null; + + useEffect(() => { + if (!hasLongHistory) return; + if (!longRangeKey) return; + if (hlArchiveCache[longRangeKey]) return; + let cancelled = false; + fetch( + `/api/bench/hyperliquid-frontends/history?window=${encodeURIComponent(longRangeKey)}`, + { cache: "no-store" }, + ) + .then(async (res) => { + const body = (await res.json().catch(() => null)) as + | HlArchiveHistoryResponse + | { error: string } + | null; + if (cancelled) return; + if (!res.ok || !body) { + const err = + body && "error" in body ? body.error : `http_${res.status}`; + setHlArchiveCache((c) => ({ ...c, [longRangeKey]: { error: err } })); + setHlArchiveDisabled(true); + // Auto-revert the chart range so the user sees data instead of + // a dead frame. 30d is the longest live window. + setChartRange("30d"); + return; + } + setHlArchiveCache((c) => ({ ...c, [longRangeKey]: body })); + }) + .catch(() => { + if (cancelled) return; + setHlArchiveCache((c) => ({ + ...c, + [longRangeKey]: { error: "network" }, + })); + setHlArchiveDisabled(true); + setChartRange("30d"); + }); + return () => { + cancelled = true; + }; + }, [hasLongHistory, longRangeKey, hlArchiveCache]); + + // Derive the chart's `longRangeSeries` prop from the archive cache. + // Each entry maps `slug -> daily-fees array` because the HL bench + // headline metric is builder fees collected (USD). The leaderboard + // below independently shows volume + fees + fills, so the wire shape + // carries all three — only `fees` is fed to the chart Y-axis. + const hlLongRangeSeries = useMemo(() => { + if (!hasLongHistory) return undefined; + const map: Partial<Record<ChartRange, Record<string, number[]>>> = {}; + for (const w of LONG_RANGES) { + const cached = hlArchiveCache[w]; + if (!cached || "error" in cached) continue; + const ts = cached.timeseries_daily ?? {}; + const perBuilder: Record<string, number[]> = {}; + for (const [slug, days] of Object.entries(ts)) { + // Builder fees collected. If the metric mapping later grows to + // include companion charts (volume / fills), this branch picks + // the matching field per metric label. + perBuilder[slug] = days.map((d) => d.fees); + } + map[w] = perBuilder; + } + return map; + }, [hasLongHistory, hlArchiveCache]); + + const hlActiveArchive: HlArchiveHistoryResponse | null = useMemo(() => { + if (!hasLongHistory || !longRangeKey) return null; + const cached = hlArchiveCache[longRangeKey]; + if (!cached || "error" in cached) return null; + return cached; + }, [hasLongHistory, longRangeKey, hlArchiveCache]); + + const hlArchiveLoading = !!( + hasLongHistory && + longRangeKey && + !hlArchiveCache[longRangeKey] + ); + if (!benchmark || !viewBenchmark) return null; const pendingCls = variantPending @@ -599,6 +716,15 @@ export function BenchmarkBody({ metricLabelOverride={activePanel?.label} unitOverride={activePanel?.unit} higherIsBetterOverride={activePanel?.higherIsBetter} + range={chartRange} + onRangeChange={setChartRange} + longRangeSeries={hasLongHistory ? hlLongRangeSeries : undefined} + longRangeDisabled={hasLongHistory ? hlArchiveDisabled : undefined} + longRangeDisabledTitle={ + hasLongHistory + ? "Archive temporarily unavailable" + : undefined + } /> {activePanel?.description && ( <p className="mt-3 text-[12px] text-ink-muted max-w-2xl"> @@ -611,16 +737,35 @@ export function BenchmarkBody({ </div> <div className={"mt-8 card-soft rounded-xl p-4 sm:p-6 lg:p-8" + pendingCls}> - <p className="label-mono text-ink-faint mb-4"> - {viewBenchmark.unit === "count" - ? "Product ledger" - : activePanel - ? `Product ledger · sorted by ${activePanel.label}` - : viewBenchmark.ledgerColumns?.length - ? `Product ledger · sorted by ${viewBenchmark.ledgerColumns[0].label}` - : "Product ledger · sorted by p50"} - </p> - <LedgerTable benchmark={viewBenchmark} activePanel={activePanel} topN={topN} /> + {hasLongHistory && longRangeKey ? ( + <> + <p className="label-mono text-ink-faint mb-4"> + Product ledger · {longRangeKey} archive + </p> + <HlArchiveLeaderboard + window={longRangeKey} + payload={hlActiveArchive} + loading={hlArchiveLoading} + knownProviders={viewBenchmark.results.map((r) => ({ + slug: r.slug, + name: r.name, + }))} + /> + </> + ) : ( + <> + <p className="label-mono text-ink-faint mb-4"> + {viewBenchmark.unit === "count" + ? "Product ledger" + : activePanel + ? `Product ledger · sorted by ${activePanel.label}` + : viewBenchmark.ledgerColumns?.length + ? `Product ledger · sorted by ${viewBenchmark.ledgerColumns[0].label}` + : "Product ledger · sorted by p50"} + </p> + <LedgerTable benchmark={viewBenchmark} activePanel={activePanel} topN={topN} /> + </> + )} </div> {viewBenchmark.unit !== "count" && diff --git a/src/components/compare-this-bench.tsx b/src/components/compare-this-bench.tsx new file mode 100644 index 000000000..3c65611ea --- /dev/null +++ b/src/components/compare-this-bench.tsx @@ -0,0 +1,113 @@ +/** + * Server-rendered contextual compare links on /benchmarks/[slug]. + * + * The compare hub has 1,500+ ad-hoc /compare/<a>-vs-<b> pages seeded via + * the sitemap, but each one had <2 inlinks (Ahref data 2026-06), + * leaving Google to file them under "Indexed, not selected". Surfacing + * 3-5 head-to-head pairs on the parent bench turns every bench page + * into an internal PageRank source for its most relevant compare pages. + * + * Pairs are chosen from the top of the live ranking (#1v2, #1v3, #2v3, + * #1v5, #2v4) so the link targets align with the queries Google + * already sends to the bench (readers arrive on "best perp funding + * stability" and immediately see "hyperliquid vs bybit"). + * + * No-op when: + * - the bench category is Blockchains (chains have their own hubs) + * - fewer than 3 usable pairs can be built (skip HL builder hex + * slugs, dedupe canonical pairs) + */ + +import Link from "next/link"; +import { ArrowUpRight } from "lucide-react"; +import { liveResults } from "@/lib/provider-filters"; +import { rankResults } from "@/lib/ranking"; +import { canonicalPairSlug } from "@/lib/compare-pairing-shared"; +import type { Benchmark } from "@/types/benchmark"; + +// Mirror of the entry-side filter on /compare/[slug]/page.tsx. HL +// builder addresses leak into the provider catalog with no search +// demand, and compare pages built off them 404 anyway. +const HEX_SLUG_RE = /^0x[0-9a-f]{4,}$/i; + +// #1v#2, #1v#3, #2v#3, #1v#5, #2v#4. Reads as "leader vs runner-up", +// "leader vs #3", ..., which is what a reader landing on the bench +// wants to compare. Order matters: the earlier a pair appears in +// this list, the earlier it renders on the page. +const PAIR_INDEX_PATTERN: ReadonlyArray<readonly [number, number]> = [ + [0, 1], + [0, 2], + [1, 2], + [0, 4], + [1, 3], +]; + +const MIN_PAIRS = 3; + +export function CompareThisBench({ benchmark }: { benchmark: Benchmark }) { + // Blockchains category = chain-slug results. The hub for chains + // already carries the "chain vs chain" story; keep those pages + // out of the ad-hoc compare graph so we don't blur the two + // discovery paths. + if (benchmark.category === "Blockchains") return null; + + const live = liveResults(benchmark.results); + const ranked = rankResults(live, benchmark.higherIsBetter).filter( + (r) => !HEX_SLUG_RE.test(r.slug), + ); + + // Defensive dedupe by slug: some benches carry the same provider + // across dimension rows before the loader collapses them. + const seenSlugs = new Set<string>(); + const uniqueRanked = ranked.filter((r) => { + const key = r.slug.toLowerCase(); + if (seenSlugs.has(key)) return false; + seenSlugs.add(key); + return true; + }); + + if (uniqueRanked.length < 3) return null; + + const seenPairs = new Set<string>(); + const pairs: { pairSlug: string; a: string; b: string }[] = []; + for (const [i, j] of PAIR_INDEX_PATTERN) { + const a = uniqueRanked[i]; + const b = uniqueRanked[j]; + if (!a || !b) continue; + const pairSlug = canonicalPairSlug(a.slug, b.slug); + if (seenPairs.has(pairSlug)) continue; + seenPairs.add(pairSlug); + pairs.push({ pairSlug, a: a.name, b: b.name }); + } + + if (pairs.length < MIN_PAIRS) return null; + + return ( + <nav className="mt-12 max-w-3xl" aria-label="Head to head comparisons"> + <h2 className="label-mono text-ink-muted">Head to head</h2> + <p className="mt-2 text-sm text-ink-soft max-w-2xl"> + Side by side pages for the top providers on this benchmark. Each + one lays out every shared benchmark, live numbers, no verdict. + </p> + <ul className="mt-4 flex flex-wrap gap-2"> + {pairs.map((p) => ( + <li key={p.pairSlug}> + <Link + href={`/compare/${p.pairSlug}`} + className="inline-flex items-center gap-1.5 rounded-md card-soft px-3 py-1.5 text-sm text-ink-soft hover:text-ink" + > + <span> + {p.a} vs {p.b} + </span> + <ArrowUpRight + size={12} + strokeWidth={2} + className="shrink-0 text-ink-faint" + /> + </Link> + </li> + ))} + </ul> + </nav> + ); +} diff --git a/src/components/hl-archive-leaderboard.tsx b/src/components/hl-archive-leaderboard.tsx new file mode 100644 index 000000000..e71631a34 --- /dev/null +++ b/src/components/hl-archive-leaderboard.tsx @@ -0,0 +1,163 @@ +"use client"; + +/** + * Window-scoped leaderboard for the Hyperliquid long-window archive. + * + * Stateless: the parent (`BenchmarkBody`) owns the fetched payload and + * the loading state, so the chart pills (which select the same window) + * and this table stay synchronized through a single source of truth. + * + * Lives in its own component because the long-window archive rows have + * a different shape from `ProviderResult` (keyed by builder address, + * carry per-window aggregates instead of p50/p90/p99/mean) so the + * shared `LedgerTable` cannot render them without a wider refactor. + */ + +import { useMemo } from "react"; +import Link from "next/link"; +import { ProviderLogo } from "@/components/provider-logo"; +import { fmtUnit } from "@/lib/format"; +import type { + HlArchiveHistoryResponse, + HlArchiveRankedRow, + HlArchiveWindow, +} from "@/types/hl-archive"; + +type Props = { + /** Selected window. Drives the visible label only — the rows already + * match this window because the parent re-fetched on change. */ + window: HlArchiveWindow; + /** Cached archive payload, or `null` while loading / on error. */ + payload: HlArchiveHistoryResponse | null; + /** True while the parent is still fetching this window. */ + loading: boolean; + /** Provider slugs known on the live bench. Used to map archive rows + * (keyed by 0x address) back to /products/<slug> links by name match + * when the archive lacks the slug. */ + knownProviders?: { slug: string; name: string }[]; +}; + +const WINDOW_LABEL: Record<HlArchiveWindow, string> = { + "24h": "24h", + "7d": "7d", + "30d": "30d", + "90d": "90d", + "180d": "180d", + "1y": "1y", + all: "all time", +}; + +export function HlArchiveLeaderboard({ + window, + payload, + loading, + knownProviders = [], +}: Props) { + const productBySlug = useMemo( + () => new Map(knownProviders.map((p) => [p.slug, p])), + [knownProviders], + ); + const productByName = useMemo( + () => new Map(knownProviders.map((p) => [p.name.toLowerCase(), p])), + [knownProviders], + ); + + if (loading || !payload) { + return ( + <div className="py-16 text-center text-[12px] text-ink-muted"> + Loading {WINDOW_LABEL[window]} leaderboard + </div> + ); + } + return ( + <Leaderboard + rows={payload.rows} + window={window} + productBySlug={productBySlug} + productByName={productByName} + /> + ); +} + +function Leaderboard({ + rows, + window, + productBySlug, + productByName, +}: { + rows: HlArchiveRankedRow[]; + window: HlArchiveWindow; + productBySlug: Map<string, { slug: string; name: string }>; + productByName: Map<string, { slug: string; name: string }>; +}) { + if (rows.length === 0) { + return ( + <div className="py-12 text-center text-[12px] text-ink-muted"> + No builders had attributed flow in this window yet. + </div> + ); + } + return ( + <div className="overflow-x-auto -mx-4 sm:mx-0 px-4 sm:px-0"> + <table className="ledger w-full min-w-full border-collapse"> + <thead> + <tr> + <th className="border-y-2 border-ink py-2 pr-3 text-left">Product</th> + <th className="border-y-2 border-ink py-2 px-3 text-right"> + Volume (USD) + </th> + <th className="border-y-2 border-ink py-2 px-3 text-right hidden md:table-cell"> + Builder fees (USD) + </th> + <th className="border-y-2 border-ink py-2 px-3 text-right hidden md:table-cell"> + Fills + </th> + </tr> + </thead> + <tbody> + {rows.map((r) => { + const known = + productBySlug.get(r.slug) ?? productByName.get(r.name.toLowerCase()); + const dimmed = !known; + return ( + <tr + key={`${r.slug}-${window}`} + className={`border-b border-rule ${dimmed ? "opacity-60" : ""}`} + > + <td className="py-2.5 pr-3 font-serif text-[14px]"> + <span className="flex items-center gap-2 min-w-0"> + <span className="text-ink-muted text-[12px] w-7 shrink-0"> + {String(r.rank).padStart(2, "0")} + </span> + {known && ( + <ProviderLogo slug={known.slug} name={known.name} size={20} /> + )} + {known ? ( + <Link + href={`/products/${known.slug}`} + className="font-semibold hover:underline underline-offset-2" + > + {known.name} + </Link> + ) : ( + <span className="font-semibold">{r.name}</span> + )} + </span> + </td> + <td className="py-2.5 px-3 text-right whitespace-nowrap"> + {fmtUnit(r.volume_usd, "usd")} + </td> + <td className="py-2.5 px-3 text-right text-ink-soft whitespace-nowrap hidden md:table-cell"> + {fmtUnit(r.fees_usd, "usd")} + </td> + <td className="py-2.5 px-3 text-right text-ink-soft whitespace-nowrap hidden md:table-cell"> + {r.fills > 0 ? r.fills.toLocaleString() : "-"} + </td> + </tr> + ); + })} + </tbody> + </table> + </div> + ); +} diff --git a/src/components/hl-cohort-leaderboard.tsx b/src/components/hl-cohort-leaderboard.tsx index e6450fe7b..2a794b14e 100644 --- a/src/components/hl-cohort-leaderboard.tsx +++ b/src/components/hl-cohort-leaderboard.tsx @@ -3,18 +3,33 @@ import Link from "next/link"; import { useMemo, useState } from "react"; import { ProviderLogo } from "@/components/provider-logo"; -import type { HlCohortRow } from "@/lib/hl-builder-stats"; +import { HlSparkline } from "@/components/hl-sparkline"; +import type { + HlCohortRow, + HlHistoryFrontendCompact, +} from "@/lib/hl-builder-stats"; /** * Sortable + searchable leaderboard of every tracked Hyperliquid * frontend. Data is passed in from the SSR pass so the initial paint * is fully populated (good for SEO and TTFB); the client only handles * interaction state. + * + * When a `historyBySlug` map is provided the row renders a compact + * 12-month sparkline in the trend column (matches the parent hub's + * per-frontend detail page). Slugs missing from the map render an + * em-dash so builders without a history sample don't blank the column. */ type SortKey = "revenue30d" | "volume30d" | "users30d" | "cohortVolumeShare24h"; -export function HlCohortLeaderboard({ rows }: { rows: HlCohortRow[] }) { +export function HlCohortLeaderboard({ + rows, + historyBySlug, +}: { + rows: HlCohortRow[]; + historyBySlug?: Map<string, HlHistoryFrontendCompact>; +}) { const [sortKey, setSortKey] = useState<SortKey>("revenue30d"); const [sortDir, setSortDir] = useState<"desc" | "asc">("desc"); const [q, setQ] = useState(""); @@ -92,47 +107,65 @@ export function HlCohortLeaderboard({ rows }: { rows: HlCohortRow[] }) { > % cohort 24h </ThSort> + {historyBySlug && <Th>12m trend</Th>} <Th> </Th> </tr> </thead> <tbody> - {filtered.map((r, i) => ( - <tr - key={r.slug} - className="border-t border-ink/5 hover:bg-paper-soft/40 transition-colors" - > - <Td muted mono> - {i + 1} - </Td> - <Td> - <Link - href={`/products/${r.slug}`} - className="flex items-center gap-2 min-w-0 hover:underline" - > - <ProviderLogo slug={r.slug} name={r.name} size={18} /> - <span className="font-medium text-ink truncate"> - {r.name} - </span> - </Link> - </Td> - <Td mono>{fmtUSD(r.revenue30d)}</Td> - <Td mono>{fmtUSD(r.volume30d)}</Td> - <Td mono>{fmtCount(r.users30d)}</Td> - <Td mono>{fmtPct(r.cohortVolumeShare24h)}</Td> - <Td> - <Link - href={`/products/${r.slug}`} - className="text-[11px] text-ink-faint hover:text-ink" - > - Open → - </Link> - </Td> - </tr> - ))} + {filtered.map((r, i) => { + const hist = historyBySlug?.get(r.slug); + return ( + <tr + key={r.slug} + className="border-t border-ink/5 hover:bg-paper-soft/40 transition-colors" + > + <Td muted mono> + {i + 1} + </Td> + <Td> + <Link + href={`/hyperliquid/${r.slug}`} + className="flex items-center gap-2 min-w-0 hover:underline" + > + <ProviderLogo slug={r.slug} name={r.name} size={18} /> + <span className="font-medium text-ink truncate"> + {r.name} + </span> + </Link> + </Td> + <Td mono>{fmtUSD(r.revenue30d)}</Td> + <Td mono>{fmtUSD(r.volume30d)}</Td> + <Td mono>{fmtCount(r.users30d)}</Td> + <Td mono>{fmtPct(r.cohortVolumeShare24h)}</Td> + {historyBySlug && ( + <Td> + {hist ? ( + <HlSparkline values={hist.fees} width={160} height={24} /> + ) : ( + <span + className="text-[11px] text-ink-faint" + style={{ fontFamily: "var(--font-mono, monospace)" }} + > + — + </span> + )} + </Td> + )} + <Td> + <Link + href={`/hyperliquid/${r.slug}`} + className="text-[11px] text-ink-faint hover:text-ink" + > + Open → + </Link> + </Td> + </tr> + ); + })} {filtered.length === 0 && ( <tr> <td - colSpan={7} + colSpan={historyBySlug ? 8 : 7} className="px-3 py-8 text-center text-[12px] text-ink-faint" > No builder matches “{q}”. diff --git a/src/components/hl-frontend-card.tsx b/src/components/hl-frontend-card.tsx new file mode 100644 index 000000000..4563fd8ae --- /dev/null +++ b/src/components/hl-frontend-card.tsx @@ -0,0 +1,195 @@ +import Link from "next/link"; +import type { HlHistoryFrontendCompact } from "@/lib/hl-builder-stats"; + +/** + * Compact per-frontend card for the `/hyperliquid` grid overview. Displays + * a single-metric log-scale sparkline over the last 12 months plus the + * current rolling-30d fees KPI, a 30-day delta and the frontend's first + * active day. Whole card is a `<Link>` to `/hyperliquid/[slug]` so the + * detail page is one click away. + * + * The parent chart (`HlHistoryChart`) uses log10(v+1) on Y; we mirror that + * here so a $200 frontend and a $5M frontend both get readable sparkline + * shapes at 200x40px. + */ + +type Props = { + frontend: HlHistoryFrontendCompact; + rank: number; + /** t0 (ms) from the parent envelope, needed to render the "since" label. */ + t0: number; + /** step (seconds) from the parent envelope. */ + step: number; +}; + +export function HlFrontendCard({ frontend, rank, t0, step }: Props) { + const { slug, name, fees, firstIdx } = frontend; + + // Last non-null fees value = current KPI. Walk from the end so a trailing + // null (harness gap on the freshest UTC day) doesn't blank the card. + let currentFees: number | null = null; + let currentIdx = -1; + for (let i = fees.length - 1; i >= 0; i--) { + const v = fees[i]; + if (v !== null && Number.isFinite(v)) { + currentFees = v; + currentIdx = i; + break; + } + } + + // 30-day delta: current vs the value 30 samples earlier in the local + // array. `firstIdx` is already dropped from the array, so we don't need + // to re-offset. Skip if we don't have 30 days of history. + let delta30d: number | null = null; + if (currentFees !== null && currentFees > 0 && currentIdx >= 30) { + const prev = fees[currentIdx - 30]; + if (prev !== null && Number.isFinite(prev) && prev > 0) { + delta30d = (currentFees - prev) / prev; + } + } + + const firstDayMs = t0 + step * 1000 * firstIdx; + const sinceLabel = formatSince(firstDayMs); + + return ( + <Link + href={`/hyperliquid/${slug}`} + className="group flex flex-col rounded-lg border border-ink/10 bg-paper p-3 transition-colors hover:border-ink/30 hover:bg-paper-soft/40" + > + <div className="flex items-start justify-between gap-2"> + <div className="min-w-0"> + <p className="truncate text-sm font-semibold text-ink">{name}</p> + <p + className="truncate text-[10px] text-ink-faint" + style={{ fontFamily: "var(--font-mono, monospace)" }} + > + {slug} + </p> + </div> + <span + className="shrink-0 rounded-full border border-ink/10 bg-paper-soft px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-ink-faint" + style={{ fontFamily: "var(--font-mono, monospace)" }} + > + #{rank} + </span> + </div> + + <div className="mt-2 flex items-baseline gap-2"> + <span className="text-lg font-semibold tabular-nums text-ink"> + {currentFees !== null ? fmtUSDShort(currentFees) : "—"} + </span> + {delta30d !== null && ( + <span + className="text-[11px] font-medium tabular-nums" + style={{ + color: delta30d >= 0 ? "var(--color-good)" : "var(--color-bad)", + }} + > + {fmtDelta(delta30d)} + </span> + )} + </div> + <p + className="label-mono mt-0.5 text-[10px] text-ink-faint" + style={{ fontFamily: "var(--font-mono, monospace)" }} + > + Fees 30d + </p> + + <Sparkline values={fees} /> + + <p className="mt-2 text-[10px] text-ink-faint">since {sinceLabel}</p> + </Link> + ); +} + +/** Log-scale sparkline mirroring HlHistoryChart's Y transform. Null values + * break the path so gaps render as gaps rather than dropping to zero. */ +function Sparkline({ values }: { values: (number | null)[] }) { + const W = 200; + const H = 40; + const PAD = 2; + const plotW = W - PAD * 2; + const plotH = H - PAD * 2; + + if (values.length === 0) { + return <svg viewBox={`0 0 ${W} ${H}`} className="mt-2 block h-10 w-full" />; + } + + let vMax = 0; + for (const v of values) { + if (v !== null && v > vMax) vMax = v; + } + const logMax = Math.log10(vMax + 1); + const logDen = logMax || 1; + + const n = values.length; + const xFor = (i: number) => PAD + (n === 1 ? 0 : (i / (n - 1)) * plotW); + const yFor = (v: number) => { + const clamped = v > 0 ? v : 0; + const norm = Math.log10(clamped + 1) / logDen; + return PAD + plotH * (1 - norm); + }; + + const parts: string[] = []; + let inSeg = false; + for (let i = 0; i < n; i++) { + const v = values[i]; + if (v === null) { + inSeg = false; + continue; + } + const cmd = inSeg ? "L" : "M"; + parts.push(`${cmd} ${xFor(i).toFixed(1)} ${yFor(v).toFixed(1)}`); + inSeg = true; + } + const path = parts.join(" "); + + return ( + <svg + viewBox={`0 0 ${W} ${H}`} + className="mt-2 block h-10 w-full" + preserveAspectRatio="none" + aria-hidden="true" + > + <path + d={path} + fill="none" + stroke="#9d65ff" + strokeWidth={1.5} + strokeLinecap="round" + strokeLinejoin="round" + className="transition-opacity group-hover:opacity-100" + style={{ opacity: 0.85 }} + /> + </svg> + ); +} + +function fmtUSDShort(v: number): string { + if (!Number.isFinite(v) || v === 0) return "$0"; + const abs = Math.abs(v); + if (abs >= 1_000_000_000) return `$${(v / 1_000_000_000).toFixed(2)}B`; + if (abs >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`; + if (abs >= 1_000) return `$${(v / 1_000).toFixed(1)}K`; + return `$${v.toFixed(0)}`; +} + +function fmtDelta(d: number): string { + const pct = d * 100; + const sign = pct >= 0 ? "+" : ""; + if (Math.abs(pct) >= 1000) return `${sign}${pct.toFixed(0)}%`; + if (Math.abs(pct) >= 100) return `${sign}${pct.toFixed(0)}%`; + return `${sign}${pct.toFixed(1)}%`; +} + +const MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +function formatSince(ms: number): string { + const d = new Date(ms); + return `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`; +} diff --git a/src/components/hl-frontend-grid.tsx b/src/components/hl-frontend-grid.tsx new file mode 100644 index 000000000..9a88d287e --- /dev/null +++ b/src/components/hl-frontend-grid.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { + HlHistoryFrontendCompact, + HlHistorySummary, +} from "@/lib/hl-builder-stats"; +import { HlFrontendCard } from "@/components/hl-frontend-card"; + +/** + * Responsive grid of `HlFrontendCard`, one per frontend in the compact + * history blob. Client component so the sort selector is interactive + * without a network round-trip. + * + * Sort modes: + * - `fees` → last non-null fees value descending (default: matches + * the leaderboard's implicit ordering). + * - `peak` → all-time max of the fees array, descending. + * - `age` → first-active timestamp ascending (oldest first). + * - `volume`→ last non-null volume value descending. + */ + +type SortBy = "fees" | "volume" | "peak" | "age"; + +export function HlFrontendGrid({ + history, + sortBy: initialSortBy = "fees", +}: { + history: HlHistorySummary; + sortBy?: SortBy; +}) { + const [sortBy, setSortBy] = useState<SortBy>(initialSortBy); + + const sorted = useMemo( + () => sortFrontends(history.frontends, sortBy), + [history.frontends, sortBy], + ); + + return ( + <div> + <div className="mb-4 flex flex-wrap items-center justify-between gap-3"> + <p className="text-sm text-ink-faint"> + {history.frontends.length} frontends · 12-month rolling 30d fees + </p> + <div className="inline-flex rounded-md border border-ink/15 text-[11px] overflow-hidden"> + <SortButton value="fees" current={sortBy} onSelect={setSortBy}> + Fees now + </SortButton> + <SortButton value="volume" current={sortBy} onSelect={setSortBy}> + Volume now + </SortButton> + <SortButton value="peak" current={sortBy} onSelect={setSortBy}> + All-time peak + </SortButton> + <SortButton value="age" current={sortBy} onSelect={setSortBy}> + Oldest first + </SortButton> + </div> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"> + {sorted.map((f, i) => ( + <HlFrontendCard + key={f.slug} + frontend={f} + rank={i + 1} + t0={history.t0} + step={history.step} + /> + ))} + </div> + </div> + ); +} + +function SortButton({ + value, + current, + onSelect, + children, +}: { + value: SortBy; + current: SortBy; + onSelect: (v: SortBy) => void; + children: React.ReactNode; +}) { + const active = value === current; + return ( + <button + type="button" + onClick={() => onSelect(value)} + className={`px-2.5 py-1.5 font-medium border-l border-ink/15 first:border-l-0 ${ + active + ? "bg-ink text-paper" + : "text-ink-soft hover:bg-paper-soft/60" + }`} + > + {children} + </button> + ); +} + +function lastNonNull(arr: (number | null)[]): number { + for (let i = arr.length - 1; i >= 0; i--) { + const v = arr[i]; + if (v !== null && Number.isFinite(v)) return v; + } + return 0; +} + +function peakOf(arr: (number | null)[]): number { + let m = 0; + for (const v of arr) { + if (v !== null && v > m) m = v; + } + return m; +} + +function sortFrontends( + frontends: HlHistoryFrontendCompact[], + by: SortBy, +): HlHistoryFrontendCompact[] { + const copy = [...frontends]; + if (by === "fees") { + copy.sort((a, b) => lastNonNull(b.fees) - lastNonNull(a.fees)); + } else if (by === "volume") { + copy.sort((a, b) => lastNonNull(b.volume) - lastNonNull(a.volume)); + } else if (by === "peak") { + copy.sort((a, b) => peakOf(b.fees) - peakOf(a.fees)); + } else if (by === "age") { + copy.sort((a, b) => a.firstIdx - b.firstIdx); + } + return copy; +} diff --git a/src/components/hl-history-chart.tsx b/src/components/hl-history-chart.tsx new file mode 100644 index 000000000..5186c465e --- /dev/null +++ b/src/components/hl-history-chart.tsx @@ -0,0 +1,598 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import type { + HlHistoryFrontendCompact, + HlHistorySummary, +} from "@/lib/hl-builder-stats"; + +/** + * 12-month evolution chart for every active HL frontend (~98). Two + * toggleable metrics (fees / volume, both 30d rolling). Top-N frontends + * are drawn in the OCB palette; the remaining "long tail" renders in a + * desaturated grey overlay so the eye still gets the shape of the + * cohort's overall scale without the legend blowing up. + * + * The input blob is the compact shape written by the worker: + * - shared time axis `t0 + step*i` + * - per-frontend `firstIdx` drops leading nulls + * - values are pre-rounded to integer USD + * + * Design goals: + * - Stays a single SVG. No recharts / D3. 98 × 365 int points renders + * comfortably; grey tail lines share a single `<path>` styling. + * - Gaps: `v === null` points break the line rather than dropping to + * zero. Matches the harness' "no sample this UTC day" semantic and + * keeps early-history cohorts (post-launch) from starting from an + * artificial floor. + * - Colours: 10-slot OCB palette, cycled if the top set grows past + * 10. Hovered / pinned line lifts to full opacity; the rest dim. + * - Crosshair tooltip lists top-N + hovered tail entry so the reader + * never chases a grey line without a label. + */ + +const COLORS = [ + "#9d65ff", // violet + "#ff8a3d", // orange + "#22c55e", // emerald + "#38bdf8", // sky + "#f43f5e", // rose + "#eab308", // amber + "#14b8a6", // teal + "#a855f7", // fuchsia + "#f97316", // deep orange + "#0ea5e9", // blue + "#84cc16", // lime + "#ec4899", // pink + "#06b6d4", // cyan + "#f59e0b", // dark amber + "#10b981", // green + "#8b5cf6", // purple + "#ef4444", // red + "#3b82f6", // indigo + "#d946ef", // magenta + "#65a30d", // olive +]; + +/** How many frontends get a colour + legend entry. The rest are drawn + * as a desaturated grey overlay so the chart shows the full cohort's + * scale without the legend collapsing under 98 chips. */ +const TOP_COLORED = 20; + +type Metric = "fees" | "volume"; + +export function HlHistoryChart({ + history, + focusSlugs, +}: { + history: HlHistorySummary; + /** When provided, only these slugs render (all in colour, no grey + * long-tail overlay, no top/tail split). Powers the per-frontend + * detail page at `/hyperliquid/[slug]`. */ + focusSlugs?: string[]; +}) { + const [metric, setMetric] = useState<Metric>("fees"); + const [pinnedSlug, setPinnedSlug] = useState<string | null>(null); + + const focusSet = useMemo( + () => (focusSlugs && focusSlugs.length > 0 ? new Set(focusSlugs) : null), + [focusSlugs], + ); + + const activeFrontends = useMemo( + () => + focusSet + ? history.frontends.filter((f) => focusSet.has(f.slug)) + : history.frontends, + [history.frontends, focusSet], + ); + if (activeFrontends.length === 0) { + return ( + <p className="text-sm text-ink-faint italic"> + No history samples yet — the backfill is still populating. + </p> + ); + } + + // Focus mode: every requested slug gets a colour; skip the grey tail. + const topFrontends = focusSet + ? activeFrontends + : activeFrontends.slice(0, TOP_COLORED); + const tailFrontends = focusSet ? [] : activeFrontends.slice(TOP_COLORED); + + return ( + <div + className="rounded-xl border border-ink/10 p-4 sm:p-6" + style={{ + background: + "linear-gradient(180deg, rgba(157,101,255,0.04), rgba(157,101,255,0.01) 60%, transparent)", + boxShadow: + "0 1px 0 rgba(0,0,0,0.02), 0 8px 24px -16px rgba(60,40,110,0.16)", + }} + > + <div className="mb-4 flex flex-wrap items-center justify-between gap-3"> + <div> + <p className="label-mono text-[10px] text-ink-faint"> + Last 12 months · rolling 30d + </p> + <p className="text-sm text-ink-faint mt-0.5"> + Daily-stepped snapshot of {activeFrontends.length} HL frontends + {tailFrontends.length > 0 ? ( + <> + {" "} + — top {TOP_COLORED} highlighted, {tailFrontends.length} in the + grey long tail + </> + ) : null} + </p> + </div> + <div className="flex items-center gap-2"> + <span + className="label-mono text-[10px] rounded border border-ink/15 px-1.5 py-0.5 text-ink-soft bg-paper-soft/40" + title="Y axis uses log10 scale so long-tail frontends stay legible against $1M+ leaders." + > + log10 scale + </span> + <div className="inline-flex rounded-md border border-ink/15 text-[11px] overflow-hidden"> + <button + type="button" + onClick={() => setMetric("fees")} + className={`px-3 py-1.5 font-medium ${ + metric === "fees" + ? "bg-ink text-paper" + : "text-ink-soft hover:bg-paper-soft/60" + }`} + > + Fees 30d + </button> + <button + type="button" + onClick={() => setMetric("volume")} + className={`px-3 py-1.5 font-medium border-l border-ink/15 ${ + metric === "volume" + ? "bg-ink text-paper" + : "text-ink-soft hover:bg-paper-soft/60" + }`} + > + Volume 30d + </button> + </div> + </div> + </div> + + <ChartCanvas + history={history} + topFrontends={topFrontends} + tailFrontends={tailFrontends} + metric={metric} + pinnedSlug={pinnedSlug} + /> + + <div className="mt-4 flex flex-wrap gap-2"> + {topFrontends.map((f, i) => { + const color = COLORS[i % COLORS.length]; + const pinned = pinnedSlug === f.slug; + return ( + <button + key={f.slug} + type="button" + onClick={() => setPinnedSlug(pinned ? null : f.slug)} + className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] transition-opacity ${ + pinnedSlug && !pinned + ? "border-ink/8 opacity-50" + : "border-ink/15" + }`} + > + <span + className="inline-block h-2.5 w-2.5 rounded-full" + style={{ background: color }} + /> + <span className="text-ink">{f.name}</span> + </button> + ); + })} + </div> + </div> + ); +} + +function ChartCanvas({ + history, + topFrontends, + tailFrontends, + metric, + pinnedSlug, +}: { + history: HlHistorySummary; + topFrontends: HlHistoryFrontendCompact[]; + tailFrontends: HlHistoryFrontendCompact[]; + metric: Metric; + pinnedSlug: string | null; +}) { + const W = 1100; + const H = 360; + const PAD_L = 68; + const PAD_R = 20; + const PAD_T = 20; + const PAD_B = 44; + const plotW = W - PAD_L - PAD_R; + const plotH = H - PAD_T - PAD_B; + + const stepMs = history.step * 1000; + const t0 = history.t0; + + const seriesOf = (f: HlHistoryFrontendCompact): (number | null)[] => + metric === "fees" ? f.fees : f.volume; + + const timestampAt = (f: HlHistoryFrontendCompact, i: number): number => + t0 + stepMs * (f.firstIdx + i); + + // Shared time axis: derive from the compact envelope. Longest series = + // t0 → t0 + step*(maxFirstIdx + maxLen - 1). Fall back to (t0, t0+step) + // so the SVG still lays out on an empty payload. + const tRange = useMemo(() => { + let tMin = Number.POSITIVE_INFINITY; + let tMax = Number.NEGATIVE_INFINITY; + const all = [...topFrontends, ...tailFrontends]; + for (const f of all) { + const s = seriesOf(f); + if (s.length === 0) continue; + const first = timestampAt(f, 0); + const last = timestampAt(f, s.length - 1); + if (first < tMin) tMin = first; + if (last > tMax) tMax = last; + } + if (!Number.isFinite(tMin) || !Number.isFinite(tMax) || tMin === tMax) { + return { tMin: t0, tMax: t0 + stepMs }; + } + return { tMin, tMax }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [topFrontends, tailFrontends, metric, t0, stepMs]); + + const yMax = useMemo(() => { + let m = 0; + const all = [...topFrontends, ...tailFrontends]; + for (const f of all) { + for (const v of seriesOf(f)) { + if (v !== null && v > m) m = v; + } + } + return niceLogMax(m); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [topFrontends, tailFrontends, metric]); + + // Log10 scale on the Y axis. We compress the value into log space via + // log10(v + 1) so v === 0 maps cleanly to 0 (no −∞) and the +1 offset + // is negligible once we hit even $10. Effective floor is 1 (log10(1+1) + // ≈ 0.30), which keeps sub-$1 noise off the axis. Long-tail frontends + // in the $100–$10k range now get vertical breathing room next to the + // $1M+ leaders instead of collapsing into the zero line. + const logMin = 0; // log10(0 + 1) = 0 + const logMax = Math.log10(yMax + 1); + const logDen = logMax - logMin || 1; + + const xFor = (t: number) => { + const span = tRange.tMax - tRange.tMin || 1; + return PAD_L + ((t - tRange.tMin) / span) * plotW; + }; + const yFor = (v: number) => { + const clamped = v > 0 ? v : 0; + const norm = (Math.log10(clamped + 1) - logMin) / logDen; + return PAD_T + plotH * (1 - norm); + }; + + // Multi-segment path: break the line whenever we hit a null so the + // chart shows gaps rather than a straight fall to zero + spike back. + const pathFor = (f: HlHistoryFrontendCompact): string => { + const s = seriesOf(f); + const parts: string[] = []; + let inSegment = false; + for (let i = 0; i < s.length; i++) { + const v = s[i]; + if (v === null) { + inSegment = false; + continue; + } + const cmd = inSegment ? "L" : "M"; + const t = timestampAt(f, i); + parts.push(`${cmd} ${xFor(t).toFixed(1)} ${yFor(v).toFixed(1)}`); + inSegment = true; + } + return parts.join(" "); + }; + + // Power-of-10 gridlines from $1 → yMax. Log axis needs decade ticks + // (not evenly spaced fractions) so the reader can eyeball orders of + // magnitude directly. + const yTicks = useMemo(() => buildLogTicks(yMax), [yMax]); + const monthTicks = useMemo( + () => buildMonthTicks(tRange.tMin, tRange.tMax), + [tRange.tMin, tRange.tMax], + ); + + const svgRef = useRef<SVGSVGElement>(null); + const [hoverT, setHoverT] = useState<number | null>(null); + + const onMove: React.PointerEventHandler<SVGSVGElement> = (e) => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const xRatio = (e.clientX - rect.left) / rect.width; + const px = xRatio * W; + if (px < PAD_L || px > W - PAD_R) { + setHoverT(null); + return; + } + const span = tRange.tMax - tRange.tMin; + const t = tRange.tMin + ((px - PAD_L) / plotW) * span; + setHoverT(t); + }; + + // Snap hover to nearest sample per frontend for the tooltip readout. + // Only the coloured top-N surface in the tooltip; a 98-line list would + // be unreadable. + const hoverRows = useMemo(() => { + if (hoverT === null) return null; + const rows: { slug: string; name: string; color: string; v: number | null }[] = []; + for (let i = 0; i < topFrontends.length; i++) { + const f = topFrontends[i]; + const s = seriesOf(f); + if (s.length === 0) { + rows.push({ slug: f.slug, name: f.name, color: COLORS[i % COLORS.length], v: null }); + continue; + } + let bestIdx = 0; + let bestDist = Math.abs(timestampAt(f, 0) - hoverT); + for (let j = 1; j < s.length; j++) { + const d = Math.abs(timestampAt(f, j) - hoverT); + if (d < bestDist) { + bestDist = d; + bestIdx = j; + } + } + rows.push({ + slug: f.slug, + name: f.name, + color: COLORS[i % COLORS.length], + v: s[bestIdx] ?? null, + }); + } + rows.sort((a, b) => (b.v ?? -1) - (a.v ?? -1)); + return rows; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hoverT, topFrontends, metric, t0, stepMs]); + + const hoverX = hoverT !== null ? xFor(hoverT) : null; + const hoverDate = hoverT !== null ? formatDate(hoverT) : null; + + return ( + <div className="relative w-full overflow-hidden"> + <svg + ref={svgRef} + viewBox={`0 0 ${W} ${H}`} + className="block w-full h-[320px] sm:h-[360px]" + preserveAspectRatio="none" + onPointerMove={onMove} + onPointerLeave={() => setHoverT(null)} + > + {yTicks.map((v) => { + const y = yFor(v); + const isFloor = v <= 1; + return ( + <g key={v}> + <line + x1={PAD_L} + x2={W - PAD_R} + y1={y} + y2={y} + stroke="currentColor" + className="text-ink/8" + strokeWidth={1} + strokeDasharray={isFloor ? "0" : "2 4"} + /> + <text + x={PAD_L - 8} + y={y + 3} + textAnchor="end" + style={{ fontFamily: "var(--font-mono, monospace)" }} + className="fill-ink-faint text-[10px] tabular-nums" + > + {fmtUSDShort(v)} + </text> + </g> + ); + })} + + {monthTicks.map((mt) => { + const x = xFor(mt.t); + if (x < PAD_L - 1 || x > W - PAD_R + 1) return null; + return ( + <g key={mt.t}> + <line + x1={x} + x2={x} + y1={PAD_T + plotH} + y2={PAD_T + plotH + 4} + stroke="currentColor" + className="text-ink/20" + strokeWidth={1} + /> + <text + x={x} + y={H - PAD_B + 18} + textAnchor="middle" + style={{ fontFamily: "var(--font-mono, monospace)" }} + className="fill-ink-faint text-[10px] tabular-nums" + > + {mt.label} + </text> + </g> + ); + })} + + {/* Long-tail grey overlay. Drawn first so the coloured top-N + paints above it. Kept as one class + one stroke so the DOM + stays cheap even with ~80 extra paths. */} + {tailFrontends.map((f) => ( + <path + key={f.slug} + d={pathFor(f)} + fill="none" + stroke="#9ca3af" + strokeWidth={1} + strokeOpacity={pinnedSlug ? 0.05 : 0.1} + strokeLinecap="round" + strokeLinejoin="round" + style={{ transition: "stroke-opacity 120ms ease-out" }} + /> + ))} + + {topFrontends.map((f, i) => { + const color = COLORS[i % COLORS.length]; + const dimmed = pinnedSlug !== null && pinnedSlug !== f.slug; + return ( + <path + key={f.slug} + d={pathFor(f)} + fill="none" + stroke={color} + strokeWidth={pinnedSlug === f.slug ? 2.4 : 1.6} + strokeOpacity={dimmed ? 0.15 : 0.9} + strokeLinecap="round" + strokeLinejoin="round" + style={{ transition: "stroke-opacity 120ms ease-out" }} + /> + ); + })} + + {hoverX !== null && ( + <line + x1={hoverX} + x2={hoverX} + y1={PAD_T} + y2={PAD_T + plotH} + stroke="currentColor" + className="text-ink/25" + strokeWidth={1} + strokeDasharray="2 2" + /> + )} + </svg> + + {hoverRows && hoverDate && hoverX !== null && ( + <Tooltip + rows={hoverRows} + date={hoverDate} + xFrac={hoverX / W} + /> + )} + </div> + ); +} + +function Tooltip({ + rows, + date, + xFrac, +}: { + rows: { slug: string; name: string; color: string; v: number | null }[]; + date: string; + xFrac: number; +}) { + const left = Math.max(4, Math.min(96, xFrac * 100)); + const flipX = left > 55; + return ( + <div + className="pointer-events-none absolute z-10 rounded-lg border border-ink/15 bg-paper shadow-lg px-3 py-2 text-[11.5px]" + style={{ + left: `${left}%`, + top: 8, + transform: `translateX(${flipX ? "-100%" : "0%"}) translateX(${flipX ? -8 : 8}px)`, + minWidth: 200, + }} + > + <p + className="label-mono text-[10px] text-ink-faint mb-1" + style={{ fontFamily: "var(--font-mono, monospace)" }} + > + {date} + </p> + <div className="grid grid-cols-1 gap-0.5"> + {rows.slice(0, 10).map((r) => ( + <div key={r.slug} className="flex items-center justify-between gap-3"> + <span className="flex items-center gap-1.5 truncate"> + <span + className="inline-block h-2 w-2 rounded-full flex-shrink-0" + style={{ background: r.color }} + /> + <span className="text-ink truncate">{r.name}</span> + </span> + <span className="font-semibold tabular-nums"> + {r.v === null ? "—" : fmtUSDShort(r.v)} + </span> + </div> + ))} + </div> + </div> + ); +} + +/** Round up to the next decade for a log-scale ceiling ($10, $100, $1k, …). + * Guarantees the topmost gridline is a clean power of 10 so labels never + * read `$1.7M` or `$3.4M`. */ +function niceLogMax(v: number): number { + if (!Number.isFinite(v) || v <= 10) return 10; + return Math.pow(10, Math.ceil(Math.log10(v))); +} + +/** Decade gridlines from $1 up through niceLogMax. Small enough (≤ 8 + * entries for a $10M ceiling) that we don't need mid-decade ticks. */ +function buildLogTicks(max: number): number[] { + const topExp = Math.max(1, Math.ceil(Math.log10(Math.max(max, 10)))); + const out: number[] = [1]; + for (let e = 1; e <= topExp; e++) { + out.push(Math.pow(10, e)); + } + return out; +} + +function fmtUSDShort(v: number): string { + if (!Number.isFinite(v) || v === 0) return "$0"; + const abs = Math.abs(v); + if (abs >= 1_000_000_000) return `$${(v / 1_000_000_000).toFixed(1)}B`; + if (abs >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M`; + if (abs >= 1_000) return `$${(v / 1_000).toFixed(0)}K`; + return `$${v.toFixed(0)}`; +} + +function formatDate(t: number): string { + const d = new Date(t); + return d.toISOString().slice(0, 10); +} + +/** First-of-month labels between two epoch-ms bounds. Keeps the count + * bounded (~12 labels) so the axis never crowds. */ +function buildMonthTicks( + tMinMs: number, + tMaxMs: number, +): { t: number; label: string }[] { + const out: { t: number; label: string }[] = []; + const start = new Date(tMinMs); + const cursor = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1)); + const MONTH_NAMES = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + while (cursor.getTime() <= tMaxMs) { + const t = cursor.getTime(); + if (t >= tMinMs) { + const label = + cursor.getUTCMonth() === 0 + ? `${MONTH_NAMES[0]} ${cursor.getUTCFullYear() % 100}` + : MONTH_NAMES[cursor.getUTCMonth()]; + out.push({ t, label }); + } + cursor.setUTCMonth(cursor.getUTCMonth() + 1); + } + return out; +} diff --git a/src/components/hl-hub-tabs.tsx b/src/components/hl-hub-tabs.tsx index f08eaaa09..6b6400348 100644 --- a/src/components/hl-hub-tabs.tsx +++ b/src/components/hl-hub-tabs.tsx @@ -1,11 +1,13 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { HlCohortLeaderboard } from "@/components/hl-cohort-leaderboard"; import { HlHip3Leaderboard } from "@/components/hl-hip3-leaderboard"; import type { HlCohortSummary, HlHip3Summary, + HlHistoryFrontendCompact, + HlHistorySummary, } from "@/lib/hl-builder-stats"; /** @@ -25,9 +27,11 @@ type Tab = "frontends" | "hip3"; export function HlHubTabs({ frontends, hip3, + history, }: { frontends: HlCohortSummary | null; hip3: HlHip3Summary | null; + history?: HlHistorySummary | null; }) { const initialTab: Tab = frontends && frontends.rows.length > 0 ? "frontends" : "hip3"; @@ -36,6 +40,17 @@ export function HlHubTabs({ const frontendsCount = frontends?.rows.length ?? 0; const hip3Count = hip3?.rows.length ?? 0; + // Index the compact history array by slug once so the leaderboard rows + // can pull their sparkline series in O(1). Rebuilds only when the blob + // changes (server-side refresh cycle) so client-side sort/filter stays + // free of the O(rows × frontends) scan. + const historyBySlug = useMemo(() => { + if (!history) return undefined; + const m = new Map<string, HlHistoryFrontendCompact>(); + for (const f of history.frontends) m.set(f.slug, f); + return m; + }, [history]); + return ( <> <div @@ -62,7 +77,7 @@ export function HlHubTabs({ </div> {tab === "frontends" && frontends && ( - <FrontendsView data={frontends} /> + <FrontendsView data={frontends} historyBySlug={historyBySlug} /> )} {tab === "hip3" && hip3 && <Hip3View data={hip3} />} </> @@ -106,7 +121,13 @@ function TabButton({ ); } -function FrontendsView({ data }: { data: HlCohortSummary }) { +function FrontendsView({ + data, + historyBySlug, +}: { + data: HlCohortSummary; + historyBySlug?: Map<string, HlHistoryFrontendCompact>; +}) { return ( <> <section className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4"> @@ -129,7 +150,7 @@ function FrontendsView({ data }: { data: HlCohortSummary }) { tip="Sum across builders. A wallet active on two frontends is counted twice (HyperTracker uses the same convention)." /> </section> - <HlCohortLeaderboard rows={data.rows} /> + <HlCohortLeaderboard rows={data.rows} historyBySlug={historyBySlug} /> </> ); } diff --git a/src/components/hl-performance-chart.tsx b/src/components/hl-performance-chart.tsx index 71e1e6df9..b485ece51 100644 --- a/src/components/hl-performance-chart.tsx +++ b/src/components/hl-performance-chart.tsx @@ -3,29 +3,27 @@ import { useEffect, useMemo, useRef, useState } from "react"; /** - * Per-builder Performance chart: 30 daily revenue bars + a unique-users - * line overlay. Matches HyperTracker's "Performance" panel but with the - * OpenChainBench palette and a tighter editorial feel. + * Per-builder Performance chart: daily revenue bars + a unique-users + * line overlay, with a 30d / 90d / 1y range picker. * - * Visual choices, in case a future refactor wants to revisit: - * - Purple (#9d65ff) bars with a vertical gradient (lighter top), 3 px - * rounded corners — reads as "revenue" without needing a legend - * label hovering above each bar. - * - Orange (#ff8a3d) users line over a soft area fill so the trend is - * immediately readable as the secondary measure, even when the bars - * are tall. - * - Two-color glow dots (white outer ring, orange fill) — they stay - * visible on hover without an opaque tooltip stealing focus. - * - Biggest-day vertical guide rendered from the harness' - * `biggest_day_unix` so the milestone story is visible right inside - * the canvas, not just in the milestone row below. - * - Crosshair + floating tooltip on hover. Pure React state. - * - Stays a single SVG — no recharts/D3 — the shape is fixed and we - * are rendering 60 numbers. + * Data-source routing: + * - 30d: live on-node harness `/daily-series` (in-memory 30-day ring, + * includes real per-day unique-users counts). + * - 90d / 1y: hl-archive DuckDB via `/daily-history?days=N` (≈11 + * months backfilled at the time of writing). The archive doesn't + * store per-day users so `users` comes back as 0 for those ranges; + * the line-overlay flavour falls back to a revenue-line-only render. * - * Client component because the data fetch hits the on-node harness - * after hydration. Hides silently if the upstream isn't reachable - * (graceful degradation, not a broken section). + * Visual choices: + * - 30d keeps the original bar chart with the users overlay. + * - 90d densifies bars (no gap) so 90 bars still read as a histogram. + * - 1y switches to a smooth revenue area/line so ~365 daily samples + * don't turn into a wall of 1-px bars. + * - Biggest-day marker is always recomputed on the active series. + * + * Client component because the data fetch hits the API after hydration + * (and switches upstream when the user flips the range). Hides silently + * if every upstream is unreachable (graceful degradation). */ type Point = { @@ -47,6 +45,22 @@ const REVENUE_COLOR = "#9d65ff"; const REVENUE_COLOR_TOP = "#bca0ff"; const USERS_COLOR = "#ff8a3d"; +type Range = "30d" | "90d" | "1y"; + +const RANGE_OPTIONS: { key: Range; label: string; days: number }[] = [ + { key: "30d", label: "30d", days: 30 }, + { key: "90d", label: "90d", days: 90 }, + { key: "1y", label: "1y", days: 365 }, +]; + +function urlForRange(slug: string, range: Range): string { + if (range === "30d") { + return `/api/builder/${encodeURIComponent(slug)}/daily-series`; + } + const days = range === "90d" ? 90 : 365; + return `/api/builder/${encodeURIComponent(slug)}/daily-history?days=${days}`; +} + export function HlPerformanceChart({ slug, biggestDayUnix, @@ -54,49 +68,85 @@ export function HlPerformanceChart({ slug: string; biggestDayUnix?: number; }) { - const [data, setData] = useState<Payload | null>(null); - const [error, setError] = useState<string | null>(null); + const [range, setRange] = useState<Range>("30d"); + // Track load state per (slug, range) fetch key. We only surface the + // stored result when its key matches the current one, so switching + // range instantly shows a spinner without a synchronous setState at + // the top of the effect (which trips react-hooks/set-state-in-effect). + type Load = + | { key: string; state: "done"; data: Payload } + | { key: string; state: "error"; error: string }; + const [load, setLoad] = useState<Load | null>(null); + const fetchKey = `${slug}|${range}`; + const active = load && load.key === fetchKey ? load : null; useEffect(() => { let cancelled = false; - fetch(`/api/builder/${encodeURIComponent(slug)}/daily-series`, { - cache: "no-store", - }) + fetch(urlForRange(slug, range), { cache: "no-store" }) .then((r) => r.ok ? r.json() : Promise.reject(new Error(`http ${r.status}`)), ) .then((p: Payload) => { - if (!cancelled) setData(p); + if (!cancelled) setLoad({ key: fetchKey, state: "done", data: p }); }) .catch((e: unknown) => { - if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + if (!cancelled) + setLoad({ + key: fetchKey, + state: "error", + error: e instanceof Error ? e.message : String(e), + }); }); return () => { cancelled = true; }; - }, [slug]); + }, [slug, range, fetchKey]); - if (error) { + const data = active?.state === "done" ? active.data : null; + const loading = active === null; + const error = active?.state === "error" ? active.error : null; + + // Keep the 30d canvas visible if the long-window fetch fails: the + // chart still has value at the default range and the picker can + // recover from a transient archive brownout. + if (error && !data) { if (typeof console !== "undefined") console.warn(`HlPerformanceChart fetch failed for ${slug}:`, error); return null; } - if (!data) { + if (loading && !data) { return ( <div className="mt-6 card-soft rounded-xl p-4 border border-ink/10 h-[340px] animate-pulse" /> ); } - if (data.points.length === 0) return null; + if (!data || data.points.length === 0) return null; - return <ChartCanvas data={data} biggestDayUnix={biggestDayUnix} />; + return ( + <ChartCanvas + data={data} + biggestDayUnix={biggestDayUnix} + range={range} + onRangeChange={setRange} + loading={loading} + hasError={error !== null} + /> + ); } function ChartCanvas({ data, biggestDayUnix, + range, + onRangeChange, + loading, + hasError, }: { data: Payload; biggestDayUnix?: number; + range: Range; + onRangeChange: (r: Range) => void; + loading: boolean; + hasError: boolean; }) { const W = 1100; const H = 340; @@ -134,29 +184,50 @@ function ChartCanvas({ const niceMaxFees = niceMax(maxFees); const niceMaxUsers = niceMax(maxUsers); - const barW = Math.max(6, plotW / n - 6); + // Render mode: bars for the two shorter ranges (readable at + // <=90 samples); line-area for 1y so ~365 daily samples don't collapse + // into a wall of 1-px bars. The users overlay lights up whenever the + // active series carries real daily uniques (peakUsers > 0) — this is + // 30d today (live harness), but any range whose upstream starts serving + // per-day uniques will auto-surface the overlay too. Ranges without + // that data (90d/1y from hl-archive) collapse to a revenue-only render + // so we don't draw a misleading flat-zero users line. + const isLine = range === "1y"; + const showUsersOverlay = peakUsers > 0; + + // Squeeze the inter-bar gap for the mid range so 90 bars still read + // as a histogram; 30d keeps the original breathing room. + const barGap = range === "30d" ? 6 : range === "90d" ? 2 : 1; + const barW = Math.max(1, plotW / n - barGap); const xFor = (i: number) => PAD_L + (plotW * (i + 0.5)) / n; const yBar = (v: number) => PAD_T + plotH - (niceMaxFees > 0 ? (v / niceMaxFees) * plotH : 0); const yLine = (v: number) => PAD_T + plotH - (niceMaxUsers > 0 ? (v / niceMaxUsers) * plotH : 0); + const yRev = (v: number) => + PAD_T + plotH - (niceMaxFees > 0 ? (v / niceMaxFees) * plotH : 0); const ticks = [0, 0.25, 0.5, 0.75, 1.0]; const xLabelEvery = Math.max(1, Math.ceil(n / 7)); - // Smooth-ish poly-line over discrete daily samples. We keep straight - // segments rather than cardinal splines so the line never overshoots - // and never implies an interpolation that didn't happen — but we use - // a subtle stroke-linejoin "round" for visual cohesion. - const linePath = data.points + // Users overlay poly-line — only used for 30d. + const usersLinePath = data.points .map( (p, i) => `${i === 0 ? "M" : "L"} ${xFor(i).toFixed(1)} ${yLine(p.users).toFixed(1)}`, ) .join(" "); + const usersAreaPath = `${usersLinePath} L ${xFor(n - 1).toFixed(1)} ${(PAD_T + plotH).toFixed(1)} L ${xFor(0).toFixed(1)} ${(PAD_T + plotH).toFixed(1)} Z`; - // Area path: same poly-line, closed back to the baseline. - const areaPath = `${linePath} L ${xFor(n - 1).toFixed(1)} ${(PAD_T + plotH).toFixed(1)} L ${xFor(0).toFixed(1)} ${(PAD_T + plotH).toFixed(1)} Z`; + // Revenue line + area — used for the 1y range where individual bars + // would be unreadable. + const revLinePath = data.points + .map( + (p, i) => + `${i === 0 ? "M" : "L"} ${xFor(i).toFixed(1)} ${yRev(p.fees_usd).toFixed(1)}`, + ) + .join(" "); + const revAreaPath = `${revLinePath} L ${xFor(n - 1).toFixed(1)} ${(PAD_T + plotH).toFixed(1)} L ${xFor(0).toFixed(1)} ${(PAD_T + plotH).toFixed(1)} Z`; // Biggest day annotation. ALWAYS computed from the displayed series, // never from the server-rendered `biggestDayUnix` prop alone. The two @@ -222,37 +293,53 @@ function ChartCanvas({ "0 1px 0 rgba(0,0,0,0.02), 0 8px 24px -16px rgba(60,40,110,0.18)", }} > - <div className="flex items-center justify-between flex-wrap gap-2 mb-4"> + <div className="flex items-center justify-between flex-wrap gap-3 mb-4"> <div> <p className="label-mono text-[10px] text-ink-faint"> - Performance · last 30d + Performance · last {range} </p> <p className="text-sm text-ink-faint mt-0.5"> - Daily builder revenue and unique active users + {showUsersOverlay + ? "Daily builder revenue and unique active users" + : "Daily builder revenue and volume"} </p> </div> - <div className="flex items-center gap-4 text-[11px]"> - <span className="flex items-center gap-1.5"> - <span - className="inline-block w-3 h-3 rounded-[3px]" - style={{ - background: `linear-gradient(180deg, ${REVENUE_COLOR_TOP}, ${REVENUE_COLOR})`, - }} - /> - <span className="font-medium text-ink">Revenue</span> - </span> - <span className="flex items-center gap-1.5"> - <span - className="inline-block w-3 h-3 rounded-full border border-paper" - style={{ - background: USERS_COLOR, - boxShadow: `0 0 0 1.5px ${USERS_COLOR}33`, - }} - /> - <span className="font-medium text-ink">Users</span> - </span> + <div className="flex items-center gap-3 flex-wrap"> + <RangePicker + value={range} + onChange={onRangeChange} + loading={loading} + /> + <div className="flex items-center gap-4 text-[11px]"> + <span className="flex items-center gap-1.5"> + <span + className="inline-block w-3 h-3 rounded-[3px]" + style={{ + background: `linear-gradient(180deg, ${REVENUE_COLOR_TOP}, ${REVENUE_COLOR})`, + }} + /> + <span className="font-medium text-ink">Revenue</span> + </span> + {showUsersOverlay && ( + <span className="flex items-center gap-1.5"> + <span + className="inline-block w-3 h-3 rounded-full border border-paper" + style={{ + background: USERS_COLOR, + boxShadow: `0 0 0 1.5px ${USERS_COLOR}33`, + }} + /> + <span className="font-medium text-ink">Users</span> + </span> + )} + </div> </div> </div> + {hasError && ( + <p className="mb-2 text-[11px] text-ink-faint"> + Long-window archive unreachable; showing the last successful load. + </p> + )} <div className="relative w-full overflow-hidden"> <svg @@ -276,6 +363,10 @@ function ChartCanvas({ <stop offset="0%" stopColor={USERS_COLOR} stopOpacity={0.28} /> <stop offset="100%" stopColor={USERS_COLOR} stopOpacity={0} /> </linearGradient> + <linearGradient id="hl-rev-area" x1="0" x2="0" y1="0" y2="1"> + <stop offset="0%" stopColor={REVENUE_COLOR} stopOpacity={0.32} /> + <stop offset="100%" stopColor={REVENUE_COLOR} stopOpacity={0} /> + </linearGradient> </defs> {ticks.map((t) => { @@ -303,20 +394,24 @@ function ChartCanvas({ > {fmtUSDShort(valFees)} </text> - <text - x={W - PAD_R + 8} - y={y + 3} - textAnchor="start" - style={{ fontFamily: "var(--font-mono, monospace)" }} - className="fill-ink-faint text-[10px] tabular-nums" - > - {fmtCountShort(valUsers)} - </text> + {showUsersOverlay && ( + <text + x={W - PAD_R + 8} + y={y + 3} + textAnchor="start" + style={{ fontFamily: "var(--font-mono, monospace)" }} + className="fill-ink-faint text-[10px] tabular-nums" + > + {fmtCountShort(valUsers)} + </text> + )} </g> ); })} - <path d={areaPath} fill="url(#hl-users-area)" /> + {showUsersOverlay && ( + <path d={usersAreaPath} fill="url(#hl-users-area)" /> + )} {biggestIdx >= 0 && ( <g> @@ -347,55 +442,84 @@ function ChartCanvas({ </g> )} - {data.points.map((p, i) => { - const x = xFor(i) - barW / 2; - const y = yBar(p.fees_usd); - const h = PAD_T + plotH - y; - const isHovered = hover === i; - return ( - <rect - key={p.day_unix} - x={x} - y={y} - width={barW} - height={Math.max(1, h)} - rx={Math.min(3, barW / 2)} - ry={Math.min(3, barW / 2)} - fill={isHovered ? "url(#hl-rev-grad-hover)" : "url(#hl-rev-grad)"} - opacity={hover === null ? 1 : isHovered ? 1 : 0.55} - style={{ transition: "opacity 120ms ease-out" }} - /> - ); - })} - - <path - d={linePath} - fill="none" - stroke={USERS_COLOR} - strokeWidth={2.25} - strokeLinecap="round" - strokeLinejoin="round" - /> - {data.points.map((p, i) => { - const isHovered = hover === i; - return ( - <g key={p.day_unix}> - <circle - cx={xFor(i)} - cy={yLine(p.users)} - r={isHovered ? 5 : 3.2} - fill="var(--color-paper, #fff)" - opacity={hover === null || isHovered ? 1 : 0.7} + {!isLine && + data.points.map((p, i) => { + const x = xFor(i) - barW / 2; + const y = yBar(p.fees_usd); + const h = PAD_T + plotH - y; + const isHovered = hover === i; + return ( + <rect + key={p.day_unix} + x={x} + y={y} + width={barW} + height={Math.max(1, h)} + rx={Math.min(3, barW / 2)} + ry={Math.min(3, barW / 2)} + fill={isHovered ? "url(#hl-rev-grad-hover)" : "url(#hl-rev-grad)"} + opacity={hover === null ? 1 : isHovered ? 1 : 0.55} + style={{ transition: "opacity 120ms ease-out" }} /> + ); + })} + + {isLine && ( + <> + <path d={revAreaPath} fill="url(#hl-rev-area)" /> + <path + d={revLinePath} + fill="none" + stroke={REVENUE_COLOR} + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + /> + {hover !== null && ( <circle - cx={xFor(i)} - cy={yLine(p.users)} - r={isHovered ? 3.4 : 2} - fill={USERS_COLOR} + cx={xFor(hover)} + cy={yRev(data.points[hover].fees_usd)} + r={4} + fill={REVENUE_COLOR} + stroke="var(--color-paper, #fff)" + strokeWidth={2} /> - </g> - ); - })} + )} + </> + )} + + {showUsersOverlay && ( + <> + <path + d={usersLinePath} + fill="none" + stroke={USERS_COLOR} + strokeWidth={2.25} + strokeLinecap="round" + strokeLinejoin="round" + /> + {data.points.map((p, i) => { + const isHovered = hover === i; + return ( + <g key={p.day_unix}> + <circle + cx={xFor(i)} + cy={yLine(p.users)} + r={isHovered ? 5 : 3.2} + fill="var(--color-paper, #fff)" + opacity={hover === null || isHovered ? 1 : 0.7} + /> + <circle + cx={xFor(i)} + cy={yLine(p.users)} + r={isHovered ? 3.4 : 2} + fill={USERS_COLOR} + /> + </g> + ); + })} + </> + )} {hover !== null && ( <line @@ -431,24 +555,39 @@ function ChartCanvas({ <Tooltip point={data.points[hover]} isBiggest={hover === biggestIdx} + showUsers={showUsersOverlay} xFrac={xFor(hover) / W} - yFrac={Math.min(yBar(data.points[hover].fees_usd), yLine(data.points[hover].users)) / H} + yFrac={ + (showUsersOverlay + ? Math.min( + yBar(data.points[hover].fees_usd), + yLine(data.points[hover].users), + ) + : yBar(data.points[hover].fees_usd)) / H + } /> )} </div> <div className="mt-4 grid grid-cols-2 sm:grid-cols-4 gap-3 text-[12px]"> <Stat - label="Revenue 30d" + label={`Revenue ${range}`} value={fmtUSD(totalFees30d)} accent={REVENUE_COLOR} /> - <Stat label="Volume 30d" value={fmtUSD(totalVol30d)} /> - <Stat - label="Peak users / day" - value={fmtCountShort(peakUsers)} - accent={USERS_COLOR} - /> + <Stat label={`Volume ${range}`} value={fmtUSD(totalVol30d)} /> + {showUsersOverlay ? ( + <Stat + label="Peak users / day" + value={fmtCountShort(peakUsers)} + accent={USERS_COLOR} + /> + ) : ( + <Stat + label="Days covered" + value={String(n)} + /> + )} <Stat label="As of" value={new Date(data.as_of * 1000).toUTCString().slice(5, 25) + " UTC"} @@ -459,14 +598,57 @@ function ChartCanvas({ ); } +function RangePicker({ + value, + onChange, + loading, +}: { + value: Range; + onChange: (r: Range) => void; + loading: boolean; +}) { + return ( + <div + className="inline-flex items-center rounded-md border border-ink/15 bg-paper/60 p-0.5 text-[11px]" + role="tablist" + aria-label="Chart range" + > + {RANGE_OPTIONS.map((opt) => { + const active = opt.key === value; + return ( + <button + key={opt.key} + type="button" + role="tab" + aria-selected={active} + disabled={loading && !active} + onClick={() => onChange(opt.key)} + className={ + "px-2.5 py-1 rounded-[5px] font-medium tabular-nums transition-colors " + + (active + ? "bg-ink text-paper" + : "text-ink-faint hover:text-ink") + } + style={{ fontFamily: "var(--font-mono, monospace)" }} + > + {opt.label} + </button> + ); + })} + </div> + ); +} + function Tooltip({ point, isBiggest, + showUsers, xFrac, yFrac, }: { point: Point; isBiggest: boolean; + showUsers: boolean; xFrac: number; yFrac: number; }) { @@ -515,18 +697,20 @@ function Tooltip({ {fmtUSD(point.fees_usd)} </span> </div> - <div className="flex items-center justify-between gap-3 leading-tight mt-0.5"> - <span className="flex items-center gap-1.5"> - <span - className="inline-block w-2.5 h-2.5 rounded-full" - style={{ background: USERS_COLOR }} - /> - <span className="text-ink-faint">Users</span> - </span> - <span className="font-semibold tabular-nums"> - {fmtCountShort(point.users)} - </span> - </div> + {showUsers && ( + <div className="flex items-center justify-between gap-3 leading-tight mt-0.5"> + <span className="flex items-center gap-1.5"> + <span + className="inline-block w-2.5 h-2.5 rounded-full" + style={{ background: USERS_COLOR }} + /> + <span className="text-ink-faint">Users</span> + </span> + <span className="font-semibold tabular-nums"> + {fmtCountShort(point.users)} + </span> + </div> + )} <div className="flex items-center justify-between gap-3 leading-tight mt-0.5"> <span className="text-ink-faint">Volume</span> <span className="font-semibold tabular-nums"> diff --git a/src/components/hl-sparkline.tsx b/src/components/hl-sparkline.tsx new file mode 100644 index 000000000..543d2ecbd --- /dev/null +++ b/src/components/hl-sparkline.tsx @@ -0,0 +1,101 @@ +/** + * Compact log-scale sparkline shared by the /hyperliquid leaderboard row + * and any future card surface. Mirrors the Y transform used by the parent + * `HlHistoryChart` (`log10(v + 1)`) so a $200 frontend and a $5M frontend + * both render readable shapes at the small sizes we need in a table row. + * + * Null values break the SVG path so gaps render as gaps rather than + * dropping to zero — same semantic as the harness' missing-sample rule. + */ + +type Props = { + values: (number | null)[]; + /** Optional pixel width. Default 200 fits the leaderboard column. */ + width?: number; + /** Optional pixel height. Default 24 keeps rows compact. */ + height?: number; + /** Stroke colour. Default matches the primary sparkline accent. */ + stroke?: string; + /** Rendered when the series has no non-null value. Defaults to em-dash. */ + emptyLabel?: string; +}; + +export function HlSparkline({ + values, + width = 200, + height = 24, + stroke = "#9d65ff", + emptyLabel = "—", +}: Props) { + const PAD = 1.5; + const plotW = width - PAD * 2; + const plotH = height - PAD * 2; + + let vMax = 0; + let hasAny = false; + for (const v of values) { + if (v !== null && Number.isFinite(v)) { + hasAny = true; + if (v > vMax) vMax = v; + } + } + if (!hasAny) { + return ( + <span + className="inline-block text-[11px] text-ink-faint" + style={{ + width, + textAlign: "center", + fontFamily: "var(--font-mono, monospace)", + }} + > + {emptyLabel} + </span> + ); + } + + const logMax = Math.log10(vMax + 1); + const logDen = logMax || 1; + const n = values.length; + const xFor = (i: number) => + PAD + (n === 1 ? 0 : (i / (n - 1)) * plotW); + const yFor = (v: number) => { + const clamped = v > 0 ? v : 0; + const norm = Math.log10(clamped + 1) / logDen; + return PAD + plotH * (1 - norm); + }; + + const parts: string[] = []; + let inSeg = false; + for (let i = 0; i < n; i++) { + const v = values[i]; + if (v === null || !Number.isFinite(v)) { + inSeg = false; + continue; + } + const cmd = inSeg ? "L" : "M"; + parts.push(`${cmd} ${xFor(i).toFixed(1)} ${yFor(v).toFixed(1)}`); + inSeg = true; + } + + return ( + <svg + viewBox={`0 0 ${width} ${height}`} + width={width} + height={height} + preserveAspectRatio="none" + aria-hidden="true" + className="block" + > + <path + d={parts.join(" ")} + fill="none" + stroke={stroke} + strokeWidth={1.4} + strokeLinecap="round" + strokeLinejoin="round" + style={{ opacity: 0.9 }} + /> + </svg> + ); +} diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index 877993745..b86dae510 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import Link from "next/link"; +import { ChevronDown, ChevronUp } from "lucide-react"; import type { Benchmark, LedgerColumn, @@ -31,6 +32,27 @@ function isHexAddressSlug(slug: string): boolean { return HEX_ADDRESS_SLUG.test(slug.toLowerCase()); } +/** Sort keys exposed by the header click handlers. `null` (the default) + * means "use the bench's natural sort" — pickValue + higherIsBetter — + * which is what callers, OG renders, and share cards depend on. Any + * non-null key takes over and the comparator routes through + * pickSortValue instead. Custom-column slots are keyed by their index + * in benchmark.ledgerColumns to keep the type narrow without smuggling + * the full column object through state. */ +type SortKey = + | "name" + | "p50" + | "p90" + | "p99" + | "mean" + | "value" + | "delta" + | "success" + | "slot_p50" + | `col_${number}`; + +type SortDir = "asc" | "desc"; + type Props = { benchmark: Benchmark; /** When the bench page has a companion-panel tab selected on the @@ -71,6 +93,11 @@ export function LedgerTable({ const unit = activePanel?.unit ?? benchmark.unit; const higherIsBetter = activePanel?.higherIsBetter ?? benchmark.higherIsBetter; const panelActive = !!activePanel; + // Cost/percentage/count benches don't have a distribution to summarise — + // p50/p90/p99 collapse to the same number and the header "Latency + // aggregates" reads as a template bug on a USD leaderboard. + const isLatencyUnit = unit === "ms" || unit === "s" || unit === "sec" || unit === "slots"; + const singleValueColumn = !isLatencyUnit && !panelActive; // Custom column mode: benches that repurpose the p50/p90/p99/mean slots // (USD revenue leaderboards) declare ledger_columns in their YAML so // every column carries an honest label + unit, and panel-backed columns @@ -96,6 +123,14 @@ export function LedgerTable({ col.unit ?? (col.panel ? (panelById.get(col.panel)?.unit ?? unit) : unit); + // Clickable column sort. Defaults to null so the natural per-bench + // ordering (pickValue + higherIsBetter) keeps driving the table — this + // is the order OG images, share cards, and snapshot tests all expect. + // A non-null sortKey takes over and routes the comparator through + // pickSortValue with the chosen direction. + const [sortKey, setSortKey] = useState<SortKey | null>(null); + const [sortDir, setSortDir] = useState<SortDir>("desc"); + // Timeframe toggle. Columns that declare `windows` (7d/30d panel-id // sources) flip to the selected window's values; columns without keep // their 24h figure and the header says so. Rendered only when at least @@ -151,6 +186,59 @@ export function LedgerTable({ // 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. + // Field-mean Δ — duplicated here so the sort comparator can rank by it + // without waiting on per-Row computation. Kept aligned with the same + // formula the Row uses for the displayed Δ% (see deltaPct below). + const fieldMeanRaw = + results.reduce((s, r) => s + pickValue(r), 0) / + Math.max(1, results.length); + const deltaForSort = (r: ProviderResult): number => { + if (fieldMeanRaw === 0) return 0; + return (pickValue(r) - fieldMeanRaw) / fieldMeanRaw; + }; + + // Map a sort key to a comparable scalar (or string for "name"). Numbers + // missing for a row sort to -Infinity / +Infinity depending on direction + // so empty cells consistently land at the bottom. + const pickSortValue = ( + r: ProviderResult, + k: SortKey, + ): number | string => { + if (k === "name") return r.name.toLowerCase(); + if (k === "value") { + const v = activePanel?.values[r.slug]; + return v != null && Number.isFinite(v) ? v : -Infinity; + } + if (k === "p50") return r.ms.p50; + if (k === "p90") return r.ms.p90; + if (k === "p99") return r.ms.p99; + if (k === "mean") return r.ms.mean; + if (k === "delta") return deltaForSort(r); + if (k === "success") return r.successRate ?? 0; + if (k === "slot_p50") return r.slots?.p50 ?? Infinity; + if (k.startsWith("col_")) { + const idx = Number(k.slice(4)); + const col = customCols?.[idx]; + if (!col) return -Infinity; + const v = colValueW(r, col); + return v != null ? v : -Infinity; + } + return 0; + }; + + // Toggle direction on the active header, otherwise switch to the new + // header and default to descending. Descending is the more natural + // first-click read for nearly every column (largest revenue, slowest + // latency, highest success) — for ascending the reader clicks twice. + const handleHeaderClick = (k: SortKey) => { + if (sortKey === k) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(k); + setSortDir("desc"); + } + }; + const sortedAll = [...results] .filter((r) => { // Sample-health gate. Rows tagged "insufficient" by the load path @@ -170,9 +258,23 @@ export function LedgerTable({ return r.ms.p50 !== 0 || r.ms.p90 !== 0 || r.ms.p99 !== 0; }) .sort((a, b) => { - const av = pickValue(a); - const bv = pickValue(b); - return higherIsBetter ? bv - av : av - bv; + // Default branch: preserve the EXACT comparator that shipped before + // sortable columns. OG renders, share cards, and snapshot tests all + // pin against this ordering — diverging here breaks them silently. + if (sortKey == null) { + const av = pickValue(a); + const bv = pickValue(b); + return higherIsBetter ? bv - av : av - bv; + } + const av = pickSortValue(a, sortKey); + const bv = pickSortValue(b, sortKey); + const dir = sortDir === "asc" ? 1 : -1; + if (typeof av === "string" && typeof bv === "string") { + return av.localeCompare(bv) * dir; + } + const na = typeof av === "number" ? av : 0; + const nb = typeof bv === "number" ? bv : 0; + return (na - nb) * dir; }); const sorted = topN == null ? sortedAll : sortedAll.slice(0, topN); const colors = useMemo(() => buildProviderColors(results), [results]); @@ -226,14 +328,22 @@ export function LedgerTable({ Product </th> <th - colSpan={customCols ? customCols.length + 1 : activePanel ? 2 : 5} + colSpan={ + customCols + ? customCols.length + 1 + : activePanel || singleValueColumn + ? 2 + : 5 + } className="border-y-2 border-ink py-2 px-3 text-center hidden md:table-cell" > {customCols ? benchmark.metric : activePanel ? activePanel.label - : "Latency aggregates"} + : singleValueColumn + ? benchmark.metric + : "Latency aggregates"} </th> <th className="border-y-2 border-ink py-2 px-3 text-right md:hidden"> {customCols ? colLabel(customCols[0]) : activePanel ? "Value" : "p50"} @@ -259,39 +369,131 @@ export function LedgerTable({ <tr> <th className="py-2 pr-2 text-left w-2"></th> <th className="py-2 pr-3 text-left w-10">№</th> - <th className="py-2 pr-3 text-left">Name</th> + <SortableHeader + sortKey="name" + activeKey={sortKey} + dir={sortDir} + onClick={handleHeaderClick} + align="left" + className="py-2 pr-3" + > + Name + </SortableHeader> {customCols ? ( customCols.map((c, idx) => ( - <th + <SortableHeader key={c.label} - className={`py-2 px-3 text-right ${idx === 0 ? "" : "hidden md:table-cell"}`} + sortKey={`col_${idx}` as SortKey} + activeKey={sortKey} + dir={sortDir} + onClick={handleHeaderClick} + align="right" + className={`py-2 px-3 ${idx === 0 ? "" : "hidden md:table-cell"}`} > {colLabel(c)} - </th> + </SortableHeader> )) - ) : panelActive ? ( + ) : panelActive || singleValueColumn ? ( // Panel sort owns the table: a single honest "Value" column // instead of p50/p90/p99/Mean headers over dashed-out cells // (a USD volume sort labeled "p50" reads as a bug). - <th className="py-2 px-3 text-right">Value</th> + <SortableHeader + sortKey="value" + activeKey={sortKey} + dir={sortDir} + onClick={handleHeaderClick} + align="right" + className="py-2 px-3" + > + Value + </SortableHeader> ) : ( <> - <th className="py-2 px-3 text-right">p50</th> - <th className="py-2 px-3 text-right hidden md:table-cell">p90</th> - <th className="py-2 px-3 text-right hidden md:table-cell">p99</th> - <th className="py-2 px-3 text-right hidden md:table-cell">Mean</th> + <SortableHeader + sortKey="p50" + activeKey={sortKey} + dir={sortDir} + onClick={handleHeaderClick} + align="right" + className="py-2 px-3" + > + p50 + </SortableHeader> + <SortableHeader + sortKey="p90" + activeKey={sortKey} + dir={sortDir} + onClick={handleHeaderClick} + align="right" + className="py-2 px-3 hidden md:table-cell" + > + p90 + </SortableHeader> + <SortableHeader + sortKey="p99" + activeKey={sortKey} + dir={sortDir} + onClick={handleHeaderClick} + align="right" + className="py-2 px-3 hidden md:table-cell" + > + p99 + </SortableHeader> + <SortableHeader + sortKey="mean" + activeKey={sortKey} + dir={sortDir} + onClick={handleHeaderClick} + align="right" + className="py-2 px-3 hidden md:table-cell" + > + Mean + </SortableHeader> </> )} - <th className="py-2 px-3 text-right hidden md:table-cell">Δ field</th> - <th className="py-2 px-3 text-right hidden md:table-cell">Success</th> + <SortableHeader + sortKey="delta" + activeKey={sortKey} + dir={sortDir} + onClick={handleHeaderClick} + align="right" + className="py-2 px-3 hidden md:table-cell" + > + Δ field + </SortableHeader> + <SortableHeader + sortKey="success" + activeKey={sortKey} + dir={sortDir} + onClick={handleHeaderClick} + align="right" + className="py-2 px-3 hidden md:table-cell" + > + Success + </SortableHeader> <th className="py-2 pl-3 text-right">24h</th> - {hasSlots && <th className="py-2 pl-3 text-right hidden md:table-cell">p50 / p99</th>} + {hasSlots && ( + <SortableHeader + sortKey="slot_p50" + activeKey={sortKey} + dir={sortDir} + onClick={handleHeaderClick} + align="right" + className="py-2 pl-3 hidden md:table-cell" + > + p50 / p99 + </SortableHeader> + )} {secondary && <th className="py-2 pl-3 text-right hidden md:table-cell">Value</th>} </tr> <tr className="border-b border-ink"> <th colSpan={ - (customCols ? 6 + customCols.length : panelActive ? 7 : 10) + + (customCols + ? 6 + customCols.length + : panelActive || singleValueColumn + ? 7 + : 10) + (hasSlots ? 1 : 0) + (secondary ? 1 : 0) } @@ -310,6 +512,7 @@ export function LedgerTable({ fieldValue={fieldValue} maxValue={maxValue} panelActive={panelActive} + singleValueColumn={singleValueColumn} hasSecondary={!!secondary} hasSlots={hasSlots} customCells={customCols?.map((c) => ({ @@ -344,6 +547,7 @@ function Row({ fieldValue, maxValue, panelActive, + singleValueColumn, hasSecondary, hasSlots, customCells, @@ -363,6 +567,7 @@ function Row({ fieldValue: number; maxValue: number; panelActive: boolean; + singleValueColumn: boolean; hasSecondary: boolean; hasSlots: boolean; /** Custom-column mode (benchmark.ledgerColumns): one pre-resolved @@ -544,7 +749,7 @@ function Row({ {c.v != null ? fmtUnit(c.v, c.unit) : "-"} </td> )) - ) : panelActive ? null : ( + ) : panelActive || singleValueColumn ? null : ( <> <td className="py-2.5 px-3 text-right text-ink-soft whitespace-nowrap hidden md:table-cell"> {fmtUnit(r.ms.p90, unit)} @@ -593,3 +798,73 @@ function Row({ </tr> ); } + +/** + * Sortable column header. Renders a button-styled `<th>` that toggles + * the table's sort key on click and shows an up/down chevron next to + * the label when this header is the active sort. Hover lifts the label + * from ink-muted to ink to telegraph that it's clickable; the chevron + * itself stays ink-muted so it reads as a marker, not as emphasis. + * + * The button is the inner element (not the `<th>` itself) so the + * existing `<th>` className keeps controlling layout / responsive + * visibility, and the click target is a real focusable button. + */ +function SortableHeader({ + sortKey, + activeKey, + dir, + onClick, + align, + className, + children, +}: { + sortKey: SortKey; + activeKey: SortKey | null; + dir: SortDir; + onClick: (k: SortKey) => void; + align: "left" | "right"; + className?: string; + children: React.ReactNode; +}) { + const isActive = activeKey === sortKey; + const alignClass = align === "right" ? "text-right" : "text-left"; + const justifyClass = align === "right" ? "justify-end" : "justify-start"; + return ( + <th + className={[alignClass, className].filter(Boolean).join(" ")} + aria-sort={ + isActive ? (dir === "asc" ? "ascending" : "descending") : "none" + } + > + <button + type="button" + onClick={() => onClick(sortKey)} + className={[ + "group inline-flex items-center gap-1 cursor-pointer transition-colors", + justifyClass, + isActive ? "text-ink" : "text-ink-muted hover:text-ink", + ].join(" ")} + > + <span>{children}</span> + {isActive ? ( + dir === "asc" ? ( + <ChevronUp + size={12} + strokeWidth={2.5} + className="text-ink-muted" + aria-hidden + /> + ) : ( + <ChevronDown + size={12} + strokeWidth={2.5} + className="text-ink-muted" + aria-hidden + /> + ) + ) : null} + </button> + </th> + ); +} diff --git a/src/components/time-series-chart/index.tsx b/src/components/time-series-chart/index.tsx index aef425ea4..1e9413056 100644 --- a/src/components/time-series-chart/index.tsx +++ b/src/components/time-series-chart/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Globe } from "lucide-react"; import type { Benchmark } from "@/types/benchmark"; import { brandColor } from "@/lib/brand"; @@ -11,6 +11,7 @@ import { LiveDot } from "@/components/live-dot"; import { TopNSelector } from "@/components/top-n-selector"; import { RANGES, + LONG_RANGES, RANGE_HOURS, RANGE_EXPECTED_POINTS, RANGE_LABEL, @@ -56,6 +57,24 @@ type Props = { higherIsBetterOverride?: boolean; topNControl?: { topN: number | null; setTopN: (n: number | null) => void }; disableTopN?: boolean; + /** Externally-controlled range. When provided, the chart relinquishes + * its internal `range` state and lets the parent drive it (so a sister + * component below — e.g. a leaderboard table — can stay in sync with + * the pill selection). */ + range?: Range; + onRangeChange?: (r: Range) => void; + /** Optional long-range tabs (90d / 180d / 1y / all). When set, the chart + * renders these pills after the live ones AND reads per-provider series + * from this map when the selected range is one of them. Each entry's + * value array is plotted as-is — typically one sample per day from the + * archive backend. Missing entries render an empty line. */ + longRangeSeries?: Partial<Record<Range, Record<string, number[]>>>; + /** When true, the long-range pills render in a disabled state with a + * "soon" hint. Used when the archive is unreachable. */ + longRangeDisabled?: boolean; + /** Tooltip surfaced on disabled long-range pills (e.g. "Archive + * temporarily unavailable"). */ + longRangeDisabledTitle?: string; }; export function TimeSeriesChart({ @@ -73,8 +92,19 @@ export function TimeSeriesChart({ topNControl, disableTopN, headerActions, + range: rangeProp, + onRangeChange, + longRangeSeries, + longRangeDisabled, + longRangeDisabledTitle, }: Props) { - const [range, setRange] = useState<Range>("24h"); + const [rangeLocal, setRangeLocal] = useState<Range>("24h"); + const range = rangeProp ?? rangeLocal; + const setRange = (next: Range) => { + if (onRangeChange) onRangeChange(next); + if (rangeProp == null) setRangeLocal(next); + }; + const isLongRange = (LONG_RANGES as readonly Range[]).includes(range); const [regionLocal, setRegionLocal] = useState<string>("all"); const region = regionProp ?? regionLocal; const setRegion = regionProp != null ? () => {} : setRegionLocal; @@ -95,13 +125,87 @@ export function TimeSeriesChart({ setZoom(null); } - const has7d = - !!benchmark.extras.series7d && - Object.keys(benchmark.extras.series7d).length > 0; + // 7d / 30d series are no longer included in the server-rendered + // Benchmark — they were pushing heavy benches past unstable_cache's + // 2 MB ceiling, silently breaking the cache and forcing every render + // to re-query Prom. They are now lazy-fetched via /api/series on the + // first 7d or 30d tab click, then cached in component state for the + // rest of the session. CDN cache-control on /api/series (60 s + // s-maxage + 300 s SWR) absorbs concurrent visitors so Prom sees at + // most one fan-out per (bench, range) per minute. + const [lazySeries7d, setLazySeries7d] = useState<Record<string, number[]> | null>(null); + const [lazySeries30d, setLazySeries30d] = useState<Record<string, number[]> | null>(null); - const has30d = - !!benchmark.extras.series30d && - Object.keys(benchmark.extras.series30d).length > 0; + // Pre-fetch 7d AND 30d in the background as soon as the chart mounts, + // not just when the user clicks the tab. The fetches are non-blocking + // (24h renders synchronously from props) and the CDN dedupes + // concurrent visitors (60 s s-maxage + 300 s SWR on /api/series), so + // by the time the reader actually flips to a longer range the data is + // already in component state. Removes the 5–15 s cold-fetch lag that + // made the first tab click feel broken. + useEffect(() => { + let cancelled = false; + // Tracks per-range whether we've already landed data OR exhausted retries. + // The previous version put `lazySeries7d` in the effect's deps and used + // `if (lazySeries7d) return` as the skip guard, so an empty `{}` written + // on the first failed fetch was treated as "already fetched" forever — + // the chart stayed empty for the whole session even after the upstream + // recovered. Trigger: Prom returned 503 for ~30 min during a TSDB + // backfill; every chart that mounted in that window cached `{}` and + // never refetched. + const done: Record<"7d" | "30d", boolean> = { "7d": false, "30d": false }; + const buildQs = (range: "7d" | "30d") => { + const qs = new URLSearchParams({ range }); + if (regionProp && regionProp !== "all") qs.set("region", regionProp); + return qs.toString(); + }; + const fetchOne = (range: "7d" | "30d", attempt = 0) => { + if (cancelled || done[range]) return; + fetch(`/api/series/${benchmark.slug}?${buildQs(range)}`) + .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) + .then((data: { providers: { slug: string; values: number[] }[] }) => { + if (cancelled) return; + done[range] = true; + const map: Record<string, number[]> = {}; + for (const p of data.providers) map[p.slug] = p.values; + if (range === "7d") setLazySeries7d(map); + else setLazySeries30d(map); + }) + .catch(() => { + if (cancelled) return; + // 2s → 4s backoff, ~6s total window. Covers a Prom hiccup, + // an in-flight ISR regen, or a Vercel cold-start lag without + // bothering the user. Past that we commit to the empty + // placeholder so the chart doesn't spin forever. + if (attempt < 2) { + setTimeout(() => fetchOne(range, attempt + 1), (attempt + 1) * 2000); + } else { + done[range] = true; + if (range === "7d") setLazySeries7d({}); + else setLazySeries30d({}); + } + }); + }; + fetchOne("7d"); + fetchOne("30d"); + return () => { + cancelled = true; + }; + }, [regionProp, benchmark.slug]); + + // Tab availability: 24h is always present (served from the cached + // Benchmark), 7d / 30d are always offered as tabs since they're + // lazy-fetchable. The fetch resolves to {} on no-data, which we still + // treat as "tab available" because the chart can render an empty + // state inline rather than hide the tab. + // + // Exception: when a metric panel is active (seriesOverride set), the + // panel's own 7d / 30d series are no longer cached either, so 7d / + // 30d tabs would show 24h data sliced wrong. Disable them in that + // case — readers must deactivate the panel to see longer ranges. + const panelActive = !!seriesOverride; + const has7d = !panelActive || !!seriesOverride7d; + const has30d = !panelActive || !!seriesOverride30d; const availableRegions = useMemo(() => { const set = new Set<string>(); @@ -145,6 +249,20 @@ export function TimeSeriesChart({ const take = Math.max(2, Math.round(full.length * ratio)); return full.slice(-take); }; + // Bench-level 7d / 30d series are lazy-loaded (see useEffect above). + // Long-range series (90d / 180d / 1y / all) come from an external + // map populated by the parent — typically by fetching the + // long-window archive for the bench. + // Pick from the lazy map when in those ranges, fall back to + // pickSeries (which uses benchmark.extras.series24h) otherwise. + const pickBenchValues = (slug: string): number[] => { + if (isLongRange) { + return longRangeSeries?.[range]?.[slug] ?? []; + } + if (range === "7d" && lazySeries7d) return lazySeries7d[slug] ?? []; + if (range === "30d" && lazySeries30d) return lazySeries30d[slug] ?? []; + return pickSeries(benchmark, slug, range, region); + }; const built = benchmark.results .map((r) => ({ slug: r.slug, @@ -152,7 +270,7 @@ export function TimeSeriesChart({ color: colors.get(r.slug) ?? "var(--color-ink-soft)", values: panel ? sliceOverride(panel[r.slug] ?? []) - : pickSeries(benchmark, r.slug, range, region), + : pickBenchValues(r.slug), excluded: excluded.has(r.slug), })) .filter((l) => l.values.length > 0); @@ -169,7 +287,7 @@ export function TimeSeriesChart({ return higherIsBetter ? bv - av : av - bv; }); return built; - }, [benchmark, range, region, colors, excluded, seriesOverride, seriesOverride7d, seriesOverride30d, higherIsBetterOverride]); + }, [benchmark, range, region, colors, excluded, seriesOverride, seriesOverride7d, seriesOverride30d, higherIsBetterOverride, lazySeries7d, lazySeries30d, isLongRange, longRangeSeries]); // Top-N selector — sized off the post-filter line count via the // shared `useTopN` hook so the option set agrees across every @@ -241,7 +359,7 @@ export function TimeSeriesChart({ </div> </div> <div className="mb-4 flex flex-wrap items-center justify-between gap-3"> - <div className="flex items-center gap-1"> + <div className="flex flex-wrap items-center gap-1"> {RANGES.map((r) => { const active = r === range; const disabled = @@ -269,6 +387,51 @@ export function TimeSeriesChart({ </button> ); })} + {longRangeSeries && ( + <> + {/* Hairline separator between live (Prom) and archive + ranges so the reader sees they come from a different + data source — kept inside the same pill strip so the + selection feels like one control. Panel tabs disable + the archive ranges (panel data is not archived). */} + <span + aria-hidden + className="mx-1 inline-block h-4 w-px bg-rule" + /> + {LONG_RANGES.map((r) => { + const active = r === range; + const disabled = longRangeDisabled || panelActive; + const title = panelActive + ? "Switch off the metric panel to see long-range history" + : longRangeDisabled + ? (longRangeDisabledTitle ?? + "Archive temporarily unavailable") + : undefined; + return ( + <button + key={r} + type="button" + onClick={() => !disabled && setRange(r)} + disabled={disabled} + className={[ + "rounded px-2.5 py-1 text-[11px] font-sans tabular uppercase tracking-[0.1em] font-medium transition-colors", + active + ? "bg-ink text-paper" + : disabled + ? "text-ink-faint cursor-not-allowed" + : "text-ink-muted hover:text-ink hover:bg-paper-soft", + ].join(" ")} + title={title} + > + {r} + {disabled && !panelActive && ( + <span className="ml-1 text-[9px] text-ink-faint">soon</span> + )} + </button> + ); + })} + </> + )} </div> {showRegionTabs && ( diff --git a/src/components/time-series-chart/scales.ts b/src/components/time-series-chart/scales.ts index c27c53f6d..025476de3 100644 --- a/src/components/time-series-chart/scales.ts +++ b/src/components/time-series-chart/scales.ts @@ -2,16 +2,39 @@ import type { Benchmark } from "@/types/benchmark"; import { isAll } from "@/lib/dimensions"; import { SECONDS_PER_DAY, SECONDS_PER_HOUR } from "@/lib/time-constants"; -export type Range = "1h" | "6h" | "24h" | "7d" | "30d"; +export type Range = + | "1h" + | "6h" + | "24h" + | "7d" + | "30d" + | "90d" + | "180d" + | "1y" + | "all"; export const RANGES: Range[] = ["1h", "6h", "24h", "7d", "30d"]; +/** Long-range tabs that live behind a separate (archive-backed) data + * source. Only rendered on benches that opt-in by passing + * `longRangeSeries` to the chart. */ +export const LONG_RANGES: Range[] = ["90d", "180d", "1y", "all"]; + export const RANGE_HOURS: Record<Range, number> = { "1h": 1, "6h": 6, "24h": 24, "7d": 168, "30d": 720, + "90d": 2160, + "180d": 4320, + "1y": 8760, + // ALL is rendered with whatever data the archive returned. The chart + // sizes the X-axis off the longest series in `longRangeSeries`, but + // RANGE_HOURS is still referenced for label formatting / zoom math, + // so we ceiling it to ~3 years which is more than the current + // archive retention and avoids divide-by-zero down the path. + all: 26280, }; // How many points spec.ts (src/lib/spec.ts:prom.series) requests for each @@ -20,12 +43,21 @@ export const RANGE_HOURS: Record<Range, number> = { // provider went offline mid-window), we anchor the rightmost point to // "now" and step earlier points backwards by 1/EXPECTED of the chart // width. The unfilled left side is honest, not stretched. +// +// Long-range tabs (90d / 180d / 1y / all) are sourced from per-day +// archive aggregates, so the expected point count equals the number of +// days in the window. ALL uses a generous ceiling that never bites in +// practice because the chart re-computes off the actual series length. export const RANGE_EXPECTED_POINTS: Record<Range, number> = { "1h": 3, "6h": 18, "24h": 72, "7d": 84, "30d": 60, + "90d": 90, + "180d": 180, + "1y": 365, + all: 1095, }; export const RANGE_LABEL: Record<Range, string> = { @@ -34,6 +66,10 @@ export const RANGE_LABEL: Record<Range, string> = { "24h": "last 24 hours", "7d": "last 7 days", "30d": "last 30 days", + "90d": "last 90 days", + "180d": "last 180 days", + "1y": "last year", + all: "all time", }; export const REGION_LABEL: Record<string, string> = { diff --git a/src/lib/chain-kpis.ts b/src/lib/chain-kpis.ts index aaf55427a..aa5960f39 100644 --- a/src/lib/chain-kpis.ts +++ b/src/lib/chain-kpis.ts @@ -15,7 +15,12 @@ * encoded as every field null (the page hides the strip entirely). */ +import { unstable_cache } from "next/cache"; import { Prometheus } from "@/lib/prometheus"; +import { + readCohortSnapshot, + writeCohortSnapshot, +} from "@/lib/cohort-snapshot"; export type ChainKpis = { slug: string; @@ -41,6 +46,12 @@ function promUrl(): string | null { return process.env.PROMETHEUS_URL?.trim() || null; } +/** Upstash key prefix for the per-chain KPI blobs, written by the + * materialize worker on every tierA sweep. One blob per chain slug keeps + * the payload tiny and makes a stale entry on one chain independent from + * the others. The cohort-snapshot module appends its own `:v1`. */ +const CHAIN_KPIS_KEY_PREFIX = "chain-kpis:"; + /** * Fetch the 6 chain KPI gauges in one Promise.all. Each is independent; * a failure on one query doesn't drop the others (graceful per-card @@ -49,8 +60,14 @@ function promUrl(): string | null { * Returns null when Prom isn't configured at all (preview / dev without * the env var) so the caller can render an explicit "Live KPIs require * Prom" banner instead of a strip full of em-dashes. + * + * Exported so the materialize worker can call the uncached Prom path + * directly before parking the result in Upstash; the public reader + * (fetchChainKpis) goes through the snapshot layer + unstable_cache below. */ -export async function fetchChainKpis(slug: string): Promise<ChainKpis | null> { +export async function fetchChainKpisFresh( + slug: string, +): Promise<ChainKpis | null> { const url = promUrl(); if (!url) return null; let prom: Prometheus; @@ -80,6 +97,43 @@ export async function fetchChainKpis(slug: string): Promise<ChainKpis | null> { }; } +/** + * Snapshot-first reader for a single chain's KPI blob. Same protocol as + * the hub cohorts: Upstash blob → live Prom + writeback → null. The + * worker keeps the blob fresh every 60 s so the typical render never + * touches Prom; on Vercel prod, Prom isn't even configured, so the + * writeback branch never runs there and null just falls through. + */ +async function fetchChainKpisRaw(slug: string): Promise<ChainKpis | null> { + const snapshot = await readCohortSnapshot<ChainKpis>( + CHAIN_KPIS_KEY_PREFIX + slug, + ); + if (snapshot) return snapshot.data; + const fresh = await fetchChainKpisFresh(slug); + if (fresh) { + try { + await writeCohortSnapshot(CHAIN_KPIS_KEY_PREFIX + slug, fresh); + } catch (err) { + console.warn( + `chain-kpis writeback failed for ${slug}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + return fresh; +} + +const fetchChainKpisCached = unstable_cache( + fetchChainKpisRaw, + ["chain-kpis-v1"], + { revalidate: 60, tags: ["chain-kpis"] }, +); + +export async function fetchChainKpis(slug: string): Promise<ChainKpis | null> { + return fetchChainKpisCached(slug); +} + /** * Convenience used by `<ChainKpiStrip>` to short-circuit when every * field is null — the strip hides entirely rather than rendering an diff --git a/src/lib/compare-cache.ts b/src/lib/compare-cache.ts index 44f48ea63..618570153 100644 --- a/src/lib/compare-cache.ts +++ b/src/lib/compare-cache.ts @@ -151,12 +151,18 @@ export async function readPairCache<T>( } const t0 = Date.now(); try { + // `cache: "no-store"` triggers Next.js "dynamic server usage" bail on + // static/ISR renders, which crashed every non-curated compare page + // (see PR #810). Instead, opt into Next.js's fetch cache with a 60s + // window: the KV itself has a 15-minute TTL and the inputsHash is + // re-checked on every read, so a 60s cross-render fetch cache is + // both safe (hash mismatch = miss) and useful (dedups burst reads). const res = await fetch( `${kvUrl()}/get/${encodeURIComponent(KEY_PREFIX + pairSlug)}`, { method: "GET", headers: authHeader(), - cache: "no-store", + next: { revalidate: 60 }, signal: AbortSignal.timeout(2_000), }, ); @@ -220,6 +226,10 @@ export function writePairCache( }); const bodyBytes = body.length; // Upstash REST: POST /set/{key}?EX={seconds} writes with TTL. + // No `cache: "no-store"` — same reason as the read path above: + // triggers a dynamic bail during ISR renders. POST responses are + // never cached by Next.js's fetch cache anyway, so omitting the + // option is a no-op semantically. const res = await fetch( `${kvUrl()}/set/${encodeURIComponent( KEY_PREFIX + pairSlug, @@ -228,7 +238,6 @@ export function writePairCache( method: "POST", headers: { ...authHeader(), "Content-Type": "application/json" }, body, - cache: "no-store", }, ); const ms = Date.now() - t0; diff --git a/src/lib/hl-archive-store.ts b/src/lib/hl-archive-store.ts new file mode 100644 index 000000000..f8c45efd6 --- /dev/null +++ b/src/lib/hl-archive-store.ts @@ -0,0 +1,178 @@ +/** + * Reader for the Hyperliquid long-window archive blob. + * + * Reads directly from the hl-archive Go service on Railway via its + * public HTTPS API. We used to go through Upstash, but the shared + * free-tier KV hit its 500k/day request cap and pushes silently + * failed; the Railway service already exposes /v1/aggregates with + * the exact payload shape and lives on the same fly DuckDB, so a + * direct read removes one moving part. The Next.js `unstable_cache` + * wrapper still keeps the per-region request load to one call per + * 60s, which is what made the Upstash hop interesting in the first + * place. + * + * Failure protocol: every error path returns null. The bench page + * and the history API fall back to the live Prom snapshot for short + * windows and show a "backfill pending" affordance for long windows. + * We never surface a network error to the renderer. + */ + +import { unstable_cache } from "next/cache"; +import type { + ArchiveSnapshot, + HlArchiveBuilder, + HlArchiveDailyPoint, + HlArchiveWindow, + HlArchiveWindowTotals, +} from "@/types/hl-archive"; +import { HL_ARCHIVE_WINDOWS } from "@/types/hl-archive"; + +const URL_ENV = ["HL_ARCHIVE_API_URL"] as const; +const KEY_ENV = ["HL_ARCHIVE_API_KEY"] as const; + +// Kept as the cache key suffix (was the Upstash key name). The constant +// is exported because the history API route uses it as the +// `unstable_cache` tag bump anchor when the upstream shape changes. +export const HL_ARCHIVE_KEY = "ocb:hl-archive:v1"; + +function creds(): { url: string; key: string } | null { + const url = URL_ENV.map((k) => process.env[k]?.trim()).find(Boolean); + const key = KEY_ENV.map((k) => process.env[k]?.trim()).find(Boolean); + return url && key ? { url: url.replace(/\/+$/, ""), key } : null; +} + +async function fetchAggregates(timeoutMs = 5_000): Promise<string | null> { + const c = creds(); + if (!c) return null; + const res = await fetch(`${c.url}/v1/aggregates?window=all`, { + method: "GET", + headers: { "X-API-Key": c.key }, + signal: AbortSignal.timeout(timeoutMs), + cache: "no-store", + }); + if (!res.ok) { + throw new Error(`hl-archive-store: http ${res.status}`); + } + return await res.text(); +} + +function isNumber(v: unknown): v is number { + return typeof v === "number" && Number.isFinite(v); +} + +function parseTotals(raw: unknown): HlArchiveWindowTotals | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record<string, unknown>; + if (!isNumber(r.volume_usd) || !isNumber(r.fees_usd) || !isNumber(r.fills)) { + return null; + } + const out: HlArchiveWindowTotals = { + volume_usd: r.volume_usd, + fees_usd: r.fees_usd, + fills: r.fills, + }; + if (isNumber(r.users)) out.users = r.users; + return out; +} + +function parseDailyPoint(raw: unknown): HlArchiveDailyPoint | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record<string, unknown>; + if (typeof r.day !== "string") return null; + if (!isNumber(r.vol) || !isNumber(r.fees) || !isNumber(r.fills)) return null; + const out: HlArchiveDailyPoint = { + day: r.day, + vol: r.vol, + fees: r.fees, + fills: r.fills, + }; + if (isNumber(r.users)) out.users = r.users; + return out; +} + +function parseBuilder(raw: unknown): HlArchiveBuilder | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record<string, unknown>; + if (typeof r.name !== "string") return null; + const slug = typeof r.slug === "string" ? r.slug : ""; + const windowsRaw = r.windows; + if (!windowsRaw || typeof windowsRaw !== "object") return null; + const windows: Partial<Record<HlArchiveWindow, HlArchiveWindowTotals>> = {}; + for (const w of HL_ARCHIVE_WINDOWS) { + const totals = parseTotals( + (windowsRaw as Record<string, unknown>)[w], + ); + if (totals) windows[w] = totals; + } + let timeseries_daily: HlArchiveDailyPoint[] | undefined; + if (Array.isArray(r.timeseries_daily)) { + timeseries_daily = r.timeseries_daily + .map(parseDailyPoint) + .filter((p): p is HlArchiveDailyPoint => p !== null); + } + return { slug, name: r.name, windows, timeseries_daily }; +} + +function parseSnapshot(raw: string): ArchiveSnapshot | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const r = parsed as Record<string, unknown>; + if (typeof r.updated_at !== "string") return null; + if (!r.builders || typeof r.builders !== "object") return null; + const builders: Record<string, HlArchiveBuilder> = {}; + for (const [addr, b] of Object.entries(r.builders as Record<string, unknown>)) { + const parsedB = parseBuilder(b); + if (parsedB) builders[addr] = parsedB; + } + return { updated_at: r.updated_at, builders }; +} + +async function getArchiveRaw(): Promise<ArchiveSnapshot | null> { + try { + const raw = await fetchAggregates(); + if (!raw) return null; + return parseSnapshot(raw); + } catch (err) { + console.warn( + `hl-archive read failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } +} + +// Bump the key when the wire shape changes (e.g. new `users` field on +// TimePoint/WindowAgg) so stale in-memory Next.js caches don't keep +// serving snapshots with missing fields after a rollout. +const getArchiveCached = unstable_cache( + getArchiveRaw, + ["hl-archive-v2-users"], + { revalidate: 60, tags: ["hl-archive"] }, +); + +export async function getArchive(): Promise<ArchiveSnapshot | null> { + return getArchiveCached(); +} + +/** Returns the archive entry matching `slug`, or null if unknown. The + * archive map is keyed by builder address, so we linear-scan the + * ~100 rows once per hit — trivially cheap, and the caller wrapping + * a per-slug route already hits `unstable_cache` upstream. */ +export async function getArchiveBuilderBySlug( + slug: string, +): Promise<HlArchiveBuilder | null> { + const snap = await getArchive(); + if (!snap) return null; + for (const b of Object.values(snap.builders)) { + if (b.slug === slug) return b; + } + return null; +} + +export function hlArchiveConfigured(): boolean { + return creds() !== null; +} diff --git a/src/lib/hl-builder-stats.ts b/src/lib/hl-builder-stats.ts index 4f1ef6c39..dd7935749 100644 --- a/src/lib/hl-builder-stats.ts +++ b/src/lib/hl-builder-stats.ts @@ -88,8 +88,12 @@ const PERCENTILE_ORDER: PercentileBucket["bucket"][] = [ * vectors. Each query is bounded by a sane abort signal; one missing * gauge returns 0/empty rather than propagating — the page still * renders, the affected section is hidden cleanly. + * + * Exported so the worker can call the uncached Prom path directly before + * parking the result in Upstash; the public reader (fetchHlBuilderStats) + * goes through the snapshot layer + unstable_cache below. */ -async function fetchHlBuilderStatsRaw( +export async function fetchHlBuilderStatsFresh( slug: string, ): Promise<HlBuilderStats | null> { const url = promUrl(); @@ -185,6 +189,35 @@ async function fetchHlBuilderStatsRaw( }; } +/** + * Snapshot-first reader for a single builder's dashboard payload. Same + * protocol as the cohort readers: Upstash blob → live Prom + writeback + * → null. Vercel prod has no Prom access post blob-only migration, so + * the snapshot path is the only working code path in prod; the live + * fallback exists for local dev and the worker's own recovery writes. + */ +async function fetchHlBuilderStatsRaw( + slug: string, +): Promise<HlBuilderStats | null> { + const snapshot = await readCohortSnapshot<HlBuilderStats>( + `hl-builder:${slug}`, + ); + if (snapshot) return snapshot.data; + const fresh = await fetchHlBuilderStatsFresh(slug); + if (fresh) { + try { + await writeCohortSnapshot(`hl-builder:${slug}`, fresh); + } catch (err) { + console.warn( + `hl-builder:${slug} writeback failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + return fresh; +} + /** Cross-request cache. The raw fn fans out 13 Prom queries per builder * page render — without this, every cold visitor on a /products/<hl * builder> page paid 200 to 500 ms of Prom round trips. Tagged @@ -251,6 +284,43 @@ export type HlHip3Summary = { * misaligned payload. The cohort-snapshot module appends its own `:v1`. */ const HL_FRONTENDS_KEY = "hl-frontends"; const HL_HIP3_KEY = "hl-hip3"; +const HL_HISTORY_KEY = "hl-history"; + +/** One evenly-spaced point on a rolling-window history series. `v = null` + * means the underlying gauge had no sample at that timestamp (harness + * gap, pre-mainnet epoch, ...) — kept as a sentinel so a range's shape + * is stable across frontends and the chart can draw a gap instead of + * interpolating through zero. + * Legacy shape, kept exported for external consumers; the KV blob now + * ships the compact layout below. */ +export type HlHistoryPoint = { t: number; v: number | null }; +/** Compact per-frontend payload. Timestamps are reconstructed from the + * outer `t0 + step * i`. `firstIdx` drops leading nulls (pre-launch + * history for late builders); intermediate nulls stay so the chart can + * still draw gaps. Values are rounded to nearest USD integer. */ +export type HlHistoryFrontendCompact = { + slug: string; + name: string; + /** Index in the shared time axis of the first non-null sample. */ + firstIdx: number; + /** Rolling 30d USD builder-fee revenue, one value per step from + * `t0 + step * firstIdx` onwards. */ + fees: (number | null)[]; + /** Rolling 30d USD notional volume, aligned with `fees`. */ + volume: (number | null)[]; +}; +export type HlHistorySummary = { + /** Range fetched in ms (365d). */ + windowMs: number; + /** Step between points in seconds (86400 = 1 day). */ + step: number; + /** First timestamp (ms) of the shared axis. Consumers rebuild + * `t(i) = t0 + step * 1000 * i`. */ + t0: number; + frontends: HlHistoryFrontendCompact[]; + /** Unix seconds when the summary was assembled. */ + asOf: number; +}; /** * Fetch a leaderboard-ready slice of every tracked HL builder, in 4 @@ -553,12 +623,234 @@ export async function fetchHlHip3Cohort(): Promise<HlHip3Summary | null> { return fetchHlHip3CohortCached(); } +/** + * Uncached fetch of the 12-month rolling fees + volume series for every + * active Hyperliquid frontend (current `fees_30d > 0`, ~98 rows). Two + * range queries per frontend (fees + volume) daily-stepped over 365d, + * batched 20 at a time so Prom isn't hammered by ~200 concurrent range + * scans. + * + * The output is written in compact form to fit under the ~300 KB blob + * budget (Redis/Upstash comfortable range): shared time axis via `t0` + * + `step`, per-frontend leading-null slice via `firstIdx`, rounded USD + * integers instead of full floats. Empty series are dropped. + * + * Ranks the input by an instant vector on `hl_frontend_fees_usd_30d_v2` + * so the chart's implicit "top-first" ordering matches the /hyperliquid + * leaderboard and the client can colour the top N in order. Returns + * null when Prom is unreachable so the reader can fall through to + * whatever snapshot Upstash has instead of poisoning the ISR cache + * with an empty chart. + * + * Exported so the worker can call the uncached Prom path directly before + * parking the result in Upstash; the public reader (fetchHlHistory) goes + * through the snapshot layer + unstable_cache below. + */ +export async function fetchHlHistoryFresh(): Promise<HlHistorySummary | null> { + const url = promUrl(); + if (!url) return null; + let prom: Prometheus; + try { + prom = new Prometheus(url); + } catch { + return null; + } + + const specs = await getSpecs(); + const hl = specs.find((s) => s.slug === "hyperliquid-frontends"); + const providers = hl?.providers ?? []; + const nameBySlug = new Map(providers.map((p) => [p.slug, p.name])); + + const currentFees = await queryVector(prom, `hl_frontend_fees_usd_30d_v2`); + if (!currentFees || currentFees.length === 0) return null; + + const sorted = currentFees + .filter((r) => Number.isFinite(r.value) && r.value > 0) + .sort((a, b) => b.value - a.value); + + const end = Math.floor(Date.now() / 1000); + const start = end - 365 * 86400; + const step = 86400; + const t0 = start * 1000; + + // Batch to keep the Prom load bounded: ~98 frontends × 2 range queries + // = ~200 in flight if we naively Promise.all. Groups of 20 = 5 rounds + // at 40 concurrent range queries. + const BATCH = 20; + const compactAll: HlHistoryFrontendCompact[] = []; + for (let i = 0; i < sorted.length; i += BATCH) { + const chunk = sorted.slice(i, i + BATCH); + const results = await Promise.all( + chunk.map(async (row) => { + const frontend = row.labels.builder; + if (!frontend) return null; + const [feesRange, volumeRange] = await Promise.all([ + queryRange( + prom, + `hl_frontend_fees_usd_30d_v2{builder="${frontend}"}`, + start, + end, + step, + ), + queryRange( + prom, + `hl_frontend_volume_usd_30d_v2{builder="${frontend}"}`, + start, + end, + step, + ), + ]); + if (!feesRange && !volumeRange) return null; + return toCompactFrontend( + frontend, + nameBySlug.get(frontend) ?? frontend, + feesRange ?? [], + volumeRange ?? [], + ); + }), + ); + for (const r of results) { + if (r) compactAll.push(r); + } + } + + if (compactAll.length === 0) return null; + + return { + windowMs: 365 * 86400 * 1000, + step, + t0, + frontends: compactAll, + asOf: Math.floor(Date.now() / 1000), + }; +} + +/** Drop leading nulls, round to integer USD, and skip the frontend + * entirely if both series are all-null. Intermediate nulls stay so the + * chart can render a gap instead of interpolating across a harness + * outage. */ +function toCompactFrontend( + slug: string, + name: string, + fees: HlHistoryPoint[], + volume: HlHistoryPoint[], +): HlHistoryFrontendCompact | null { + const n = Math.max(fees.length, volume.length); + if (n === 0) return null; + let firstIdx = -1; + for (let i = 0; i < n; i++) { + const f = fees[i]?.v ?? null; + const v = volume[i]?.v ?? null; + if (f !== null || v !== null) { + firstIdx = i; + break; + } + } + if (firstIdx < 0) return null; + const feesOut: (number | null)[] = []; + const volOut: (number | null)[] = []; + for (let i = firstIdx; i < n; i++) { + const f = fees[i]?.v ?? null; + const v = volume[i]?.v ?? null; + feesOut.push(f === null ? null : Math.round(f)); + volOut.push(v === null ? null : Math.round(v)); + } + return { slug, name, firstIdx, fees: feesOut, volume: volOut }; +} + +/** Snapshot-first reader for the 12-month history blob. Same Upstash + * → live Prom + writeback → null protocol as the two cohort readers. + * The historical data changes slowly (daily stepped), so the + * unstable_cache TTL is 1h rather than 60 s — no need to fan out + * 20 range queries against Prom on every ISR cycle. */ +async function fetchHlHistoryRaw(): Promise<HlHistorySummary | null> { + const snapshot = await readCohortSnapshot<HlHistorySummary>(HL_HISTORY_KEY); + if (snapshot) return snapshot.data; + const fresh = await fetchHlHistoryFresh(); + if (fresh) { + try { + await writeCohortSnapshot(HL_HISTORY_KEY, fresh); + } catch (err) { + console.warn( + `hl-history writeback failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + return fresh; +} + +const fetchHlHistoryCached = unstable_cache( + fetchHlHistoryRaw, + ["hl-history-v2-compact"], + { revalidate: 3600, tags: ["hl-cohort", "hl-history"] }, +); + +export async function fetchHlHistory(): Promise<HlHistorySummary | null> { + return fetchHlHistoryCached(); +} + /** * Tiny vector helper. Returns `[{ labels, value }]` from an instant * vector query, or empty on error/empty result. Kept local to this * module because Sprint 2 is the only consumer; the broader Prometheus * client only needs scalars. */ +/** Range query helper: returns evenly-stepped `{ t, v }` points for a + * single-series PromQL selector. If Prom returns multiple series (which + * shouldn't happen when the selector pins one label value) they're + * averaged per timestamp. Missing samples become `null` so the chart + * can render a gap rather than interpolating through zero. Returns null + * on network / Prom error so the caller can decide to skip the frontend. */ +async function queryRange( + prom: Prometheus, + promql: string, + startSec: number, + endSec: number, + stepSec: number, +): Promise<HlHistoryPoint[] | null> { + try { + const res = await prom.queryRange( + promql, + new Date(startSec * 1000), + new Date(endSec * 1000), + stepSec, + ); + if (res.result.length === 0) return []; + const buckets = new Map<number, number[]>(); + for (const series of res.result) { + for (const [ts, raw] of series.values) { + const v = Number(raw); + if (!Number.isFinite(v)) continue; + const list = buckets.get(ts) ?? []; + list.push(v); + buckets.set(ts, list); + } + } + const points: HlHistoryPoint[] = []; + for (let ts = startSec; ts <= endSec; ts += stepSec) { + const vs = buckets.get(ts); + if (!vs || vs.length === 0) { + points.push({ t: ts * 1000, v: null }); + } else { + const mean = vs.reduce((s, v) => s + v, 0) / vs.length; + points.push({ + t: ts * 1000, + v: mean === 0 ? 0 : Number(mean.toPrecision(6)), + }); + } + } + return points; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + console.warn( + `prom.queryRange failed (${reason}) for query: ${promql.slice(0, 200)}`, + ); + return null; + } +} + async function queryVector( prom: Prometheus, promql: string, diff --git a/src/lib/live/config.ts b/src/lib/live/config.ts index 9f1f57189..105310bee 100644 --- a/src/lib/live/config.ts +++ b/src/lib/live/config.ts @@ -5,7 +5,7 @@ export const RELAY_WS_URL = process.env.NEXT_PUBLIC_RELAY_WS_URL ?? - "wss://ocb-stream-relay-production.up.railway.app/ws"; + "wss://stream.openchainbench.com/ws"; /** Default range opened on the live chart. */ export const DEFAULT_RANGE: import("./types").RangeKey = "10m"; diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 631d4b515..08c49fb73 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -100,7 +100,7 @@ const RAW: Record<string, string> = { "1rpc": "/logos/1rpc.svg", cloudflare: "/logos/cloudflare.svg", "base-official": "/logos/base.jpeg", - binance: "/logos/binance.svg", + binance: "/logos/binance.png", lava: "/logos/lava.webp", nodies: "/logos/nodies.png", tenderly: "/logos/tenderly.svg", diff --git a/src/lib/pm-stats.ts b/src/lib/pm-stats.ts index 4929acc7b..23e866e6e 100644 --- a/src/lib/pm-stats.ts +++ b/src/lib/pm-stats.ts @@ -20,7 +20,12 @@ * are rendered with null fields; the page degrades gracefully. */ +import { unstable_cache } from "next/cache"; import { Prometheus } from "@/lib/prometheus"; +import { + readCohortSnapshot, + writeCohortSnapshot, +} from "@/lib/cohort-snapshot"; export type PmVenueType = "onchain" | "offchain"; @@ -93,6 +98,12 @@ function promUrl(): string | null { return process.env.PROMETHEUS_URL?.trim() || null; } +/** Upstash key for the pm-hub cohort blob, written by the materialize + * worker after every tierA sweep. Bump the suffix if the summary shape + * changes so a stale-shape blob can never deserialize into a misaligned + * payload. The cohort-snapshot module appends its own `:v1`. */ +const PM_HUB_KEY = "pm-hub"; + /** * Fetch the venue + data feed cohort in one Promise.all fan out. Returns * null when Prom is unreachable so the page renders a configuration @@ -100,8 +111,12 @@ function promUrl(): string | null { * has no series yet (harness not yet deployed) returns a fully populated * shape with every numeric field nulled; the leaderboard then shows * dashes and the page stays useful. + * + * Exported so the materialize worker can call the uncached Prom path + * directly before parking the result in Upstash; the public reader + * (fetchPmCohort) goes through the snapshot layer + unstable_cache below. */ -export async function fetchPmCohort(): Promise<PmCohortSummary | null> { +export async function fetchPmCohortFresh(): Promise<PmCohortSummary | null> { const url = promUrl(); if (!url) return null; let prom: Prometheus; @@ -270,6 +285,41 @@ export async function fetchPmCohort(): Promise<PmCohortSummary | null> { }; } +/** + * Snapshot-first reader for the pm-hub cohort. Same protocol as the + * /hyperliquid and /perps hubs: Upstash blob → live Prom + writeback → + * null. The 60 s unstable_cache wrapper around this collapses concurrent + * requests; the worker keeps the Upstash blob fresh so the typical + * render never touches Prom. + */ +async function fetchPmCohortRaw(): Promise<PmCohortSummary | null> { + const snapshot = await readCohortSnapshot<PmCohortSummary>(PM_HUB_KEY); + if (snapshot) return snapshot.data; + const fresh = await fetchPmCohortFresh(); + if (fresh) { + try { + await writeCohortSnapshot(PM_HUB_KEY, fresh); + } catch (err) { + console.warn( + `pm-hub writeback failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + return fresh; +} + +const fetchPmCohortCached = unstable_cache( + fetchPmCohortRaw, + ["pm-hub-cohort-v1"], + { revalidate: 60, tags: ["pm-cohort"] }, +); + +export async function fetchPmCohort(): Promise<PmCohortSummary | null> { + return fetchPmCohortCached(); +} + function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); diff --git a/src/lib/prometheus.ts b/src/lib/prometheus.ts index 7ee168630..5c6b58e18 100644 --- a/src/lib/prometheus.ts +++ b/src/lib/prometheus.ts @@ -37,7 +37,13 @@ export class Prometheus { } catch { throw new Error("Prometheus baseUrl is not a valid URL"); } - if (u.protocol !== "https:") { + // Allow plain http only for explicit in-network hosts (worker → Prom + // over the docker bridge or local dev). Everything else (Vercel, + // production renders) must hit https to keep credentials off the + // wire and prevent accidental egress to a forged Prom. + const isInternalHost = + u.hostname === "ocb-prom" || u.hostname === "localhost" || u.hostname === "127.0.0.1"; + if (u.protocol !== "https:" && !(isInternalHost && u.protocol === "http:")) { throw new Error("Prometheus baseUrl must be https://"); } if (u.username || u.password) { @@ -376,6 +382,20 @@ const hostCheckCache = new Map<string, HostCheckEntry>(); const hostCheckInFlight = new Map<string, Promise<void>>(); async function assertPublicHost(url: URL): Promise<void> { + // The worker on the VPS reaches Prometheus over the docker bridge by + // its container name (resolves to a private 172.18.0.0/16 address). + // Letting the SSRF guard reject those would force the worker through + // the public Caddy → Prom hop, which is exactly the failure mode that + // broke under load. The constructor in the Prometheus class already + // gates plain-http baseUrls to this same allow-list, so anything that + // reaches here over http with one of these hostnames is trusted. + if ( + url.hostname === "ocb-prom" || + url.hostname === "localhost" || + url.hostname === "127.0.0.1" + ) { + return; + } // hostname keeps brackets for IPv6 literals; strip them so isIP can // recognise the address. let host = url.hostname; diff --git a/src/lib/search-featured.ts b/src/lib/search-featured.ts index 398d6f617..8345d860b 100644 --- a/src/lib/search-featured.ts +++ b/src/lib/search-featured.ts @@ -14,6 +14,7 @@ */ import { getBenchmarks } from "@/data/benchmarks"; +import { readMaterialized } from "@/lib/materialize/store"; import { leader, fieldValue } from "@/lib/citation"; const FEATURED_BENCH_SLUGS = [ @@ -87,3 +88,55 @@ export async function buildFeaturedLeaders(): Promise<FeaturedLeadersBlob> { /** Exported for the public endpoint + the dialog's TS type. */ export const FEATURED_SLUGS = ALL_SLUGS; + +/** + * Worker-safe variant of `buildFeaturedLeaders`. Reads each of the 12 + * featured/trending bench blobs directly from KV via `readMaterialized` + * instead of going through `getBenchmarks()`, which sits behind Next's + * `unstable_cache` + React `cache()` and throws + * `Invariant: incrementalCache missing` when invoked outside a + * Next request lifecycle (i.e. from the standalone worker tsx process). + * + * Same output shape as `buildFeaturedLeaders` — both write through the + * same `writeCohortSnapshot("search-featured", ...)` envelope. + */ +export async function buildFeaturedLeadersFromStore(): Promise<FeaturedLeadersBlob> { + const snapshots = await Promise.all( + ALL_SLUGS.map(async (slug) => { + try { + const snap = await readMaterialized(slug, ""); + return snap ? snap.bench : null; + } catch { + return null; + } + }), + ); + const bySlug = new Map( + snapshots + .filter((b): b is NonNullable<typeof b> => b !== null) + .map((b) => [b.slug, b]), + ); + + const card = (slug: string): FeaturedCardData | null => { + const b = bySlug.get(slug); + if (!b) return null; + const top = leader(b); + return { + slug: b.slug, + title: b.title, + category: b.category, + unit: b.unit, + value: fieldValue(b), + leader: top ? { name: top.name, slug: top.slug } : null, + }; + }; + + return { + featured: FEATURED_BENCH_SLUGS.map(card).filter( + (x): x is FeaturedCardData => x !== null, + ), + trending: TRENDING_BENCH_SLUGS.map(card).filter( + (x): x is FeaturedCardData => x !== null, + ), + }; +} diff --git a/src/lib/spec.ts b/src/lib/spec.ts index bda826c5e..656570d7b 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -19,35 +19,28 @@ import type { Spec } from "@/lib/spec-schema"; // initialization" because both ends touch each other during ESM load. import { canonicalChainSlug } from "@/lib/chain-aliases"; import { renderBenchmarkText } from "@/lib/bench-template"; -import { MS_PER_MINUTE } from "@/lib/time-constants"; import { - buildEditorial, draftPlaceholderForSpec, filterSig, loadSpecsUncached, - parseFilterSig, - specToBenchmark, type BenchmarkFilters, } from "@/lib/materialize/load"; -import { - readSnapshot, - snapshotFromBenchmark, - writeSnapshot, -} from "@/lib/snapshot"; import { readMaterialized } from "@/lib/materialize/store"; export type { Spec } from "@/lib/spec-schema"; export type { BenchmarkFilters } from "@/lib/materialize/load"; export { bestForChain, injectLabels } from "@/lib/materialize/load"; -// ─── Materialized read path (phase 1, flag-gated) ──────────────────── -// When READ_FROM_STORE=1, benches are served from the worker-published -// snapshots (complete, carry-forward, ~60s fresh) instead of querying -// Prom at render time. The live path below stays as fallback: store -// miss, parse failure, or a snapshot older than STORE_MAX_AGE_MS (worker -// down) all fall through to the old behavior. Rollback = unset the flag. +// ─── Materialized read path (blob-only, no live Prom fallback) ──────── +// The worker is the sole Prom consumer; Vercel renders read the +// worker-published snapshots and never query Prom at render time. A +// stale snapshot is preferred over a render-time Prom fan-out — when +// the worker stalls, the chart shows aged data with a freshness badge +// instead of cascading a Vercel function into Prom's query queue, which +// is what crashed Prom under load. The READ_FROM_STORE flag is +// preserved as a kill switch (unset to disable blob reads entirely; +// every render then returns undefined → draft placeholder). const READ_FROM_STORE = process.env.READ_FROM_STORE === "1"; -const STORE_MAX_AGE_MS = 30 * MS_PER_MINUTE; async function benchFromStore( slug: string, @@ -56,12 +49,6 @@ async function benchFromStore( if (!READ_FROM_STORE) return null; const snap = await readMaterialized(slug, sig); if (!snap) return null; - if (Date.now() - snap.builtAt > STORE_MAX_AGE_MS) { - console.warn( - `[materialize] snapshot for ${slug}/${sig || "all"} is ${Math.round((Date.now() - snap.builtAt) / MS_PER_MINUTE)}min old, falling back to live`, - ); - return null; - } return snap.bench; } @@ -156,6 +143,43 @@ function overlayEditorial(stored: Benchmark, spec: Spec): Benchmark { return renderBenchmarkText(overlaid); } +// Strip lazy-loadable series fields from the cached Benchmark to bring +// it under Next.js's 2 MB unstable_cache item limit. Heavy benches +// (hl-frontends with 104 providers, wallet-labels-coverage, etc.) were +// blowing past that ceiling once series7d + series30d + per-panel +// 7d/30d arrays were included, silently failing the cache write and +// forcing every read to re-query Prom — root cause of the Railway +// egress blowout (2026-06-29, ~150 GB/day). +// +// Kept in the cached object: +// - extras.series24h (hub cards, mini-charts, ledger sparklines, OG) +// - extras.seriesByRegion24h +// - metricPanels[].seriesByProvider (24h, ledger panel rescaling) +// +// Stripped (lazy-fetched via /api/series/[slug]?range=7d|30d on tab +// click): +// - extras.series7d, extras.series30d +// - extras.seriesByRegion7d, extras.seriesByRegion30d +// - metricPanels[].seriesByProvider7d, seriesByProvider30d +// +// /api/series serves these on demand, CDN-cached (60 s s-maxage + 300 s +// SWR) so the cache miss only hits Prom once per (bench, range) per +// minute regardless of concurrent visitor count. +function slimBenchmarkForCache(b: Benchmark): Benchmark { + const slimPanels = b.metricPanels?.map((panel) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { seriesByProvider7d, seriesByProvider30d, ...rest } = panel; + return rest; + }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { series7d, series30d, seriesByRegion7d, seriesByRegion30d, ...slimExtras } = b.extras; + return { + ...b, + extras: slimExtras, + ...(slimPanels ? { metricPanels: slimPanels } : {}), + }; +} + // Per-bench unfiltered cache. ONE unstable_cache entry per slug so a // transient Prom hiccup on bench A doesn't poison the cache for benches // B, C, ...Z. Inside: if a spec marked `status: live` collapses to a @@ -170,52 +194,14 @@ const loadBenchmarkUnfilteredCached = unstable_cache( const specs = await loadSpecs(); const spec = specs.find((s) => s.slug === slug); if (!spec) return undefined; + // Blob-only: serve whatever the worker last published. Missing or + // unparseable blobs fall through to the aggregator's draft + // placeholder instead of fanning out a Prom build at render time — + // that fan-out is what saturated Prom's query queue under Vercel + // ISR re-render bursts. const stored = await benchFromStore(slug, ""); - if (stored) return overlayEditorial(stored, spec); - const promStart = Date.now(); - const bench = await specToBenchmark(spec, {}, { - onRendered: (rendered) => - writeSnapshot(spec.slug, snapshotFromBenchmark(rendered)), - }); - const promMs = Date.now() - promStart; - if (spec.status === "live" && bench.status === "draft") { - // Live spec, but Prom returned nothing this cycle. Try the - // persistent snapshot before giving up. This is the cold-start - // path: a fresh Vercel instance with no in-memory cache, called - // during a Prom blackout. With KV configured we serve the last - // good data; without KV we throw to preserve any previous cache - // value (or eventually fall through to the draft placeholder in - // the aggregator). - // [DRAFT-TRACE] temporary observability — remove once we've pinned - // the cause of intermittent draft renders. - console.warn( - `[DRAFT-TRACE] collapse slug=${slug} prom_ms=${promMs} → trying KV snapshot`, - ); - const kvStart = Date.now(); - const snap = await readSnapshot(slug); - const kvMs = Date.now() - kvStart; - if (snap) { - console.warn( - `[DRAFT-TRACE] kv_hit slug=${slug} kv_ms=${kvMs} → serving snapshot`, - ); - const editorial = buildEditorial(spec); - const reconstructed = renderBenchmarkText({ ...editorial, ...snap }); - // The reconstructed bench is live data, just sourced from KV - // instead of Prom. Mark providers as live (snapshot only - // captures providers that did return data). - for (const r of reconstructed.results) { - if (!r.availability) r.availability = "live"; - } - return reconstructed; - } - console.warn( - `[DRAFT-TRACE] kv_miss slug=${slug} kv_ms=${kvMs} → throwing to keep prev cache`, - ); - throw new Error( - `loadBenchmark(${slug}): live spec collapsed to draft, keeping prev cache`, - ); - } - return bench; + if (stored) return slimBenchmarkForCache(overlayEditorial(stored, spec)); + return undefined; }, // Version key bumped when Benchmark shape changes so stale cache entries // from a previous deploy can't surface objects missing newer fields. @@ -260,7 +246,14 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // not render on existing benches until the cache aged out. // v16: bumped to flush stale HL frontends snapshot after adding 6 // identified frontends in #772 (invo/bitget-wallet/etc.). - ["bench-unfiltered-v16"], + // v17: strip series7d/series30d/seriesByRegion7d/seriesByRegion30d + // and metricPanels[].seriesByProvider7d/30d from cached objects. Was + // pushing heavy benches (hl-frontends, wallet-labels-coverage) past + // unstable_cache's 2 MB limit, silently failing the cache write and + // forcing every render to re-query Prom. Root cause of the 150 GB/day + // Railway egress blowout (2026-06-29). Series for 7d/30d are now + // lazy-fetched via /api/series/<slug>?range=7d|30d on tab click. + ["bench-unfiltered-v17"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -397,7 +390,9 @@ const loadAllBenchmarksCached = unstable_cache( // sampleHealth / expectedN fields. // v20: bumped with bench-unfiltered-v16 to flush the HL frontends // snapshot that didn't include the 6 newly identified frontends. - ["all-benchmarks-v20"], + // v21: bumped with bench-unfiltered-v17 (slim cached objects — strip + // 7d/30d series from extras + panels so the cache stays under 2 MB). + ["all-benchmarks-v21"], { revalidate: 300, tags: ["benchmarks"] }, ); export const loadAllBenchmarks = cache(loadAllBenchmarksCached); @@ -450,31 +445,22 @@ const loadBenchmarkFiltered = unstable_cache( const specs = await loadSpecs(); const spec = specs.find((s) => s.slug === slug); if (!spec) return undefined; + // Blob-only: variant blobs are published by the worker's tier-B + // sweep. A missing variant blob means the worker has not covered + // this filter combination yet — return undefined so the caller can + // fall back to the unfiltered "All" view rather than spinning up a + // render-time Prom build. const stored = await benchFromStore(slug, sig); - if (stored) return overlayEditorial(stored, spec); - const bench = await specToBenchmark(spec, parseFilterSig(sig)); - // Same stale-while-revalidate as loadAllBenchmarks: if Prom drops the - // single bench we just queried to draft, throw so unstable_cache keeps - // the previous live entry instead of overwriting with n/a. - // - // Bug fix: previously checked `editorialStatus !== "live"`, which is - // sourced from the YAML and never changes at runtime — so the throw - // never fired for editorially-live benches. Comparing runtime - // `bench.status` to editorial `spec.status` catches the real collapse - // case: spec says live, Prom returned nothing, runtime fell to draft. - if (spec.status === "live" && bench.status === "draft") { - throw new Error( - `loadBenchmark(${slug}): live spec collapsed to draft, keeping prev cache`, - ); - } - return bench; + if (stored) return slimBenchmarkForCache(overlayEditorial(stored, spec)); + return undefined; }, // v4: bumped with bench-unfiltered-v7 (ledgerColumns). // v5: bumped with bench-unfiltered-v8 (sec unit). // v6: bumped with bench-unfiltered-v9 (bp unit). // v7: bumped with bench-unfiltered-v10 (dimensions overlay). // v8: bumped with bench-unfiltered-v11 (egress reduction). - ["bench-filters-v8"], + // v9: bumped with bench-unfiltered-v17 (slim cached objects). + ["bench-filters-v9"], { revalidate: 300, tags: ["benchmarks"] } ); diff --git a/src/types/hl-archive.ts b/src/types/hl-archive.ts new file mode 100644 index 000000000..d1d09b4d4 --- /dev/null +++ b/src/types/hl-archive.ts @@ -0,0 +1,106 @@ +/** + * Shared wire types for the Hyperliquid long-window archive layer. + * + * The archive is written by an out-of-process Go service (hl-archive) that + * tails the local hl-node fill stream, aggregates per-builder windows the + * Prom snapshot can't hold (90d, 180d, 1y, all-time), and parks the result + * in Upstash for the OpenChainBench Next.js app to read on demand. + * + * Wire shape is documented here so the Go side and the Next.js readers + * never drift: any field addition is a v2 of the Upstash key. + */ + +export type HlArchiveWindow = + | "24h" + | "7d" + | "30d" + | "90d" + | "180d" + | "1y" + | "all"; + +/** Long-window subset used by the API and the UI toggle for the windows + * the Prom snapshot cannot serve. Live windows (24h/7d/30d) flow through + * the existing Benchmark snapshot. */ +export type HlArchiveLongWindow = "90d" | "180d" | "1y" | "all"; + +export const HL_ARCHIVE_LONG_WINDOWS: readonly HlArchiveLongWindow[] = [ + "90d", + "180d", + "1y", + "all", +] as const; + +export const HL_ARCHIVE_WINDOWS: readonly HlArchiveWindow[] = [ + "24h", + "7d", + "30d", + ...HL_ARCHIVE_LONG_WINDOWS, +] as const; + +export type HlArchiveWindowTotals = { + volume_usd: number; + fees_usd: number; + fills: number; + /** Sum of daily distinct users over the window (user-days). Not the + * true across-days unique-users because the archive DuckDB does not + * retain per-day user sets — only cardinalities — to keep storage + * bounded. Absent on responses served by an older harness build. */ + users?: number; +}; + +export type HlArchiveDailyPoint = { + day: string; + vol: number; + fees: number; + fills: number; + /** Distinct user addresses seen at (day, builder). A user who traded + * multiple assets that day counts once. Absent on responses served + * by an older harness build. */ + users?: number; +}; + +export type HlArchiveBuilder = { + /** OCB provider slug (e.g. "phantom-perps"). Emitted by hl-archive from + * its builders.json registry so the frontend can look up a builder + * without maintaining its own address→slug map. Empty when the + * registry entry has no slug set (should never happen in prod). */ + slug: string; + name: string; + windows: Partial<Record<HlArchiveWindow, HlArchiveWindowTotals>>; + timeseries_daily?: HlArchiveDailyPoint[]; +}; + +export type ArchiveSnapshot = { + updated_at: string; + builders: Record<string, HlArchiveBuilder>; +}; + +/** Ranked, leaderboard-ready row emitted by the history API. Stable shape + * shared with the client so the UI can render archive rows and live + * Prom-derived rows from a single component. */ +export type HlArchiveRankedRow = { + slug: string; + name: string; + volume_usd: number; + fees_usd: number; + fills: number; + /** Distinct-user rollup for the row's window. Same semantic as + * HlArchiveWindowTotals.users (user-days for long windows, absent + * for Prom-sourced short windows because the live snapshot does + * not carry a users signal today). */ + users?: number; + rank: number; +}; + +export type HlArchiveHistoryResponse = { + window: HlArchiveWindow; + source: "prom" | "archive"; + updated_at: string; + rows: HlArchiveRankedRow[]; + /** Per-builder daily timeseries for the selected window, keyed by the + * same slug used in `rows[]`. Only emitted on archive-sourced + * responses (long windows: 90d/180d/1y/all). Powers the time-series + * chart when the reader selects a long window. */ + timeseries_daily?: Record<string, HlArchiveDailyPoint[]>; +}; diff --git a/vercel.json b/vercel.json index 996e15c83..7b2ecc384 100644 --- a/vercel.json +++ b/vercel.json @@ -14,18 +14,6 @@ { "path": "/api/cron/indexnow", "schedule": "30 4 * * *" - }, - { - "path": "/api/cron/snapshot-perp-cohort", - "schedule": "* * * * *" - }, - { - "path": "/api/cron/snapshot-hl-cohort", - "schedule": "* * * * *" - }, - { - "path": "/api/cron/warm-search-featured", - "schedule": "* * * * *" } ] } diff --git a/worker/index.ts b/worker/index.ts index 5851788a8..478e4dc2d 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -39,6 +39,21 @@ import { storeConfigured, touchSnapshot, } from "@/lib/materialize/store"; +import { + cohortSnapshotConfigured, + writeCohortSnapshot, +} from "@/lib/cohort-snapshot"; +import { fetchPerpCohortFresh } from "@/lib/perp-stats"; +import { + fetchHlBuilderStatsFresh, + fetchHlCohortFresh, + fetchHlHip3CohortFresh, + fetchHlHistoryFresh, +} from "@/lib/hl-builder-stats"; +import { fetchPmCohortFresh } from "@/lib/pm-stats"; +import { fetchChainKpisFresh } from "@/lib/chain-kpis"; +import { CHAINS } from "@/lib/chains"; +import { buildFeaturedLeadersFromStore } from "@/lib/search-featured"; import type { Benchmark, MetricPanel } from "@/types/benchmark"; import type { Spec } from "@/lib/spec-schema"; @@ -330,6 +345,66 @@ async function sweep(iteration: number): Promise<void> { (e) => noteHeartbeat(false, e), ); + // Cohort snapshots used by the hub pages and the search dialog. Each + // builder hits Prom directly (via the in-network http://ocb-prom:9090 + // URL), so they don't add load on the public reverse proxy. Failures + // are isolated per blob — a bad perp fetch doesn't block the HL or + // featured-leaders writes. + if (cohortSnapshotConfigured()) { + const cohortStart = Date.now(); + // Enumerate active HL builders once so per-builder dashboard jobs + // (biggest day, milestones, coin shares, ...) can be scheduled next + // to the cohort blob. Falls back to an empty list on brownout so a + // dead Prom doesn't tear down the whole cohort sweep. + const hlCohort = await fetchHlCohortFresh().catch(() => null); + const hlBuilders = hlCohort?.rows.map((r) => r.slug) ?? []; + const cohortJobs: Array<{ + key: string; + build: () => Promise<unknown>; + }> = [ + { key: "perp-cohort", build: () => fetchPerpCohortFresh() }, + { key: "hl-frontends", build: () => Promise.resolve(hlCohort) }, + { key: "hl-hip3", build: () => fetchHlHip3CohortFresh() }, + { key: "hl-history", build: () => fetchHlHistoryFresh() }, + { key: "pm-hub", build: () => fetchPmCohortFresh() }, + { key: "search-featured", build: () => buildFeaturedLeadersFromStore() }, + // One blob per chain slug so a stale reading on one chain doesn't + // pollute the others. Small enough that the Promise.allSettled loop + // absorbs 20+ jobs without meaningful sweep-time overhead. + ...CHAINS.map((c) => ({ + key: `chain-kpis:${c.slug}`, + build: () => fetchChainKpisFresh(c.slug), + })), + // Per-builder dashboard payloads. Each fans out ~13 Prom queries, + // so ~91 builders × 13 = ~1200 queries; the Prom client's 64-slot + // global semaphore keeps the concurrency bounded and the whole + // batch typically completes in a few seconds. + ...hlBuilders.map((slug) => ({ + key: `hl-builder:${slug}`, + build: () => fetchHlBuilderStatsFresh(slug), + })), + ]; + const results = await Promise.allSettled( + cohortJobs.map(async ({ key, build }) => { + const blob = await build(); + if (!blob) throw new Error("builder returned null"); + await writeCohortSnapshot(key, blob); + return key; + }), + ); + const okCount = results.filter((r) => r.status === "fulfilled").length; + const failures = results + .map((r, i) => + r.status === "rejected" + ? `${cohortJobs[i].key}: ${r.reason instanceof Error ? r.reason.message : r.reason}` + : null, + ) + .filter(Boolean) as string[]; + console.log( + `[worker] cohort done in ${((Date.now() - cohortStart) / 1000).toFixed(1)}s (${okCount}/${cohortJobs.length} ok)${failures.length ? `: ${failures.join("; ")}` : ""}`, + ); + } + // Keep the bench pages warm on every cycle: ISR revalidate is 60s, so // a ping per sweep means the CDN always serves a fresh-enough copy // instantly to real visitors.